From eb71c56dc25cdac1fe1937e32e812461622a805d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 04:01:03 +0000 Subject: [PATCH] Fix key-sync pitch shift persisting for whole track after transition Harmonic mixing repitches the incoming track by +/-1 semitone so its Camelot key is compatible with the outgoing track during a blend, but that shift was promoted to the current deck at finishTransition and never reset until the next fresh Deck.load. Auto-advanced songs therefore played their ENTIRE remaining duration detuned (typically sounding "a little higher", since Camelot's [0, 1, -1] preference is upward-biased). The shift is only needed while both tracks overlap. Now, once the promoted track is playing solo, the deck's pitch glides back to true (0 cents) over a ~2s raised-cosine settle driven by the existing 20 Hz tick loop, then the persisted shift is cleared so the rest of the track plays at correct pitch. The glide (not an instant reset) avoids an audible pitch pop at the handoff. A new transition or fresh load cleanly supersedes an in-progress settle. Camelot/HarmonicMix logic is untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KTkuxCLeDKzyVAnXKdxqvv --- .../Sources/Playback/Player+Transitions.swift | 52 +++++++++++++++++++ .../Sources/Playback/Player+Transport.swift | 1 + .../Sources/Playback/Player.swift | 18 +++++++ 3 files changed, 71 insertions(+) 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