Skip to content

Fix key-sync pitch shift persisting for whole track after transition - #122

Merged
sanylax0 merged 1 commit into
mainfrom
claude/slack-session-hja5f1
Jul 21, 2026
Merged

sanylax0 merged 1 commit into
mainfrom
claude/slack-session-hja5f1

Conversation

@sanylax2

@sanylax2 sanylax2 commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

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 beginTransition the shift is applied to the incoming deck (incoming.pitchCents = Float(shift * 100)), and in finishTransition it is promoted to the current deck via currentPitchShiftSemitones = incomingPitchShiftSemitones. Nothing ever reset timePitch.pitch after that — only the next fresh Deck.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.pitchShiftSemitones prefers shifts in the order [0, 1, -1] — when a nudge is needed, +1 is 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 sets currentPitchShiftSemitones = incomingPitchShiftSemitones (so key comparison stays correct during the overlap and for any immediately-following blend), then calls beginPitchSettle().
  • The settle is driven by the existing 20 Hz tick loop (Player.tick() → advancePitchSettle()), easing the deck's timePitch.pitch from 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 clears currentPitchShiftSemitones.
  • A new transition or a fresh load (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 timePitch node — never position — 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 / HarmonicMix logic is untouched.

Why not sample-rate / playback-rate?

The detune is a deliberate pitch shift via AVAudioUnitTimePitch.pitch (cents), independent of tempo. The beatmatch rate is 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; new beginPitchSettle(), advancePitchSettle(), cancelPitchSettle(); clearTransitionState() cancels an in-flight settle.
  • Player.swift — settle state (@ObservationIgnored bookkeeping vars), pitchSettleDurationSeconds constant, advancePitchSettle() call in tick(), settle cancellation in the fresh-load paths.
  • Player+Transport.swift — settle cancellation in prepare() restage.

Testing notes

  • ContinuityCore Linux tests are unaffected (no Core files touched; Camelot logic unchanged).
  • ContinuityKit/Playback is AVFoundation-bound and not Linux-buildable — verified by review + mental type-check against the surrounding APIs.
  • On-device verification needed for the transition/pitch feel: confirm auto-advanced tracks no longer sound higher after a blend, and that the ~2 s settle is inaudible (no pop). Suggest toggling Harmonic Mixing off/on in Transition Settings to A/B compare.

🤖 Generated with Claude Code

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
sanylax0 marked this pull request as ready for review July 21, 2026 06:10
@sanylax0
sanylax0 merged commit 7905d2d into main Jul 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants