From c27b6516798eb3e94c7de5c3cda65d58ecdbf364 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 18:19:48 +0000 Subject: [PATCH] Fix transition recovery after audio engine stops Co-authored-by: sanylax0 --- Packages/ContinuityKit/Package.swift | 5 ++++ .../Playback/Player+AudioSession.swift | 16 ++++++++-- .../Sources/Playback/Player+Transitions.swift | 10 +++++++ .../PlayerTransitionRecoveryTests.swift | 30 +++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift diff --git a/Packages/ContinuityKit/Package.swift b/Packages/ContinuityKit/Package.swift index 68ec8ec..acd1741 100644 --- a/Packages/ContinuityKit/Package.swift +++ b/Packages/ContinuityKit/Package.swift @@ -55,5 +55,10 @@ let package = Package( dependencies: ["Domain", .product(name: "ContinuityCore", package: "ContinuityCore")], swiftSettings: [.swiftLanguageMode(.v5)] ), + .testTarget( + name: "PlaybackTests", + dependencies: ["Playback"], + swiftSettings: [.swiftLanguageMode(.v5)] + ), ] ) diff --git a/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift b/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift index e471814..9c99d40 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift @@ -97,8 +97,7 @@ extension Player { let wasPlaying = isPlaying let resumePosition = position // Don't touch the dead engine/decks — even stop() can throw on orphaned nodes. - isTransitioning = false - transitionProgress = 0 + clearTransitionState() isPlaying = false stopTimer() audio = nil @@ -137,12 +136,25 @@ extension Player { resumeAfterInterruption = false // Observers only exist once the stack does, so `audio` is always live here. guard currentTrack != nil, let audio else { return } + let transitionWasInterrupted = isTransitioning guard ensureRunning() else { + if transitionWasInterrupted { + // The idle deck's schedule is no longer trustworthy. Clear the blend flags even + // when CoreAudio cannot restart so a later play cannot resume that stale deck. + clearTransitionState() + } Logger.audio.error("engine restart failed during recovery") isPlaying = false stopTimer() return } + if transitionWasInterrupted { + // Configuration-change recovery bypasses pauseForEnvironment(), but an engine stop + // invalidates both decks just the same. Drop the incoming deck before rescheduling + // the outgoing one; otherwise pause/resume can call play() on its stale schedule. + Logger.audio.info("engine stop interrupted transition — discarding incoming deck") + cancelTransition() + } // Engine stops invalidate node schedules — reschedule from where the clock stood. if audio.current.seekRealFile(to: position) { baselineSeconds = position diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift index 6d88719..61fcac7 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift @@ -174,9 +174,19 @@ extension Player { audio.current.vocalsGain = 1 audio.current.bassGainDB = 0 } + clearTransitionState() + } + + /// Clears blend bookkeeping without touching AVAudio objects. Media-services reset leaves + /// every node orphaned, so that path must abandon state without calling even `stop()`. + func clearTransitionState() { isTransitioning = false + transitionTargetIndex = currentIndex activeTransitionDurationSeconds = 0 isUserInitiatedSkipTransition = false + incomingStartOffset = 0 + incomingPitchShiftSemitones = 0 + incomingRate = 1 transitionProgress = 0 } } diff --git a/Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift new file mode 100644 index 0000000..4f2d1eb --- /dev/null +++ b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerTransitionRecoveryTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Playback + +@MainActor +final class PlayerTransitionRecoveryTests: XCTestCase { + func testClearingTransitionStateDoesNotMaterializeAudioStack() { + let player = Player() + player.currentIndex = 2 + player.transitionTargetIndex = 3 + player.activeTransitionDurationSeconds = 5 + player.isUserInitiatedSkipTransition = true + player.incomingStartOffset = 1.5 + player.incomingPitchShiftSemitones = 1 + player.incomingRate = 1.05 + player.transitionProgress = 0.4 + player.isTransitioning = true + + player.clearTransitionState() + + XCTAssertFalse(player.isTransitioning) + XCTAssertEqual(player.transitionTargetIndex, player.currentIndex) + XCTAssertEqual(player.activeTransitionDurationSeconds, 0) + XCTAssertFalse(player.isUserInitiatedSkipTransition) + XCTAssertEqual(player.incomingStartOffset, 0) + XCTAssertEqual(player.incomingPitchShiftSemitones, 0) + XCTAssertEqual(player.incomingRate, 1) + XCTAssertEqual(player.transitionProgress, 0) + XCTAssertNil(player.audio) + } +}