feat: add crossfade support between tracks#322
Merged
Conversation
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.
Owner
|
Looks good overall, I will give it a try and then merge. |
Closed
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.
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_durationis 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
SetPrimarykeeps 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.io.EOFreturned 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.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.Isnever 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
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.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.)