Skip to content

Commit 9e93480

Browse files
Merge feat/sw-background-audio: SW-path background audio (iOS)
Keep audio playing when the iOS app backgrounds during software-decoded (VP9 / AV1-without-HW / etc.) playback. The combined demux loop drops video and paces on the audio renderer while backgroundAudioOnly is set; the engine routes the SW host to audio-only on background-enter and resumes video on foreground. Verified headless via aetherctl bgaudio + unit tests + iOS/tvOS builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 5035e79 + ebc9b1b commit 9e93480

7 files changed

Lines changed: 299 additions & 11 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#if DEBUG
2+
import Foundation
3+
4+
// Test-only hooks for the aetherctl `bgaudio` harness. The SW-path background-audio keepalive is normally
5+
// driven by the iOS app lifecycle (UIApplication.didEnterBackground), which does not exist on macOS, so the
6+
// CLI toggles it directly. DEBUG-gated: absent from Release builds, so this is never shipped API.
7+
extension AetherEngine {
8+
9+
/// Enter / leave SW-path background-audio-only. No-op when the active backend is not the software host.
10+
@MainActor
11+
public func setSoftwareBackgroundAudioOnlyForTesting(_ on: Bool) {
12+
if on {
13+
softwareHost?.enterBackgroundAudioOnly()
14+
} else {
15+
softwareHost?.exitBackgroundAudioOnly()
16+
}
17+
}
18+
19+
/// Count of video frames the SW host has enqueued. Flat while background-audio-only drops video; rises
20+
/// again on foreground return. nil when the active backend is not the software host.
21+
@MainActor
22+
public var softwareVideoFramesEnqueuedForTesting: Int? {
23+
softwareHost?.framesEnqueued
24+
}
25+
}
26+
#endif

Sources/AetherEngine/AetherEngine.swift

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,27 @@ public final class AetherEngine: ObservableObject {
174174
enabled && (pipActive || state == .playing)
175175
}
176176

177+
/// What to do with the active video pipeline when the app enters the background. Pure so the lifecycle
178+
/// policy is unit-testable. Mirrors the spirit of the native keepalive onto the software path.
179+
enum BackgroundAction: Equatable {
180+
case doNothing // audio backend, or native keepalive: leave the running session alone
181+
case enterSoftwareAudioOnly // SW host kept alive: drop video in the demux loop, keep feeding audio
182+
case teardownVideo // release the video pipeline before idle suspension
183+
}
184+
185+
/// - keepVideoAlive: result of shouldKeepVideoAlive. Pass false on tvOS (the wedge-safe unconditional teardown).
186+
nonisolated static func backgroundAction(
187+
isAudioBackend: Bool,
188+
hasSoftwareHost: Bool,
189+
keepVideoAlive: Bool,
190+
state: PlaybackState
191+
) -> BackgroundAction {
192+
if isAudioBackend { return .doNothing }
193+
if keepVideoAlive { return hasSoftwareHost ? .enterSoftwareAudioOnly : .doNothing }
194+
guard state == .playing || state == .paused else { return .doNothing }
195+
return .teardownVideo
196+
}
197+
177198
/// 1 Hz diagnostics sampler. Separate ObservableObject for the same reason as `clock`: per-sample
178199
/// objectWillChange would re-render every engine-observing view (AetherEngine#29 follow-up).
179200
/// Observe only in stats overlays.
@@ -1229,7 +1250,7 @@ public final class AetherEngine: ObservableObject {
12291250
#if os(iOS)
12301251
// Paused while backgrounded with no PiP: the app will idle-suspend, so release the video pipeline
12311252
// now (wedge-safe, mirrors the unconditional background teardown). Audio backends are already spared.
1232-
if isBackgrounded && !pictureInPictureActive && !audioAVPlayerActive && audioHost == nil {
1253+
if isBackgrounded && !pictureInPictureActive && !audioAVPlayerActive && audioHost == nil && softwareHost == nil {
12331254
Task { @MainActor in await self.teardownVideoForBackground() }
12341255
}
12351256
#endif
@@ -1913,18 +1934,28 @@ public final class AetherEngine: ObservableObject {
19131934
guard let self = self else { return }
19141935
#if os(iOS)
19151936
self.isBackgrounded = true
1916-
#endif
1917-
if self.audioAVPlayerActive || self.audioHost != nil { return }
1918-
#if os(iOS)
19191937
// Keep the video pipeline alive for PiP / background audio while the app stays running.
19201938
// Wedge-safe: a pause while backgrounded tears down via pause() below, so nothing crosses
19211939
// an idle suspension. tvOS keeps the unconditional teardown.
1922-
if Self.shouldKeepVideoAlive(enabled: self.backgroundPlaybackEnabled,
1923-
pipActive: self.pictureInPictureActive,
1924-
state: self.state) { return }
1940+
let keepAlive = Self.shouldKeepVideoAlive(enabled: self.backgroundPlaybackEnabled,
1941+
pipActive: self.pictureInPictureActive,
1942+
state: self.state)
1943+
#else
1944+
let keepAlive = false // tvOS: wedge-safe unconditional teardown
19251945
#endif
1926-
guard self.state == .playing || self.state == .paused else { return }
1927-
await self.teardownVideoForBackground()
1946+
switch Self.backgroundAction(
1947+
isAudioBackend: self.audioAVPlayerActive || self.audioHost != nil,
1948+
hasSoftwareHost: self.softwareHost != nil,
1949+
keepVideoAlive: keepAlive,
1950+
state: self.state
1951+
) {
1952+
case .doNothing:
1953+
return
1954+
case .enterSoftwareAudioOnly:
1955+
self.softwareHost?.enterBackgroundAudioOnly()
1956+
case .teardownVideo:
1957+
await self.teardownVideoForBackground()
1958+
}
19281959
}
19291960
}
19301961
lifecycleObservers.append(bgObserver)
@@ -1933,7 +1964,11 @@ public final class AetherEngine: ObservableObject {
19331964
forName: UIApplication.didBecomeActiveNotification,
19341965
object: nil, queue: .main
19351966
) { [weak self] _ in
1936-
Task { @MainActor in self?.isBackgrounded = false }
1967+
Task { @MainActor in
1968+
guard let self else { return }
1969+
self.softwareHost?.exitBackgroundAudioOnly()
1970+
self.isBackgrounded = false
1971+
}
19371972
}
19381973
lifecycleObservers.append(fgObserver)
19391974
#endif

Sources/AetherEngine/Audio/AudioOutput.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ final class AudioOutput: @unchecked Sendable {
109109
return t.isFinite ? t : 0
110110
}
111111

112+
/// Whether the audio renderer can accept more samples. The combined demux loop normally paces on the
113+
/// video renderer; in background-audio-only mode (video dropped) it paces on this instead, so it does
114+
/// not buffer the rest of the file unbounded.
115+
var isReadyForMoreMediaData: Bool {
116+
renderer.isReadyForMoreMediaData
117+
}
118+
112119
/// Flush the audio renderer (call on seek).
113120
func flush() {
114121
lock.lock()

Sources/AetherEngine/Native/SoftwarePlaybackHost.swift

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,20 @@ final class SoftwarePlaybackHost {
220220
}
221221
}
222222

223+
/// Set by the engine on background-enter (iOS keepalive) and cleared on foreground. While true the
224+
/// combined demux loop drops video packets and paces on the audio renderer, so audio keeps playing in
225+
/// the background. The setter broadcasts the demux condition so a parked loop re-evaluates immediately.
226+
nonisolated(unsafe) private var _backgroundAudioOnly = false
227+
nonisolated var backgroundAudioOnly: Bool {
228+
get { flagsLock.lock(); defer { flagsLock.unlock() }; return _backgroundAudioOnly }
229+
set {
230+
flagsLock.lock(); _backgroundAudioOnly = newValue; flagsLock.unlock()
231+
demuxCondition.lock()
232+
demuxCondition.broadcast()
233+
demuxCondition.unlock()
234+
}
235+
}
236+
223237
// MARK: - Init
224238

225239
init() {
@@ -391,6 +405,22 @@ final class SoftwarePlaybackHost {
391405
isPlaying = false
392406
}
393407

408+
/// Background-enter (iOS keepalive): keep audio flowing, stop feeding video. The demux loop reads the flag.
409+
func enterBackgroundAudioOnly() {
410+
backgroundAudioOnly = true
411+
}
412+
413+
/// Foreground return: resume video. Flush the video decoder + renderer (NOT audio) so video resyncs at the
414+
/// next keyframe; the synchronizer is already at the audio time, so the keyframe presents promptly. Order
415+
/// matters: while backgroundAudioOnly is still true the loop drops video and never touches videoDecoder /
416+
/// renderer, so flushing here from the main actor cannot race the demux queue. Clear the flag last.
417+
func exitBackgroundAudioOnly() {
418+
guard backgroundAudioOnly else { return }
419+
videoDecoder.flush()
420+
renderer.flush()
421+
backgroundAudioOnly = false
422+
}
423+
394424
func setRate(_ newRate: Float) {
395425
lastRate = newRate
396426
audioOutput?.setRate(newRate)
@@ -602,6 +632,9 @@ final class SoftwarePlaybackHost {
602632
let getSeekGeneration: @Sendable () -> UInt64 = { [weak self] in
603633
self?.seekGeneration ?? 0
604634
}
635+
let getBackgroundAudioOnly: @Sendable () -> Bool = { [weak self] in
636+
self?.backgroundAudioOnly ?? false
637+
}
605638

606639
if liveSession, let ring {
607640
let readCursor: @Sendable () -> Int = { [weak self] in
@@ -683,6 +716,7 @@ final class SoftwarePlaybackHost {
683716
clockArmed: getClockArmed,
684717
markClockArmed: setClockArmed,
685718
seekGeneration: getSeekGeneration,
719+
backgroundAudioOnly: getBackgroundAudioOnly,
686720
onError: onError,
687721
onEnd: onEnd
688722
)
@@ -963,6 +997,7 @@ final class SoftwarePlaybackHost {
963997
clockArmed: @Sendable () -> Bool,
964998
markClockArmed: @Sendable () -> Void,
965999
seekGeneration: @Sendable () -> UInt64,
1000+
backgroundAudioOnly: @Sendable () -> Bool,
9661001
onError: @Sendable (String) -> Void,
9671002
onEnd: @Sendable () -> Void
9681003
) {
@@ -1089,8 +1124,14 @@ final class SoftwarePlaybackHost {
10891124
}
10901125

10911126
if streamIdx == videoStreamIndex {
1127+
// Background audio-only: drop video, don't gate on the non-draining display layer.
1128+
if backgroundAudioOnly() {
1129+
av_packet_unref(packet)
1130+
av_packet_free_safe(packet)
1131+
continue
1132+
}
10921133
// Back-pressure via SampleBufferRenderer.isReadyForMoreMediaData (not the deprecated layer property). Park on condition while paused to avoid 200 Hz CPU spin.
1093-
while !renderer.isReadyForMoreMediaData && !stopRequested() {
1134+
while !renderer.isReadyForMoreMediaData && !stopRequested() && !backgroundAudioOnly() {
10941135
if !isPlaying() {
10951136
condition.lock()
10961137
while !isPlaying() && !stopRequested() {
@@ -1101,6 +1142,12 @@ final class SoftwarePlaybackHost {
11011142
Thread.sleep(forTimeInterval: 0.005)
11021143
}
11031144
}
1145+
// Entered background while parked on back-pressure: drop this frame.
1146+
if backgroundAudioOnly() {
1147+
av_packet_unref(packet)
1148+
av_packet_free_safe(packet)
1149+
continue
1150+
}
11041151
if stopRequested() {
11051152
av_packet_unref(packet)
11061153
av_packet_free_safe(packet)
@@ -1120,6 +1167,26 @@ final class SoftwarePlaybackHost {
11201167
markClockArmed()
11211168
}
11221169
} else if streamIdx == audioStreamIndex, let aDec = audioDecoder, let aOut = audioOutput {
1170+
// Background audio-only: the video gate is bypassed, so pace on the audio renderer to avoid
1171+
// buffering the rest of the file. Park on condition while paused (same shape as the video gate).
1172+
if backgroundAudioOnly() {
1173+
while !aOut.isReadyForMoreMediaData && !stopRequested() && backgroundAudioOnly() {
1174+
if !isPlaying() {
1175+
condition.lock()
1176+
while !isPlaying() && !stopRequested() {
1177+
_ = condition.wait(until: Date(timeIntervalSinceNow: 0.5))
1178+
}
1179+
condition.unlock()
1180+
} else {
1181+
Thread.sleep(forTimeInterval: 0.005)
1182+
}
1183+
}
1184+
if stopRequested() {
1185+
av_packet_unref(packet)
1186+
av_packet_free_safe(packet)
1187+
break
1188+
}
1189+
}
11231190
audioPacketsSeen += 1
11241191
let buffers = aDec.decode(packet: packet)
11251192
if !buffers.isEmpty { audioBuffersProduced = true }
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import Foundation
2+
import AetherEngine
3+
4+
// MARK: - bgaudio
5+
6+
/// Drive a software-path source through the full engine, then toggle the SW-path background-audio-only flag
7+
/// (the macOS stand-in for the iOS background lifecycle) and verify audio keeps advancing while video is
8+
/// dropped, then resumes on foreground return. Exercises the real runDemuxLoop background branch headless.
9+
func runBackgroundAudio(url: URL, fgSeconds: Double, bgSeconds: Double) -> Int32 {
10+
print("aetherctl bgaudio: \(url.absoluteString) (fg=\(fgSeconds)s bg=\(bgSeconds)s)")
11+
// Must use CFRunLoopRun, not a blocking semaphore: AetherEngine is @MainActor, so parking the main thread
12+
// would deadlock the executor. The run loop also lets the host's time timer + Combine sinks fire.
13+
let box = UncheckedBox<Int32?>(nil)
14+
Task { @MainActor in
15+
box.value = await backgroundAudioTest(url: url, fgSeconds: fgSeconds, bgSeconds: bgSeconds)
16+
CFRunLoopStop(CFRunLoopGetMain())
17+
}
18+
CFRunLoopRun()
19+
return box.value ?? 1
20+
}
21+
22+
@MainActor
23+
private func backgroundAudioTest(url: URL, fgSeconds: Double, bgSeconds: Double) async -> Int32 {
24+
let engine: AetherEngine
25+
do {
26+
engine = try AetherEngine()
27+
} catch {
28+
print("engine init failed: \(error.localizedDescription)")
29+
return 1
30+
}
31+
do {
32+
try await engine.load(url: url)
33+
} catch {
34+
print("load failed: \(error.localizedDescription)")
35+
return 1
36+
}
37+
let backend = engine.playbackBackend
38+
print("backend=\(backend.rawValue) duration=\(String(format: "%.1f", engine.duration))s")
39+
guard backend == .software else {
40+
print("FAIL: expected backend .software, got \(backend.rawValue) (source did not route to the SW path)")
41+
return 1
42+
}
43+
engine.play()
44+
45+
func vframes() -> Int { engine.softwareVideoFramesEnqueuedForTesting ?? 0 }
46+
func sample(_ phase: String) {
47+
let fp = physFootprintBytes()
48+
print(String(format: " [%@] clock=%.2fs vframes=%d footprint=%.1fMB",
49+
phase, engine.currentTime, vframes(), Double(fp) / 1_048_576))
50+
}
51+
func tickFor(_ seconds: Double, phase: String) async {
52+
let ticks = max(1, Int(seconds * 2))
53+
for _ in 0..<ticks {
54+
try? await Task.sleep(nanoseconds: 500_000_000)
55+
sample(phase)
56+
}
57+
}
58+
59+
// Phase 1: foreground baseline (video gate active).
60+
print("--- foreground (video-gated) ---")
61+
await tickFor(fgSeconds, phase: "FG")
62+
let clockBeforeBg = engine.currentTime
63+
let vfBeforeBg = vframes()
64+
let fpBeforeBg = physFootprintBytes()
65+
66+
// Phase 2: background-audio-only (video dropped, pace on audio renderer).
67+
print("--- background-audio-only (video dropped, pace on audio) ---")
68+
engine.setSoftwareBackgroundAudioOnlyForTesting(true)
69+
await tickFor(bgSeconds, phase: "BG")
70+
let clockAfterBg = engine.currentTime
71+
let vfAfterBg = vframes()
72+
let fpAfterBg = physFootprintBytes()
73+
74+
// Phase 3: foreground again (video resumes at next keyframe).
75+
print("--- foreground again (video resumes) ---")
76+
engine.setSoftwareBackgroundAudioOnlyForTesting(false)
77+
await tickFor(fgSeconds, phase: "FG2")
78+
let vfAfterFg2 = vframes()
79+
80+
engine.stop()
81+
82+
let bgClockDelta = clockAfterBg - clockBeforeBg
83+
let bgVideoDelta = vfAfterBg - vfBeforeBg
84+
let bgFootprintDeltaMB = Double(fpAfterBg - fpBeforeBg) / 1_048_576
85+
let fg2VideoDelta = vfAfterFg2 - vfAfterBg
86+
87+
print("")
88+
print("=== BACKGROUND AUDIO PROBE RESULT ===")
89+
print(String(format: "Background clock advance: %.2fs over %.1fs wall (expect ~wall, audio alive)", bgClockDelta, bgSeconds))
90+
print("Background video frames: +\(bgVideoDelta) (expect ~0, video dropped)")
91+
print(String(format: "Background footprint delta: %+.1fMB (expect small, audio-paced not unbounded)", bgFootprintDeltaMB))
92+
print("Foreground-2 video frames: +\(fg2VideoDelta) (expect > 0, video resumed)")
93+
print("=====================================")
94+
95+
var ok = true
96+
if bgClockDelta < bgSeconds * 0.5 {
97+
print("FAIL: audio clock did not advance during background (\(String(format: "%.2f", bgClockDelta))s); the loop starved audio")
98+
ok = false
99+
}
100+
if bgVideoDelta > 2 {
101+
print("WARN: video frames advanced during background (+\(bgVideoDelta)); video should be dropped")
102+
}
103+
if fg2VideoDelta <= 0 {
104+
print("WARN: video did not resume after foreground return (+\(fg2VideoDelta))")
105+
}
106+
if ok {
107+
print("OK: background-audio-only kept audio advancing while video was dropped, and video resumed on return.")
108+
return 0
109+
}
110+
return 1
111+
}

Sources/aetherctl/main.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,21 @@ if first == "seektest" {
240240
exit(runSeekTest(url: parseSourceURL(urlArg), seeks: seeks, gapMs: gapMs, settleSeconds: settle))
241241
}
242242

243+
// SW-path background-audio keepalive harness (iOS background audio on the software decode path).
244+
if first == "bgaudio" {
245+
var rest = Array(args.dropFirst(2))
246+
let fg = takeDoubleFlag("--fg", from: &rest) ?? 3.0
247+
let bg = takeDoubleFlag("--bg", from: &rest) ?? 6.0
248+
guard let urlArg = rest.first(where: { !$0.hasPrefix("--") }) else {
249+
print("ERROR: bgaudio requires a <url> argument")
250+
print("Usage: aetherctl bgaudio [--fg N] [--bg N] <url>")
251+
exit(64)
252+
}
253+
rest.removeAll { $0 == urlArg }
254+
rejectStrayFlags(rest, subcommand: "bgaudio")
255+
exit(runBackgroundAudio(url: parseSourceURL(urlArg), fgSeconds: fg, bgSeconds: bg))
256+
}
257+
243258
if first == "smbtest" {
244259
var rest = Array(args.dropFirst(2))
245260
let reads = takeIntFlag("--reads", from: &rest) ?? 64

0 commit comments

Comments
 (0)