Skip to content

Commit 578ed2b

Browse files
committed
feat: Implement low-load battery-saver WebRTC profile for device harness
- Added support for a low-load battery-saver WebRTC profile when the environment variable `LIVEKIT_NATIVE_DEVICE_HARNESS_BATTERY_SAVER_WEBRTC` is set. - Configured camera capture options to 320x180 at 10fps, with a single H.264 layer and a publisher bitrate of 250 kbps. - Updated diagnostics and reconnect logic to accommodate the new profile. - Enhanced the `DeviceHarnessViewModel` to manage soak automation and diagnostics effectively. - Modified `LocalVideoPublishPlan` and related classes to handle the new H.264 bitrate configuration. - Updated tests to validate the new power policy and its effects on video publishing. - Revised documentation to reflect changes in the harness behavior and configuration.
1 parent 355ad31 commit 578ed2b

16 files changed

Lines changed: 417 additions & 48 deletions

Examples/DeviceHarness/DeviceHarnessViewModel.swift

Lines changed: 156 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ final class DeviceHarnessViewModel: ObservableObject {
2929
private var didOpenPublisherHarnessDataChannel = false
3030
private var lastDataPublishFailure = ""
3131
private var lastAudioRouteDiagnosticAt = Date.distantPast
32+
private var isSoakAutomationRunning = false
3233
private static let voiceIsolationRecommendationKey =
3334
"LiveKitNativeDeviceHarness.didSuggestAppleVoiceIsolation"
3435

@@ -250,8 +251,11 @@ final class DeviceHarnessViewModel: ObservableObject {
250251
}
251252

252253
@discardableResult
253-
func refreshDiagnostics(reason: String) async -> RoomMediaDiagnostics? {
254-
guard let diagnostics = await currentDiagnostics() else {
254+
func refreshDiagnostics(
255+
reason: String,
256+
includeDataChannels: Bool = true
257+
) async -> RoomMediaDiagnostics? {
258+
guard let diagnostics = await currentDiagnostics(includeDataChannels: includeDataChannels) else {
255259
diagnosticsText = "No active room for diagnostics."
256260
return nil
257261
}
@@ -347,14 +351,16 @@ final class DeviceHarnessViewModel: ObservableObject {
347351
}
348352
}
349353

350-
private func currentDiagnostics() async -> RoomMediaDiagnostics? {
354+
private func currentDiagnostics(includeDataChannels: Bool = true) async -> RoomMediaDiagnostics? {
351355
guard let room else {
352356
return nil
353357
}
354358

355-
var diagnostics = await room.mediaDiagnostics()
359+
var diagnostics = await room.mediaDiagnostics(includeDataChannels: includeDataChannels)
356360
if let subscriberRoom {
357-
let subscriberDiagnostics = await subscriberRoom.mediaDiagnostics()
361+
let subscriberDiagnostics = await subscriberRoom.mediaDiagnostics(
362+
includeDataChannels: includeDataChannels
363+
)
358364
diagnostics = Self.combinedDiagnostics(
359365
publisherDiagnostics: diagnostics,
360366
subscriberDiagnostics: subscriberDiagnostics
@@ -459,6 +465,11 @@ final class DeviceHarnessViewModel: ObservableObject {
459465
}
460466

461467
var policy = WebRTCPowerPolicy.batterySaver
468+
policy.cameraCaptureOptions = CameraCaptureOptions(width: 320, height: 180, framesPerSecond: 10)
469+
policy.h264Bitrate = 250_000
470+
policy.adaptiveStream = false
471+
policy.subscriberAllowPause = false
472+
policy.applySubscriberAdaptiveTrackSettings = false
462473
policy.decodeSubscriberVideo = true
463474
return .custom(policy)
464475
}
@@ -523,10 +534,17 @@ final class DeviceHarnessViewModel: ObservableObject {
523534
status = "Observed \(name)."
524535
if name == "audio-route-change" {
525536
let now = Date()
526-
guard now.timeIntervalSince(lastAudioRouteDiagnosticAt) >= 5 else {
537+
let minimumInterval: TimeInterval = isSoakAutomationRunning ? 30 : 5
538+
guard now.timeIntervalSince(lastAudioRouteDiagnosticAt) >= minimumInterval else {
527539
return
528540
}
529541
lastAudioRouteDiagnosticAt = now
542+
if isSoakAutomationRunning {
543+
if let detail {
544+
emitHarnessLog(detail)
545+
}
546+
return
547+
}
530548
}
531549
if let detail {
532550
emitHarnessLog(detail)
@@ -742,10 +760,13 @@ final class DeviceHarnessViewModel: ObservableObject {
742760
var didObserveMediaContinuity = false
743761
var lastMediaContinuityCounters: (renderedFrames: Int, audioBuffers: Int)?
744762

763+
isSoakAutomationRunning = true
764+
defer { isSoakAutomationRunning = false }
765+
745766
emitHarnessLog(
746767
"soak_started: duration_minutes=\(clampedDurationMinutes) diagnostic_interval_seconds=\(intervalSeconds) reconnect_after_seconds=\(reconnectAtSeconds)"
747768
)
748-
await refreshDiagnostics(reason: "soak-start")
769+
await refreshDiagnostics(reason: "soak-start", includeDataChannels: false)
749770

750771
while Int(Date().timeIntervalSince(start)) < totalSeconds {
751772
let elapsedSeconds = Int(Date().timeIntervalSince(start))
@@ -754,7 +775,10 @@ final class DeviceHarnessViewModel: ObservableObject {
754775
reconnectReason = "scheduled"
755776
}
756777

757-
let diagnostics = await refreshDiagnostics(reason: "media-continuity")
778+
let diagnostics = await refreshDiagnostics(
779+
reason: "media-continuity",
780+
includeDataChannels: false
781+
)
758782
let canAttemptHealthReconnect = lastReconnectElapsedSeconds.map {
759783
elapsedSeconds - $0 >= reconnectCooldownSeconds
760784
} ?? true
@@ -787,16 +811,18 @@ final class DeviceHarnessViewModel: ObservableObject {
787811
reconnectAttemptCount += 1
788812
let attempt = reconnectAttemptCount
789813
emitHarnessLog("soak_reconnect: started reason=\(reconnectReason) attempt=\(attempt) elapsed_seconds=\(elapsedSeconds)")
790-
await disconnect()
791-
await connectAndPublish()
814+
let recovered = await reconnectForSoak(
815+
reason: reconnectReason,
816+
attempt: attempt
817+
)
792818
lastReconnectElapsedSeconds = Int(Date().timeIntervalSince(start))
793-
if isConnected {
819+
if recovered {
794820
reconnectCount += 1
795821
}
796822
didObserveMediaContinuity = false
797823
lastMediaContinuityCounters = nil
798-
emitHarnessLog("soak_reconnect: \(isConnected ? "completed" : "failed") reason=\(reconnectReason) attempt=\(attempt) elapsed_seconds=\(Int(Date().timeIntervalSince(start)))")
799-
await refreshDiagnostics(reason: "reconnect-recovery")
824+
emitHarnessLog("soak_reconnect: \(recovered ? "completed" : "failed") reason=\(reconnectReason) attempt=\(attempt) elapsed_seconds=\(Int(Date().timeIntervalSince(start)))")
825+
await refreshDiagnostics(reason: "reconnect-recovery", includeDataChannels: false)
800826
}
801827

802828
let remainingSeconds = max(1, totalSeconds - Int(Date().timeIntervalSince(start)))
@@ -806,7 +832,28 @@ final class DeviceHarnessViewModel: ObservableObject {
806832

807833
emitHarnessLog("duration_minutes: \(clampedDurationMinutes)")
808834
emitHarnessLog("soak_completed: duration_minutes=\(clampedDurationMinutes) reconnect_observed=\(reconnectCount > 0) reconnect_count=\(reconnectCount) reconnect_attempts=\(reconnectAttemptCount)")
809-
await refreshDiagnostics(reason: "soak-complete")
835+
var completionReady = soakCompletionDiagnosticsReady(
836+
await refreshDiagnostics(reason: "soak-complete", includeDataChannels: false)
837+
)
838+
if !completionReady {
839+
for attempt in 1...3 {
840+
emitHarnessLog("soak_complete_recovery: reconnecting attempt=\(attempt)")
841+
_ = await reconnectForSoak(
842+
reason: "soak-complete",
843+
attempt: attempt
844+
)
845+
try? await Task.sleep(nanoseconds: 5_000_000_000)
846+
completionReady = soakCompletionDiagnosticsReady(
847+
await refreshDiagnostics(reason: "soak-complete", includeDataChannels: false)
848+
)
849+
if completionReady {
850+
break
851+
}
852+
}
853+
if !completionReady {
854+
emitHarnessLog("soak_complete_recovery: failed")
855+
}
856+
}
810857
}
811858

812859
private func shouldRecoverDuringSoak(diagnostics: RoomMediaDiagnostics) -> Bool {
@@ -824,6 +871,101 @@ final class DeviceHarnessViewModel: ObservableObject {
824871
return false
825872
}
826873

874+
private func reconnectForSoak(reason: String, attempt: Int) async -> Bool {
875+
for retry in 1...3 {
876+
disconnectCurrentRoomsInBackground()
877+
try? await Task.sleep(nanoseconds: UInt64(retry) * 2_000_000_000)
878+
let connected = await connectAndPublishForSoak(timeoutSeconds: 45)
879+
if connected {
880+
return true
881+
}
882+
if retry < 3 {
883+
emitHarnessLog("soak_reconnect_retry: reason=\(reason) attempt=\(attempt) retry=\(retry)")
884+
}
885+
}
886+
887+
return false
888+
}
889+
890+
private func connectAndPublishForSoak(timeoutSeconds: UInt64) async -> Bool {
891+
await connectAndPublish(disconnectFirst: false)
892+
guard isConnected else {
893+
return false
894+
}
895+
896+
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
897+
while Date() < deadline {
898+
if let diagnostics = await currentDiagnostics(includeDataChannels: false),
899+
soakReconnectDiagnosticsReady(diagnostics)
900+
{
901+
return true
902+
}
903+
try? await Task.sleep(nanoseconds: 1_000_000_000)
904+
}
905+
906+
emitHarnessLog("soak_reconnect_timeout: seconds=\(timeoutSeconds)")
907+
disconnectCurrentRoomsInBackground()
908+
return false
909+
}
910+
911+
private func soakReconnectDiagnosticsReady(_ diagnostics: RoomMediaDiagnostics) -> Bool {
912+
if !soakMediaPathConnected(diagnostics) {
913+
return false
914+
}
915+
if diagnostics.counters.publisherAudioSenderCount < 1 ||
916+
diagnostics.counters.publisherVideoSenderCount < 1
917+
{
918+
return false
919+
}
920+
if diagnostics.counters.subscriberRenderedVideoFrameCount < 1 ||
921+
diagnostics.counters.subscriberAudioPlayoutScheduledBufferCount < 1
922+
{
923+
return false
924+
}
925+
926+
return true
927+
}
928+
929+
private func soakMediaPathConnected(_ diagnostics: RoomMediaDiagnostics) -> Bool {
930+
if diagnostics.connectionState != .connected {
931+
return false
932+
}
933+
if diagnostics.publisher.dtlsSRTPState != .connected {
934+
return false
935+
}
936+
if !subscriberToken.isEmpty,
937+
diagnostics.subscriber.dtlsSRTPState != .connected
938+
{
939+
return false
940+
}
941+
942+
return true
943+
}
944+
945+
private func soakCompletionDiagnosticsReady(_ diagnostics: RoomMediaDiagnostics?) -> Bool {
946+
guard let diagnostics else {
947+
return false
948+
}
949+
950+
let counters = diagnostics.counters
951+
let renderedFrames = max(
952+
counters.subscriberRenderedVideoFrameCount,
953+
videoView.renderedFrameCount
954+
)
955+
956+
if !soakMediaPathConnected(diagnostics) {
957+
return false
958+
}
959+
if counters.publisherAudioSenderCount < 1 || counters.publisherVideoSenderCount < 1 {
960+
return false
961+
}
962+
if renderedFrames < 1 || counters.subscriberAudioPlayoutScheduledBufferCount < 1 {
963+
return false
964+
}
965+
966+
return true
967+
}
968+
827969
private func emitHarnessLog(_ text: String) {
828970
print(text)
829971
}

Examples/DeviceHarness/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ The harness intentionally keeps the app small:
3636
- Runs long soak loops when `LIVEKIT_NATIVE_DEVICE_HARNESS_SOAK_MINUTES` is set,
3737
including periodic media-continuity snapshots and a scripted reconnect
3838
recovery marker.
39+
- Uses a permanent low-load battery-saver soak profile when
40+
`LIVEKIT_NATIVE_DEVICE_HARNESS_BATTERY_SAVER_WEBRTC=1` is set: 320x180@10fps,
41+
a single H.264 layer, 250 kbps publisher bitrate, subscriber decode/playout
42+
enabled, and adaptive subscriber pause disabled.
3943

4044
The production workflow can use `scripts/run_device_validation_on_device.sh` to
4145
build the app, install it on physical hardware, launch it with console capture,

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,8 @@ WebRTC power presets are opt-in through `RoomOptions.webRTCPowerMode`; leaving
389389
the mode nil preserves existing SDK behavior. `.batterySaver` starts camera
390390
helpers at 640x360@15, disables camera simulcast, uses 24 kbps Opus with DTX,
391391
requests low subscriber video, and can be paired with automatic Low Power Mode
392-
and thermal adaptation:
392+
and thermal adaptation. Custom policies can also cap the H.264 publisher
393+
bitrate for low-resolution runs:
393394

394395
```swift
395396
let room = Room(
@@ -409,6 +410,7 @@ subscriber power tradeoffs:
409410
let customPolicy = WebRTCPowerPolicy(
410411
cameraCaptureOptions: CameraCaptureOptions(width: 960, height: 540, framesPerSecond: 24),
411412
cameraSimulcast: false,
413+
h264Bitrate: 500_000,
412414
opusBitrate: 28_000,
413415
initialSubscribedVideoQuality: .medium,
414416
decodeSubscriberVideo: false

Sources/LiveKitNative/Core/LocalPublishPlan.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ struct LocalVideoPublishPlan: Equatable, Sendable {
2222
var ssrc: UInt32
2323
var payloadType: UInt8
2424
var nativeCameraSource: NativeCameraVideoSource?
25+
var h264Bitrate: UInt32?
2526

2627
init(
2728
track: LocalVideoTrack,
2829
options: TrackPublishOptions = .init(),
30+
powerPolicy: WebRTCPowerPolicy? = nil,
2931
ssrc: UInt32 = GeneratedSSRC.make(),
3032
payloadType: UInt8 = 102
3133
) {
@@ -41,6 +43,7 @@ struct LocalVideoPublishPlan: Equatable, Sendable {
4143
self.ssrc = ssrc
4244
self.payloadType = payloadType
4345
self.nativeCameraSource = track.nativeCameraSource
46+
self.h264Bitrate = powerPolicy?.h264Bitrate
4447
}
4548

4649
static func == (lhs: LocalVideoPublishPlan, rhs: LocalVideoPublishPlan) -> Bool {
@@ -53,7 +56,8 @@ struct LocalVideoPublishPlan: Equatable, Sendable {
5356
lhs.simulcast == rhs.simulcast &&
5457
lhs.codec == rhs.codec &&
5558
lhs.ssrc == rhs.ssrc &&
56-
lhs.payloadType == rhs.payloadType
59+
lhs.payloadType == rhs.payloadType &&
60+
lhs.h264Bitrate == rhs.h264Bitrate
5761
}
5862

5963
var videoEncodingLayers: [LocalVideoEncodingLayer] {
@@ -115,7 +119,11 @@ struct LocalVideoPublishPlan: Equatable, Sendable {
115119
}
116120

117121
private var recommendedBitrate: Int {
118-
switch max(width, height) {
122+
if let h264Bitrate {
123+
return max(1, Int(h264Bitrate))
124+
}
125+
126+
return switch max(width, height) {
119127
case 0..<721:
120128
1_500_000
121129
case 721..<1_081:

Sources/LiveKitNative/Core/Participant.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,11 @@ public final class LocalParticipant: Participant, @unchecked Sendable {
223223
}
224224

225225
public func publish(videoTrack: LocalVideoTrack, options: TrackPublishOptions = .init()) async throws -> LocalTrackPublication {
226-
let plan = LocalVideoPublishPlan(track: videoTrack, options: options)
226+
let plan = LocalVideoPublishPlan(
227+
track: videoTrack,
228+
options: options,
229+
powerPolicy: currentWebRTCPowerPolicy()
230+
)
227231
let publishedTrack = if let commandHandler = currentCommandHandler() {
228232
try await commandHandler.publishVideo(plan)
229233
} else {

Sources/LiveKitNative/Core/Room.swift

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -586,15 +586,26 @@ public final class Room: @unchecked Sendable {
586586
snapshots.connectionState
587587
}
588588

589-
public func mediaDiagnostics() async -> RoomMediaDiagnostics {
589+
public func mediaDiagnostics(includeDataChannels: Bool = true) async -> RoomMediaDiagnostics {
590590
let publisherStartup = publisherMediaStartupLock.withLock {
591591
(result: publisherMediaStartupResult, error: publisherMediaStartupError)
592592
}
593593
let subscriberStartup = subscriberMediaStartupLock.withLock {
594594
(result: subscriberMediaStartupResult, error: subscriberMediaStartupError)
595595
}
596-
let publisherDataChannelState = await publisherDataChannelObserverState()
597-
let subscriberDataChannelState = await subscriberDataChannelObserverState()
596+
let publisherDataChannelState: RoomDataChannelObserverState
597+
let subscriberDataChannelState: RoomDataChannelObserverState
598+
if includeDataChannels {
599+
publisherDataChannelState = await publisherDataChannelObserverState()
600+
subscriberDataChannelState = await subscriberDataChannelObserverState()
601+
} else {
602+
publisherDataChannelState = Self.skippedDataChannelObserverState(
603+
installed: publisherDataChannel != nil
604+
)
605+
subscriberDataChannelState = Self.skippedDataChannelObserverState(
606+
installed: subscriberDataChannel != nil
607+
)
608+
}
598609
let publisherRTPState = publisherRTPSenderLock.withLock {
599610
(
600611
audioSenderCount: publisherAudioRTPSendersBySID.count,
@@ -666,6 +677,15 @@ public final class Room: @unchecked Sendable {
666677
)
667678
}
668679

680+
private static func skippedDataChannelObserverState(installed: Bool) -> RoomDataChannelObserverState {
681+
RoomDataChannelObserverState(
682+
installed: installed,
683+
reliableOpen: false,
684+
lossyOpen: false,
685+
pendingPlanCount: 0
686+
)
687+
}
688+
669689
public var mediaSectionsRequirement: MediaSectionsRequirementInfo? {
670690
snapshots.mediaSectionsRequirement
671691
}

0 commit comments

Comments
 (0)