Skip to content

feat: add crossfade support between tracks#322

Merged
devgianlu merged 5 commits into
devgianlu:masterfrom
NomanV:feat/crossfade
Jul 4, 2026
Merged

feat: add crossfade support between tracks#322
devgianlu merged 5 commits into
devgianlu:masterfrom
NomanV:feat/crossfade

Conversation

@NomanV

@NomanV NomanV commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Implements crossfade between tracks, closing the gap discussed in #170.

How it works

All the work happens in SwitchingAudioSource, which already handles the gapless hand-off between the playing track and the prefetched next one, so crossfade support ends up being a natural extension of the existing design rather than a new subsystem.

When crossfade_duration is set, the source keeps a lookahead of the playing track in a ring buffer sized to the fade plus a small headroom, filled with bounded reads so a greedy decoder can never stall playback. Playback is served from the headroom above a full fade held in reserve, which means the end of a track is discovered by the decoder before the listener hears it: at that moment the buffered tail is faded out with equal-power gains (cos/sin) while the prefetched secondary fades in underneath it. No track duration metadata is involved, so seeks, podcasts and tracks with imprecise durations behave correctly by construction.

With crossfade_duration: 0 (the default) the original read path runs untouched, byte for byte.

Behavior details

  • The track-done signal fires when the fade begins, so state, metadata, events and prefetch scheduling advance in sync with what the listener hears (same UX as the official clients). At the end of a context, with no next track, the tail drains first and EOF timing matches current behavior exactly.
  • The daemon re-sets the promoted source when it acknowledges a track change; that same-pointer SetPrimary keeps the in-flight fade going, while a genuinely new source (manual skip) aborts the fade immediately. Promoting a source also clears a secondary alias of itself, so skipping straight to the prefetched track can no longer replay or fade into an exhausted stream.
  • Reported positions are compensated for the lookahead, so the API and clients see the listener position, not the decoder position.
  • Seeking drops the lookahead and any in-flight fade and restarts buffering at the target. Right after a start or seek, playback begins immediately from a partial buffer; a track that ends before the reserve fills simply gets a proportionally shorter fade.
  • Edge cases covered: next track shorter than the fade (mixes against silence, both done signals still fire), prefetch attaching late while the tail is already draining (the remaining tail crossfades), partial reads, io.EOF returned together with the final chunk, non-EOF errors delivered only after already-decoded audio has played out, and frame alignment throughout so channels can never desynchronize.
  • Mixed samples are clamped to 32767/32768 so the s16 output conversion cannot overflow when two loud tracks overlap.

Related fixes in the series

Two pre-existing issues surfaced while testing transitions end to end; both matter for crossfade but stand on their own:

  • fix: recognize prefetched streams for relinked tracks: when a restricted track is relinked to an alternative, Stream.Is never matched the id the stream was requested with, so the daemon discarded its own prefetched stream and loaded the same track again from scratch. Without crossfade this silently wasted the prefetch and produced a gap; with crossfade it replaced the already-fading stream mid-transition.
  • fix: keep the prefetched next track consistent with the playback plan: repeat-track now prefetches the repeating track itself (so its ending is not truncated on every loop and the transition crossfades like any other), and repeat/shuffle toggles, queue changes and non-prefetched loads now drop a stale prefetched stream instead of leaving it attached for the player to switch or fade into.

Configuration

crossfade_duration: 5000 # milliseconds, 0 (default) disables

Documented in the README and config_schema.json.

Testing

  • player/source_test.go (21 tests, run with -race): bit-exact passthrough with crossfade disabled (including EOF/done semantics on seeks), sample-exact verification of the fade region against the equal-power formula, output length across transitions, peak clamping, no-secondary drain with original EOF timing, late prefetch attach, short next track, skip/seek/pause during an active fade, skip-to-prefetched aliasing, cleared secondary, greedy-decoder fill boundedness, error delivery after buffered audio, position compensation, partial/odd reads, EOF-with-final-chunk, done-per-track semantics, and a concurrency smoke test.
  • Real-world validation on an always-on Spotify Connect device (pipe backend feeding an Icecast radio encoder on a 1 GB cloud box): recorded 20+ forced and natural transitions off the stream and analyzed them for silence gaps and sample discontinuities (none found through 1 s, 5 s, 7 s and 12 s fades); exercised rapid skip storms, seeks, pauses and volume changes during active fades via the API with no errors; verified repeat-track crossfades into its own restart without truncation; soaked under continuous playback with a clean journal.

Happy to adjust naming, defaults or split things differently if you'd prefer.

(Replaces #321, which could not be reopened after the branch was rebased.)

NomanV added 5 commits July 2, 2026 03:29
When a crossfade duration is configured, SwitchingAudioSource buffers a
lookahead of the playing track in a ring buffer, keeping a full fade
worth of samples in reserve while serving playback from the headroom
above it. The end of the track is therefore discovered before it is
audible: the buffered tail is faded out with equal-power gains while
the prefetched secondary source fades in, overlapping the transition
exactly like the official clients do.

Behavior notes:

- A zero duration keeps the original read path untouched, preserving
  current gapless behavior.
- The track-done signal fires when the fade begins, so state, metadata
  and prefetch scheduling advance in sync with what the listener hears.
  At the end of a context (no next track) the tail drains first and EOF
  timing matches the previous behavior.
- Re-setting the already-promoted source (which the daemon does when it
  acknowledges a track change) keeps an in-flight fade going, while a
  genuinely new source, e.g. a manual skip, aborts it.
- Seeking drops the lookahead and any in-flight fade, and reported
  positions are compensated for the buffered lookahead so the API and
  clients see where the listener actually is, not the decoder.
- A next track shorter than the fade, a late prefetch attaching while
  the tail is already draining, and partial reads are all handled; the
  ramp simply adapts to the audio that is actually available.
- Mixed samples are clamped below full scale so the s16 output
  conversion cannot overflow when two loud signals overlap.
Adds crossfade_duration (milliseconds, default 0 = disabled) to the
daemon configuration, plumbed through to the player. Documented in the
README and config schema.
Covers the disabled path (bit-exact passthrough and original EOF
timing), sample-exact equal-power mixing against the reference formula,
output length across transitions, peak clamping, no-secondary drains,
late prefetch attach, next track shorter than the fade, skips and seeks
during an active fade, promotion acknowledgement keeping the fade
alive, lookahead-compensated position reporting, partial and odd-sized
reads, EOF delivered with the final chunk, done signalling per track,
and concurrent access under the race detector.
When a restricted track is relinked to an alternative, the stream's
media id no longer matches the id it was requested with, so Stream.Is
returned false for the very track that was prefetched. The daemon then
discarded the prefetched stream and loaded the same track again from
scratch: without crossfade this silently wasted the prefetch and
produced a gap, with crossfade it replaced the already-fading stream
mid-transition. Track the requested id on the stream and match on it.
The prefetched secondary stream could go stale in several ways, and the
player would happily switch (or crossfade) into it at track end:

- repeat-track: the daemon reloads the same track, but the prefetch was
  for the context's next track, so the transition briefly started the
  wrong track and the reload truncated the current one. Prefetch the
  repeating track itself instead.
- repeat/shuffle toggles and queue changes alter which track comes next
  but left the old prefetched stream attached; it is now dropped and
  prefetch rescheduled.
- loading a track other than the prefetched one cleared the daemon's
  reference but left the stream attached to the player.
- skipping straight to the prefetched track left the same stream
  aliased as both primary and secondary, replaying or fading into its
  own exhausted self at EOF; promoting a source now clears the alias.
@devgianlu

Copy link
Copy Markdown
Owner

Looks good overall, I will give it a try and then merge.

@devgianlu
devgianlu merged commit 0e1427d into devgianlu:master Jul 4, 2026
8 checks passed
@devgianlu devgianlu mentioned this pull request Jul 4, 2026
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.

2 participants