From 4342540b98d78f9699f14edeeeb988fdfd842e8a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 03:44:49 +0000 Subject: [PATCH] Harden playback against system audio restarts Co-authored-by: sanylax0 --- .../ContinuityKit/Sources/Playback/Deck.swift | 52 ++++++++++++++++--- .../Playback/Player+AudioSession.swift | 25 +++++++-- .../Sources/Playback/Player+Transport.swift | 29 ++++++++++- .../Sources/Playback/Player.swift | 49 ++++++++++++++++- .../PlayerTransitionRecoveryTests.swift | 33 ++++++++++++ 5 files changed, 174 insertions(+), 14 deletions(-) diff --git a/Packages/ContinuityKit/Sources/Playback/Deck.swift b/Packages/ContinuityKit/Sources/Playback/Deck.swift index b221d6c..76516f5 100644 --- a/Packages/ContinuityKit/Sources/Playback/Deck.swift +++ b/Packages/ContinuityKit/Sources/Playback/Deck.swift @@ -24,11 +24,18 @@ final class Deck { private let synthFormat: AVAudioFormat /// Current `accompPlayer → stemMixer` connection format (varies with single-file content). private var accompFormat: AVAudioFormat + /// Host-time boundary of the current engine run. A render timestamp older than this belongs + /// to a stopped engine and must never authorize `play(at:)`. + private var engineStartedAtHostTime: UInt64 = 0 private(set) var track: Track? private(set) var loadedDuration: TimeInterval = 0 /// True when this deck loaded separated stems (so vocal-aware transitions apply). private(set) var hasStems = false + /// Distinguishes a failed real-file reschedule from a synth deck, where seeking is unsupported. + var hasRealFile: Bool { accompFile != nil } + /// Engine stops invalidate player-node schedules even though this Deck still holds its files. + private(set) var needsRescheduleAfterEngineStart = false private var accompFile: AVAudioFile? // drives accompPlayer (accompaniment stem, or whole mix) private var vocalsFile: AVAudioFile? // drives vocalsPlayer (vocals stem), stem mode only @@ -53,6 +60,11 @@ final class Deck { engine.connect(deckMixer, to: mainMixer, format: synthFormat) // fixed; never reconnected } + func markEngineStarted(at hostTime: UInt64) { + engineStartedAtHostTime = hostTime + needsRescheduleAfterEngineStart = track != nil + } + /// Overall deck output gain — the crossfade ramps this. var volume: Float { get { deckMixer.outputVolume } @@ -123,6 +135,7 @@ final class Deck { accompPlayer.scheduleBuffer(buffer, at: nil, options: .loops, completionHandler: nil) loadedDuration = track.durationSeconds > 0 ? track.durationSeconds : 30 } + needsRescheduleAfterEngineStart = false return loadedDuration } @@ -144,14 +157,19 @@ final class Deck { // pre-interruption timeline. Anchoring to it puts the start time in the PAST, // which raises the same uncatchable exception → SIGABRT mid-song whenever the // system pokes the audio environment. - // So: require a render cycle within the last second (proof the engine is actively - // rendering) and anchor to NOW, not to the render timestamp. Otherwise fall back to - // two bare play() calls — on a freshly (re)started engine they land on the same - // quantum in practice. + // So: require a render cycle from THIS engine run, within the last second, and anchor + // to NOW rather than to the render timestamp. A quick stop/restart can leave the old + // timestamp less than a second old, so recency alone is not proof of freshness. + // Otherwise fall back to two bare play() calls — on a freshly (re)started engine they + // land on the same quantum in practice. let now = mach_absolute_time() if let render = accompPlayer.lastRenderTime, render.isHostTimeValid, - render.hostTime <= now, - now - render.hostTime < AVAudioTime.hostTime(forSeconds: 1.0) { + Deck.isRenderClockUsable( + renderHostTime: render.hostTime, + engineStartedAtHostTime: engineStartedAtHostTime, + now: now, + maximumAge: AVAudioTime.hostTime(forSeconds: 1.0) + ) { let start = AVAudioTime(hostTime: now + AVAudioTime.hostTime(forSeconds: 0.03)) accompPlayer.play(at: start) vocalsPlayer.play(at: start) @@ -171,9 +189,15 @@ final class Deck { } func stop() { - accompPlayer.stop(); vocalsPlayer.stop() + // A system configuration change can stop the engine after its notification was delivered. + // AVAudioPlayerNode.stop() may raise an uncatchable ObjC exception in that window. + if engine.isRunning { + accompPlayer.stop() + vocalsPlayer.stop() + } accompFile = nil; vocalsFile = nil; track = nil loadedDuration = 0; hasStems = false + needsRescheduleAfterEngineStart = false } /// Seconds elapsed since this deck last (re)started (from the always-present accompaniment). @@ -185,11 +209,12 @@ final class Deck { /// Frame-accurate seek for a real-file (or stem) deck. Returns `false` for a synth deck. func seekRealFile(to seconds: TimeInterval) -> Bool { - guard let aFile = accompFile else { return false } + guard engine.isRunning, let aFile = accompFile else { return false } scheduleSegment(accompPlayer, file: aFile, from: seconds) if hasStems, let vFile = vocalsFile { scheduleSegment(vocalsPlayer, file: vFile, from: seconds) } + needsRescheduleAfterEngineStart = false return true } @@ -201,6 +226,17 @@ final class Deck { frameCount: AVAudioFrameCount(file.length - startFrame), at: nil) } + nonisolated static func isRenderClockUsable( + renderHostTime: UInt64, + engineStartedAtHostTime: UInt64, + now: UInt64, + maximumAge: UInt64 + ) -> Bool { + renderHostTime >= engineStartedAtHostTime + && renderHostTime <= now + && now - renderHostTime < maximumAge + } + private func resolveDuration(_ track: Track, file: AVAudioFile) -> TimeInterval { let fileDuration = Double(file.length) / file.processingFormat.sampleRate if track.durationSeconds <= 0 { track.durationSeconds = fileDuration } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift b/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift index 9c99d40..4745814 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift @@ -69,9 +69,20 @@ extension Player { // Spurious changes happen with the engine still rendering — notably the // Taptic Engine (context-menu long-press haptic) sharing the audio hardware. // Recovery stops + reschedules both stem players (an audible ~1s dropout), - // so only do it when the engine actually stopped. + // so only do it when the engine actually stopped. The stop can land just AFTER + // this callback (Live Activity / Dynamic Island updates exercise that race), so + // recheck once after the system has settled instead of trusting one snapshot. guard !engine.isRunning else { - Logger.audio.info("engine configuration change — engine still running, skipping recovery") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + MainActor.assumeIsolated { + guard let self, self.isPlaying, + let currentEngine = self.audio?.engine, + currentEngine === engine, + !engine.isRunning else { return } + Logger.audio.error("engine stopped after configuration callback — recovering") + self.recoverPlayback(force: true) + } + } return } Logger.audio.info("engine configuration change — recovering playback") @@ -131,7 +142,7 @@ extension Player { /// Restarts the engine and reschedules the current track at the current position. `force` /// recovers even without a preceding `pauseForEnvironment` (configuration changes stop the /// engine without an interruption notification). - private func recoverPlayback(force: Bool = false) { + func recoverPlayback(force: Bool = false) { guard force || resumeAfterInterruption else { return } resumeAfterInterruption = false // Observers only exist once the stack does, so `audio` is always live here. @@ -161,6 +172,14 @@ extension Player { audio.current.play() isPlaying = true startTimer() + } else if audio.current.hasRealFile { + // The engine stopped again between ensureRunning() and the reschedule. Unload the + // invalid schedule and stage this position so the next explicit play rebuilds it. + Logger.audio.error("engine stopped again during playback reschedule") + pendingSeekSeconds = position + audio.current.stop() + isPlaying = false + stopTimer() } else { // Synth deck: the loop is position-agnostic; a fresh start is equivalent. startCurrentFresh() diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift index b690c5b..ba4c05c 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift @@ -246,13 +246,40 @@ extension Player { return } pendingSeekSeconds = nil // a live deck seek supersedes any staged one + guard audio.engine.isRunning else { + // A system event invalidated the schedule before the seek reached the deck. Leave the + // requested position staged for a clean reload instead of touching stopped nodes. + pendingSeekSeconds = clamped + audio.current.stop() + baselineSeconds = clamped + position = clamped + isPlaying = false + stopTimer() + persistState() + return + } if audio.current.seekRealFile(to: clamped) { baselineSeconds = clamped position = clamped if isPlaying { - guard ensureRunning() else { isPlaying = false; return } + guard audio.engine.isRunning else { + pendingSeekSeconds = clamped + audio.current.stop() + isPlaying = false + stopTimer() + persistState() + return + } audio.current.play() } + } else if audio.current.hasRealFile { + // The engine stopped between the initial check and segment scheduling. + pendingSeekSeconds = clamped + baselineSeconds = clamped + position = clamped + audio.current.stop() + isPlaying = false + stopTimer() } else { // Synth deck: looped audio is identical at any offset, so just move the clock. baselineSeconds = clamped - audio.current.elapsed diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index 2f9ae45..fcd501b 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -219,6 +219,12 @@ public final class Player { guard let track = currentTrack else { return false } let audio = ensureAudioStack() guard ensureRunning() else { return false } + if isTransitioning && audio.idle.needsRescheduleAfterEngineStart { + // A paused blend cannot survive an engine stop: both node schedules were invalidated. + // Keep the outgoing track and discard the incoming deck before manual/remote resume. + Logger.audio.info("engine restart invalidated paused transition — discarding incoming deck") + cancelTransition() + } if audio.current.track?.id != track.id { audio.current.load(track) applyLoudness(to: audio.current) @@ -227,13 +233,37 @@ public final class Player { } if let pending = pendingSeekSeconds { pendingSeekSeconds = nil - if pending > 0, audio.current.seekRealFile(to: pending) { + if audio.current.seekRealFile(to: pending) { baselineSeconds = pending + } else if audio.current.hasRealFile { + // The engine stopped again between ensureRunning() and scheduling. Preserve the + // staged seek and force a fresh load on the next play attempt. + pendingSeekSeconds = pending + audio.current.stop() + return false } else { // Synth deck: looped audio is identical at any offset, so just move the clock. baselineSeconds = pending - audio.current.elapsed } position = pending + } else if audio.current.needsRescheduleAfterEngineStart { + // The track stayed loaded while a system event stopped the engine, invalidating its + // player-node schedules. Rebuild them before any manual/remote resume can call play(). + if audio.current.seekRealFile(to: position) { + baselineSeconds = position + } else if audio.current.hasRealFile { + pendingSeekSeconds = position + audio.current.stop() + return false + } else { + // Synth loops cannot seek; reloading is position-agnostic, then retain the logical + // clock and any rate/pitch inherited from the previous transition. + audio.current.load(track) + applyLoudness(to: audio.current) + audio.current.rate = Float(currentRate) + audio.current.pitchCents = Float(currentPitchShiftSemitones * 100) + baselineSeconds = position + } } // First real audio need (play from a prepare/restore staging) — start JIT stems here, // not at launch. prepare() deliberately skips notifyUpcoming to avoid ORT jetsam. @@ -285,7 +315,15 @@ public final class Player { let audio = ensureAudioStack() if audio.engine.isRunning { return true } try? AVAudioSession.sharedInstance().setActive(true) - do { try audio.engine.start(); return true } catch { return false } + do { + try audio.engine.start() + let start = mach_absolute_time() + audio.deckA.markEngineStarted(at: start) + audio.deckB.markEngineStarted(at: start) + return true + } catch { + return false + } } /// Starts the current track from scratch on the current deck (play / next / previous / restart). @@ -359,6 +397,13 @@ public final class Player { private func tick() { guard isPlaying, let audio else { return } + // Configuration changes can report the engine as running in their callback, then stop it + // asynchronously. Never let transition scheduling touch player nodes in that gap. + guard audio.engine.isRunning else { + Logger.audio.error("playback tick found stopped engine — recovering") + recoverPlayback(force: true) + return + } let elapsed = baselineSeconds + audio.current.elapsed position = elapsed // Playback memory heartbeat (~10 s): the jetsam RCA's remaining blind spot is memory diff --git a/Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift index 4f2d1eb..7e7066d 100644 --- a/Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift +++ b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift @@ -27,4 +27,37 @@ final class PlayerTransitionRecoveryTests: XCTestCase { XCTAssertEqual(player.transitionProgress, 0) XCTAssertNil(player.audio) } + + func testRecentRenderClockFromPreviousEngineRunIsRejected() { + XCTAssertFalse(Deck.isRenderClockUsable( + renderHostTime: 950, + engineStartedAtHostTime: 1_000, + now: 1_010, + maximumAge: 100 + )) + } + + func testRecentRenderClockFromCurrentEngineRunIsAccepted() { + XCTAssertTrue(Deck.isRenderClockUsable( + renderHostTime: 1_005, + engineStartedAtHostTime: 1_000, + now: 1_010, + maximumAge: 100 + )) + } + + func testFutureAndExpiredRenderClocksAreRejected() { + XCTAssertFalse(Deck.isRenderClockUsable( + renderHostTime: 1_020, + engineStartedAtHostTime: 1_000, + now: 1_010, + maximumAge: 100 + )) + XCTAssertFalse(Deck.isRenderClockUsable( + renderHostTime: 1_005, + engineStartedAtHostTime: 1_000, + now: 1_200, + maximumAge: 100 + )) + } }