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: 52 additions & 0 deletions Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions Packages/ContinuityKit/Sources/Playback/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading