Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions Packages/ContinuityKit/Sources/Playback/Deck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -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).
Expand All @@ -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
}

Expand All @@ -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 }
Expand Down
25 changes: 22 additions & 3 deletions Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
29 changes: 28 additions & 1 deletion Packages/ContinuityKit/Sources/Playback/Player+Transport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 47 additions & 2 deletions Packages/ContinuityKit/Sources/Playback/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
}
}
Loading