Fix key-sync pitch shift persisting for whole track after transition - #122
Merged
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTkuxCLeDKzyVAnXKdxqvv
sanylax0
approved these changes
Jul 21, 2026
sanylax0
marked this pull request as ready for review
July 21, 2026 06:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a bug where auto-transitioned songs sometimes play "a little higher pitched" for their entire remaining duration.
Slack thread: https://continuity-org.slack.com/archives/C0BCTF2C5NK/p1784605454126979
Root cause
Harmonic mixing (key-sync) repitches the incoming track by ±1 semitone during a blend so its Camelot key is compatible with the outgoing track. In
beginTransitionthe shift is applied to the incoming deck (incoming.pitchCents = Float(shift * 100)), and infinishTransitionit is promoted to the current deck viacurrentPitchShiftSemitones = incomingPitchShiftSemitones. Nothing ever resettimePitch.pitchafter that — only the next freshDeck.load()clears it. So a track that was blended in with a +1 semitone shift kept that detune for its whole solo playback.The audible bias is upward because
HarmonicMix.pitchShiftSemitonesprefers shifts in the order[0, 1, -1]— when a nudge is needed,+1is tried before-1, so the persistent detune skews sharp ("higher").The fix
The shift is genuinely needed only while the two tracks overlap (so the crossfade sounds harmonically compatible). Once the promoted track is playing solo, its pitch is glided back to true (0 cents):
finishTransition()still setscurrentPitchShiftSemitones = incomingPitchShiftSemitones(so key comparison stays correct during the overlap and for any immediately-following blend), then callsbeginPitchSettle().Player.tick()→advancePitchSettle()), easing the deck'stimePitch.pitchfrom the shifted value to 0 over ~2 s using a raised-cosine curve (zero slope at both ends, so no audible pitch "pop" at the handoff). On the final tick it snaps to 0 and clearscurrentPitchShiftSemitones.startCurrentFresh/ensureCurrentLoaded/prepare) cleanly supersedes an in-progress settle;cancelPitchSettle()finalizes a mid-glide settle to true pitch 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).The settle only touches the deck's
timePitchnode — neverposition— so it stays off the heavy-view tick path (per the 20 Hz view-churn rule). No allocations in the tick loop, no engine rebuild.Camelot/HarmonicMixlogic is untouched.Why not sample-rate / playback-rate?
The detune is a deliberate pitch shift via
AVAudioUnitTimePitch.pitch(cents), independent of tempo. The beatmatchrateis a separate axis and was not implicated — the tempo of affected tracks was correct, only pitch was off. So this is fixed at the pitch node, not by touching sample rate or playback rate (which would also alter tempo/duration).Files changed
Player+Transitions.swift—finishTransition()starts the settle; newbeginPitchSettle(),advancePitchSettle(),cancelPitchSettle();clearTransitionState()cancels an in-flight settle.Player.swift— settle state (@ObservationIgnoredbookkeeping vars),pitchSettleDurationSecondsconstant,advancePitchSettle()call intick(), settle cancellation in the fresh-load paths.Player+Transport.swift— settle cancellation inprepare()restage.Testing notes
🤖 Generated with Claude Code