Skip to content

Commit fe7c954

Browse files
authored
feat: throttle the broadcast under thermal and Low Power pressure (#15)
Add a thermal + Low Power governor that sheds encoder, network, and camera load before iOS hard-throttles a long stream, reusing the existing adaptive path. ThermalPowerGovernor maps ProcessInfo state to a ceiling (bitrate scale + fps cap + facecam) that folds into the ABR's effectiveMaximum and frameInterval for both RTMP and SRT/WHIP; ScreenCaptureController observes thermal/power notifications, slows capture pacing, drops the facecam under heat, and exposes thermalNotice. Holds across Wi-Fi<->5G changes and SRT/WHIP connect. Adversarially reviewed; compile-verified on the simulator and device SDKs (on-device runtime validation still required).
1 parent 3e88ea1 commit fe7c954

4 files changed

Lines changed: 235 additions & 11 deletions

File tree

Stream/ScreenCaptureController.swift

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ private let captureLog = Logger(subsystem: "com.joeblau.Stream", category: "scre
1818
final class ScreenCaptureController: NSObject {
1919
private(set) var isLive = false
2020
private(set) var errorMessage: String?
21+
/// A user-facing note when the device's thermal/power state is forcing a
22+
/// quality reduction, or nil when unrestricted. Read by the (future) stats HUD.
23+
private(set) var thermalNotice: String?
2124

2225
@ObservationIgnored private let picker = SCContentSharingPicker.shared
2326
@ObservationIgnored private var pendingSettings: StreamSettings?
@@ -27,6 +30,8 @@ final class ScreenCaptureController: NSObject {
2730
@ObservationIgnored private var publisherTask: Task<Void, Never>?
2831
@ObservationIgnored private var heartbeatTask: Task<Void, Never>?
2932
@ObservationIgnored private var micVolumeObserver: DarwinSignalObserver?
33+
@ObservationIgnored private var lastAppliedCeiling: ThermalPowerCeiling?
34+
@ObservationIgnored private var thermalApplyTask: Task<Void, Never>?
3035

3136
override init() {
3237
super.init()
@@ -35,6 +40,21 @@ final class ScreenCaptureController: NSObject {
3540
micVolumeObserver = DarwinSignalObserver(name: BroadcastControl.micVolumeSignal) { [weak self] in
3641
Task { @MainActor [weak self] in self?.applyMicVolume() }
3742
}
43+
let center = NotificationCenter.default
44+
center.addObserver(self, selector: #selector(thermalOrPowerChanged),
45+
name: ProcessInfo.thermalStateDidChangeNotification, object: nil)
46+
center.addObserver(self, selector: #selector(thermalOrPowerChanged),
47+
name: NSNotification.Name.NSProcessInfoPowerStateDidChange, object: nil)
48+
}
49+
50+
deinit {
51+
NotificationCenter.default.removeObserver(self)
52+
}
53+
54+
/// Thermal-state and power-state notifications arrive on an arbitrary thread;
55+
/// hop to the main actor to re-evaluate the governor.
56+
@objc private nonisolated func thermalOrPowerChanged() {
57+
Task { @MainActor [weak self] in self?.applyThermalCeiling() }
3858
}
3959

4060
func presentPicker(settings: StreamSettings) {
@@ -137,6 +157,8 @@ final class ScreenCaptureController: NSObject {
137157
try await stream.startCapture()
138158
isLive = true
139159
publishState(true)
160+
lastAppliedCeiling = nil
161+
applyThermalCeiling()
140162
heartbeatTask = Task {
141163
while !Task.isCancelled {
142164
try? await Task.sleep(for: .seconds(3))
@@ -165,6 +187,9 @@ final class ScreenCaptureController: NSObject {
165187
heartbeatTask?.cancel()
166188
heartbeatTask = nil
167189
isLive = false
190+
thermalNotice = nil
191+
lastAppliedCeiling = nil
192+
thermalApplyTask = nil
168193
output?.finish()
169194

170195
if let stream, stream.isCapturing {
@@ -205,6 +230,34 @@ final class ScreenCaptureController: NSObject {
205230
Task { await publisher.setMicVolume(volume) }
206231
}
207232

233+
/// Recomputes the thermal/Low-Power ceiling and pushes it to the encoder (via
234+
/// the adaptive controller) and the compositor. Reuses the existing throttle
235+
/// path rather than adding a parallel one. No-op while not capturing.
236+
private func applyThermalCeiling() {
237+
guard let publisher, let output else { return }
238+
let ceiling = ThermalPowerGovernor.current()
239+
guard ceiling != lastAppliedCeiling else { return }
240+
lastAppliedCeiling = ceiling
241+
thermalNotice = ceiling.notice
242+
output.applyThermalProfile(frameRateCap: ceiling.frameRateCap,
243+
allowPiP: ceiling.allowPiP)
244+
let scale = ceiling.bitRateScale
245+
let cap = ceiling.frameRateCap
246+
// Serialize applies in submission order so two notifications firing close
247+
// together cannot land on the ABR actor out of order and leave a stale
248+
// ceiling stored while thermalNotice reports otherwise.
249+
let previous = thermalApplyTask
250+
thermalApplyTask = Task {
251+
await previous?.value
252+
await publisher.setThermalCeiling(bitRateScale: scale, frameRateCap: cap)
253+
}
254+
if ceiling.notice != nil {
255+
captureLog.notice("Thermal governor engaged: fpsCap=\(cap, privacy: .public) bitrateScale=\(scale, privacy: .public) lowPower=\(ProcessInfo.processInfo.isLowPowerModeEnabled, privacy: .public)")
256+
} else {
257+
captureLog.info("Thermal governor cleared; full quality restored")
258+
}
259+
}
260+
208261
private func configurePreferredMicrophone(_ uid: String?) async {
209262
let session = AVAudioSession.sharedInstance()
210263
var activated = false
@@ -321,7 +374,7 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
321374
/// so the screen callback itself must throttle down to `settings.frameRate` —
322375
/// otherwise the encoder is flooded (irregular pacing + CPU/thermal spikes that
323376
/// stutter both video and audio). Touched only on the serial `sampleQueue`.
324-
private let targetFrameInterval: CMTime
377+
private var targetFrameInterval: CMTime
325378
private var nextVideoDeadline: CMTime = .invalid
326379

327380
init(publisher: any Publisher,
@@ -422,6 +475,25 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
422475
func setMicVolume(_ volume: Double) {
423476
micLevelMeter.setGain(volume)
424477
}
478+
479+
/// Applies the device thermal/Low-Power profile to the capture side: paces the
480+
/// screen callback to the capped frame rate (on the serial sample queue) and
481+
/// stops the facecam under pressure so the camera + compositor stop burning
482+
/// power. Re-arms the facecam when the device recovers. PiP toggling only
483+
/// applies when the user has the facecam enabled.
484+
func applyThermalProfile(frameRateCap: Int, allowPiP: Bool) {
485+
sampleQueue.async { [weak self] in
486+
guard let self else { return }
487+
let fps = max(1, min(self.settings.frameRate, frameRateCap))
488+
self.targetFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
489+
}
490+
guard settings.pipEnabled else { return }
491+
if allowPiP {
492+
facecam.start(with: settings)
493+
} else {
494+
facecam.stop()
495+
}
496+
}
425497
}
426498

427499
/// Computes a throttled post-gain RMS level from ScreenCaptureKit microphone
@@ -533,6 +605,7 @@ private final class ScreenCaptureMicrophoneMeter: @unchecked Sendable {
533605
final class ScreenCaptureController {
534606
private(set) var isLive = false
535607
private(set) var errorMessage: String?
608+
private(set) var thermalNotice: String?
536609

537610
func presentPicker(settings: StreamSettings) {
538611
errorMessage = "Screen capture requires a physical iOS 27 device."

Stream/ThermalPowerGovernor.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Foundation
2+
3+
/// A throttle ceiling derived from the device's thermal and power state.
4+
///
5+
/// The broadcast has no environmental awareness on its own: a healthy-network
6+
/// stream encodes at full rate until iOS hard-throttles or terminates it. This
7+
/// ceiling is fed into the *existing* adaptive path — the network ABR still owns
8+
/// the final rate by taking the `min()` of the network and thermal ceilings — so
9+
/// a hot or battery-constrained device sheds encoder, network, and camera load
10+
/// before the OS does it for us.
11+
struct ThermalPowerCeiling: Equatable {
12+
/// Fraction of the configured maximum video bitrate to allow (0...1).
13+
var bitRateScale: Double
14+
/// Hard frame-rate ceiling. `.max` means "no thermal cap".
15+
var frameRateCap: Int
16+
/// When false, the facecam is stopped to shed camera + compositor cost.
17+
var allowPiP: Bool
18+
/// User-facing reason, or nil when the device is unrestricted.
19+
var notice: String?
20+
21+
static let unrestricted = ThermalPowerCeiling(
22+
bitRateScale: 1.0, frameRateCap: .max, allowPiP: true, notice: nil
23+
)
24+
}
25+
26+
/// Maps `ProcessInfo` thermal + Low Power state to a `ThermalPowerCeiling`.
27+
enum ThermalPowerGovernor {
28+
/// Pure policy: state in, ceiling out. Side-effect free so the mapping reads
29+
/// clearly and can be reasoned about (and exercised) in isolation.
30+
static func ceiling(thermalState: ProcessInfo.ThermalState,
31+
lowPowerMode: Bool) -> ThermalPowerCeiling {
32+
switch thermalState {
33+
case .critical:
34+
// Shed aggressively — the OS is about to throttle or kill us anyway.
35+
return ThermalPowerCeiling(bitRateScale: 0.35, frameRateCap: 10,
36+
allowPiP: false,
37+
notice: "Cooling down — quality reduced")
38+
case .serious:
39+
return ThermalPowerCeiling(bitRateScale: 0.6, frameRateCap: 24,
40+
allowPiP: false,
41+
notice: "Device warm — quality reduced")
42+
case .fair, .nominal:
43+
// Mild thermals are fine; only Low Power Mode pulls the rate back.
44+
return lowPowerMode
45+
? ThermalPowerCeiling(bitRateScale: 0.75, frameRateCap: 30,
46+
allowPiP: false,
47+
notice: "Low Power Mode — quality reduced")
48+
: .unrestricted
49+
@unknown default:
50+
return .unrestricted
51+
}
52+
}
53+
54+
/// The ceiling for the device's current thermal + power state.
55+
static func current() -> ThermalPowerCeiling {
56+
ceiling(thermalState: ProcessInfo.processInfo.thermalState,
57+
lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled)
58+
}
59+
}

StreamBroadcast/RTMPPublisher.swift

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,12 @@ actor RTMPPublisher: Publisher {
754754
}
755755
}
756756

757+
func setThermalCeiling(bitRateScale: Double, frameRateCap: Int) async {
758+
await networkController.setThermalCeiling(bitRateScale: bitRateScale,
759+
frameRateCap: frameRateCap,
760+
applyingTo: stream)
761+
}
762+
757763
/// Appends a (raw or composited) screen video buffer. Dropped until the output
758764
/// size is locked, so the encoder never starts at the wrong dimensions.
759765
func appendVideo(_ sb: CMSampleBuffer) async {
@@ -983,7 +989,17 @@ actor BroadcastAdaptiveBitRateController: StreamBitRateStrategy {
983989
private var currentInterface: NetworkPathSnapshot.Interface?
984990
private var lastGoodTarget: [NetworkPathSnapshot.Interface: Int] = [:]
985991

986-
private var effectiveMaximum: Int { min(mamimumVideoBitRate, pathCeiling) }
992+
/// Thermal / Low-Power ceiling from `ThermalPowerGovernor`, composed with the
993+
/// network path ceiling. `min()`'d into `effectiveMaximum` so the upward probe
994+
/// never climbs past it, and folded into every frame-interval decision.
995+
private var thermalBitRateScale: Double = 1.0
996+
private var thermalFrameRateCap: Int = .max
997+
998+
private var effectiveMaximum: Int {
999+
let thermalCeiling = Int(Double(mamimumVideoBitRate) * thermalBitRateScale)
1000+
return max(minimumVideoBitRate,
1001+
min(min(mamimumVideoBitRate, pathCeiling), thermalCeiling))
1002+
}
9871003

9881004
init(maximumBitRate: Int, frameRate: Int) {
9891005
mamimumVideoBitRate = maximumBitRate
@@ -1103,7 +1119,10 @@ actor BroadcastAdaptiveBitRateController: StreamBitRateStrategy {
11031119
}
11041120
let raised = clamped > pathCeiling
11051121
pathCeiling = clamped
1106-
targetBitRate = min(targetBitRate, pathCeiling)
1122+
// Clamp to effectiveMaximum (which folds in the thermal ceiling), not just
1123+
// pathCeiling: otherwise an interface change re-seeds targetBitRate from a
1124+
// full-rate last-good value and silently defeats an active thermal cap.
1125+
targetBitRate = min(targetBitRate, effectiveMaximum)
11071126
if raised { healthySeconds = 0 } // restart the upward probe cleanly
11081127
await applyTarget(to: stream) // apply NOW, not at the next 1 Hz event
11091128
}
@@ -1118,13 +1137,62 @@ actor BroadcastAdaptiveBitRateController: StreamBitRateStrategy {
11181137
severe: Bool = false) async {
11191138
var video = await stream.videoSettings
11201139
video.bitRate = targetBitRate
1140+
video.frameInterval = frameInterval(severe: severe)
1141+
try? await stream.setVideoSettings(video)
1142+
}
1143+
1144+
/// The frame interval for the current congestion + thermal state. A larger
1145+
/// interval is a lower frame rate, so taking `max()` of the congestion and
1146+
/// thermal intervals always yields the more restrictive of the two.
1147+
private func frameInterval(severe: Bool) -> Double {
1148+
let congestionInterval: Double
11211149
if severe {
1122-
video.frameInterval = VideoCodecSettings.frameInterval10
1150+
congestionInterval = VideoCodecSettings.frameInterval10
11231151
} else if congestionActive, configuredFrameRate > 30 {
1124-
video.frameInterval = VideoCodecSettings.frameInterval30
1152+
congestionInterval = VideoCodecSettings.frameInterval30
11251153
} else {
1126-
video.frameInterval = preferredFrameInterval
1154+
congestionInterval = preferredFrameInterval
11271155
}
1156+
let cappedFrameRate = max(1, min(configuredFrameRate, thermalFrameRateCap))
1157+
let thermalInterval = max(0, (1.0 / Double(cappedFrameRate)) - 0.001)
1158+
return max(congestionInterval, thermalInterval)
1159+
}
1160+
1161+
/// Stores a thermal/Low-Power ceiling and clamps the current target to it.
1162+
/// Used when there is no stream to apply to yet (pre-connect); the value then
1163+
/// takes effect on the first adaptive event after connect.
1164+
func storeThermalCeiling(bitRateScale: Double, frameRateCap: Int) {
1165+
thermalBitRateScale = min(max(bitRateScale, 0.1), 1.0)
1166+
thermalFrameRateCap = max(1, frameRateCap)
1167+
// Only lower the target to fit a tightened ceiling. Leave healthySeconds
1168+
// alone: resetting it on every change would let brief thermal flapping
1169+
// across a boundary keep restarting the up-probe and starve recovery.
1170+
targetBitRate = min(targetBitRate, effectiveMaximum)
1171+
}
1172+
1173+
func currentFrameInterval() -> Double { frameInterval(severe: false) }
1174+
1175+
/// Applies the current (possibly thermally-clamped) target + frame interval to
1176+
/// a freshly-connected stream. SRT/WHIP emit no `.reset` event, so the
1177+
/// pre-connect store path relies on this to land the ceiling on the encoder.
1178+
func applyCurrentTarget(to stream: any StreamConvertible) async {
1179+
var video = await stream.videoSettings
1180+
video.bitRate = targetBitRate
1181+
video.frameInterval = frameInterval(severe: false)
1182+
try? await stream.setVideoSettings(video)
1183+
}
1184+
1185+
/// Applies a thermal/Low-Power ceiling to the encoder immediately, instead of
1186+
/// waiting for the next ~1 Hz adaptive event. Takes `any StreamConvertible` so
1187+
/// both the concrete RTMPStream and SessionPublisher's existential stream can
1188+
/// drive it.
1189+
func setThermalCeiling(bitRateScale: Double,
1190+
frameRateCap: Int,
1191+
applyingTo stream: any StreamConvertible) async {
1192+
storeThermalCeiling(bitRateScale: bitRateScale, frameRateCap: frameRateCap)
1193+
var video = await stream.videoSettings
1194+
video.bitRate = targetBitRate
1195+
video.frameInterval = frameInterval(severe: false)
11281196
try? await stream.setVideoSettings(video)
11291197
}
11301198

StreamBroadcast/SessionPublisher.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ protocol Publisher: Actor {
2727
nonisolated func enqueueMic(_ sb: CMSampleBuffer)
2828
nonisolated func enqueueApp(_ sb: CMSampleBuffer)
2929
func setMicVolume(_ volume: Double) async
30+
/// Applies a device thermal / Low-Power ceiling (bitrate scale + fps cap) to
31+
/// the encoder, composed with the network ceiling by the adaptive controller.
32+
func setThermalCeiling(bitRateScale: Double, frameRateCap: Int) async
3033
}
3134

3235
/// Publishes over SRT or WHIP via HaishinKit's protocol-agnostic `StreamSession`
@@ -162,14 +165,19 @@ actor SessionPublisher: Publisher {
162165
// Re-apply the locked encoder size to the fresh stream (across reconnects).
163166
if let outputSize {
164167
try? await stream.setVideoSettings(
165-
makeVideoSettings(await stream.videoSettings, size: outputSize)
168+
await makeVideoSettings(await stream.videoSettings, size: outputSize)
166169
)
167170
}
168171

169172
await mixer.addOutput(stream)
170173
self.session = session
171174
self.stream = stream
172175

176+
// Land any thermal ceiling stored before the stream existed. SRT/WHIP emit
177+
// no `.reset` event, so no adaptive event would otherwise apply a hot-at-
178+
// go-live ceiling to this fresh encoder until congestion or a state change.
179+
await networkController.applyCurrentTarget(to: stream)
180+
173181
try await session.connect { [weak self] in
174182
guard let self else { return }
175183
Task { await self.handleDisconnect() }
@@ -214,20 +222,36 @@ actor SessionPublisher: Publisher {
214222
outputSizeConfigured = true
215223
guard let stream else { return }
216224
do {
217-
try await stream.setVideoSettings(makeVideoSettings(await stream.videoSettings, size: size))
225+
try await stream.setVideoSettings(await makeVideoSettings(await stream.videoSettings, size: size))
218226
} catch {
219227
sessionLog.error("Video encoder configuration failed: \(String(describing: error), privacy: .public)")
220228
}
221229
}
222230

223-
private func makeVideoSettings(_ current: VideoCodecSettings, size: CGSize? = nil) -> VideoCodecSettings {
231+
func setThermalCeiling(bitRateScale: Double, frameRateCap: Int) async {
232+
// Apply immediately when connected; otherwise store the ceiling so the
233+
// first adaptive event after connect (the `.reset`) picks it up.
234+
if let stream {
235+
await networkController.setThermalCeiling(bitRateScale: bitRateScale,
236+
frameRateCap: frameRateCap,
237+
applyingTo: stream)
238+
} else {
239+
await networkController.storeThermalCeiling(bitRateScale: bitRateScale,
240+
frameRateCap: frameRateCap)
241+
}
242+
}
243+
244+
private func makeVideoSettings(_ current: VideoCodecSettings, size: CGSize? = nil) async -> VideoCodecSettings {
224245
let frameRate = min(max(settings.frameRate, 1), 30)
225246
var v = current
226247
if let size { v.videoSize = size }
227248
v.scalingMode = .letterbox
228-
v.bitRate = settings.videoBitrate
249+
// Seed from the adaptive controller's current target + frame interval so a
250+
// path or thermal ceiling learned before this (re)configuration is honored
251+
// immediately, instead of starting the fresh encoder at the full rate.
252+
v.bitRate = min(settings.videoBitrate, await networkController.currentTargetBitRate())
229253
v.expectedFrameRate = Double(frameRate)
230-
v.frameInterval = max(0, (1.0 / Double(frameRate)) - 0.001)
254+
v.frameInterval = await networkController.currentFrameInterval()
231255
v.maxKeyFrameIntervalDuration = 2
232256
v.bitRateMode = .average
233257
v.profileLevel = kVTProfileLevel_H264_Main_AutoLevel as String

0 commit comments

Comments
 (0)