diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift index 61fcac7..4c8fb37 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift @@ -153,6 +153,10 @@ extension Player { position = baselineSeconds + incoming.elapsed currentPitchShiftSemitones = incomingPitchShiftSemitones currentRate = incomingRate + // The key-sync shift was only needed while both tracks overlapped. Now that the incoming + // plays solo, glide its pitch back to true over a short window and clear the shift — else + // it repitches the whole remaining track (the "plays a little higher" bug). + beginPitchSettle() isTransitioning = false activeTransitionDurationSeconds = 0 isUserInitiatedSkipTransition = false @@ -188,5 +192,53 @@ extension Player { incomingPitchShiftSemitones = 0 incomingRate = 1 transitionProgress = 0 + cancelPitchSettle() + } + + /// Starts the cosmetic pitch settle on the now-current deck once a blend completes. The + /// harmonic key-sync detune only has to hold while both tracks overlap; once the incoming + /// plays solo we glide it back to true pitch (0 cents) over `pitchSettleDurationSeconds` and + /// clear the persisted shift, so the rest of the track isn't left detuned. No-op (and drops + /// any prior settle) when the promoted track isn't shifted. + func beginPitchSettle() { + guard currentPitchShiftSemitones != 0 else { cancelPitchSettle(); return } + // Driven by the 20 Hz tick loop (see Player.startTimer): duration / tick interval ticks. + pitchSettleTotalTicks = max(1, Int((Player.pitchSettleDurationSeconds / 0.05).rounded())) + pitchSettleTicksRemaining = pitchSettleTotalTicks + pitchSettleStartCents = Float(currentPitchShiftSemitones * 100) + } + + /// One tick of the pitch settle: eases the current deck from its key-sync pitch toward true + /// pitch with a raised-cosine glide (zero slope at both ends → no click), then clears the + /// persisted shift. Only touches the deck's timePitch node, never `position`, so it can't + /// churn the tick-driven UI. Driven from `Player.tick()`. + func advancePitchSettle() { + guard pitchSettleTicksRemaining > 0, let audio else { return } + pitchSettleTicksRemaining -= 1 + guard pitchSettleTicksRemaining > 0 else { + audio.current.pitchCents = 0 + currentPitchShiftSemitones = 0 // nothing stays detuned for the rest of the track + pitchSettleStartCents = 0 + return + } + let progress = Double(pitchSettleTotalTicks - pitchSettleTicksRemaining) / Double(pitchSettleTotalTicks) + let eased = 0.5 * (1 + cos(progress * .pi)) // 1 → 0 as progress goes 0 → 1 + audio.current.pitchCents = pitchSettleStartCents * Float(eased) + } + + /// Abandons any in-progress pitch settle. When a settle was actually mid-glide, finalize it — + /// snap the current deck to true pitch and clear the persisted shift — so a track can't be + /// left frozen at a partial detune (e.g. a second transition cancelled while the first's + /// settle was still gliding). A pure no-op when no settle is active, so it never touches a + /// deck's pitch on the normal path. Fresh-load callers already zeroed the deck via `Deck.load`, + /// so the snap is redundant-but-harmless there. + func cancelPitchSettle() { + if pitchSettleTicksRemaining > 0 { + audio?.current.pitchCents = 0 + currentPitchShiftSemitones = 0 + } + pitchSettleTicksRemaining = 0 + pitchSettleTotalTicks = 0 + pitchSettleStartCents = 0 } } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift index b690c5b..edb962e 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift @@ -33,6 +33,7 @@ extension Player { } currentPitchShiftSemitones = 0 currentRate = 1 + cancelPitchSettle() // restaging supersedes any in-flight post-transition settle queueRefillAttempted = false // restaged track earns a fresh exhaustion refill baselineSeconds = 0 position = 0 diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index 2f9ae45..c9e45e2 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -141,6 +141,15 @@ public final class Player { var currentRate: Double = 1 /// Rate staged on the incoming deck; promoted to `currentRate` when the transition completes. var incomingRate: Double = 1 + /// Post-transition pitch settle. The harmonic key-sync shift is only needed while the two + /// tracks overlap; once the promoted track plays solo, holding the detune repitches its whole + /// remaining duration (the audible "plays a little higher" bug). After a blend finishes we + /// glide the now-current deck from its shifted pitch back to true (0 cents) over + /// `pitchSettleDurationSeconds`, driven tick-by-tick, then clear `currentPitchShiftSemitones`. + /// Bookkeeping only (never read by views); 0 ticks remaining = no settle in progress. + @ObservationIgnored var pitchSettleTicksRemaining = 0 + @ObservationIgnored var pitchSettleTotalTicks = 0 + @ObservationIgnored var pitchSettleStartCents: Float = 0 /// True when playback was paused by an interruption/route change and should resume when the /// system says the coast is clear. @@ -224,6 +233,7 @@ public final class Player { applyLoudness(to: audio.current) currentPitchShiftSemitones = 0 currentRate = 1 + cancelPitchSettle() // load() reset the deck's pitch; drop any in-flight settle } if let pending = pendingSeekSeconds { pendingSeekSeconds = nil @@ -264,6 +274,10 @@ public final class Player { /// one back (capped). Previous-skips are unlimited and walk the persistent play history. static let maxSkips = 3 static let skipTransitionDurationSeconds: TimeInterval = 5 + /// Cosmetic window over which a post-transition track glides from its harmonic key-sync pitch + /// back to true pitch (0 cents). Short — just long enough that the un-detune isn't an audible + /// pitch pop at the handoff. + static let pitchSettleDurationSeconds: TimeInterval = 2 public internal(set) var skipsRemaining = Player.maxSkips /// IDs of previously played tracks, most recent last. Persisted, so "previous" works across /// launches. @@ -301,6 +315,7 @@ public final class Player { applyLoudness(to: audio.current) currentPitchShiftSemitones = 0 currentRate = 1 + cancelPitchSettle() // a fresh start supersedes any in-flight post-transition settle queueRefillAttempted = false // new current track earns a fresh exhaustion refill baselineSeconds = 0 position = 0 @@ -361,6 +376,9 @@ public final class Player { guard isPlaying, let audio else { return } let elapsed = baselineSeconds + audio.current.elapsed position = elapsed + // Cosmetic: ease a just-transitioned track's key-sync pitch back to true. Touches only the + // deck's timePitch node (never `position`), so it stays off the heavy-view tick path. + advancePitchSettle() // Playback memory heartbeat (~10 s): the jetsam RCA's remaining blind spot is memory // that ramps during PLAIN playback (no ingest/stems activity, so no other breadcrumb // fires). The decay slope across these lines names the leak rate; the value at the