mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(crossfade): AutoDJ — content-aware silence-trimming crossfade (#1122)
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
This commit is contained in:
+111
-10
@@ -85,6 +85,27 @@ import {
|
||||
getSeekTargetSetAt,
|
||||
} from './seekTargetState';
|
||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||
import { analyzeBoundary, computeWaveformSilence, STANDARD_BLEND_SEC } from '../utils/waveform/waveformSilence';
|
||||
import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from './crossfadePreload';
|
||||
import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
|
||||
|
||||
// Silence-aware crossfade (A-tail): guards the early advance to once per play
|
||||
// generation so a single playback instance triggers at most one trim-advance
|
||||
// (re-arms automatically on the next play / repeat-all loop, never loops on a
|
||||
// backward seek within the same playback).
|
||||
let crossfadeTrimAdvanceGen = -1;
|
||||
|
||||
// AutoDJ: mirror of the engine's `autodj_suppress_autocrossfade` flag so we only
|
||||
// invoke the setter on change. When a content fade is pending for the upcoming
|
||||
// transition we suppress the engine's autonomous crossfade timer and let the JS
|
||||
// A-tail advance drive it (gated on the next track being playable) — otherwise
|
||||
// the engine would start a still-buffering next track and fade over it (a jump).
|
||||
let autodjSuppressSent: boolean | null = null;
|
||||
function syncAutodjSuppress(want: boolean): void {
|
||||
if (autodjSuppressSent === want) return;
|
||||
autodjSuppressSent = want;
|
||||
invoke('audio_set_autodj_suppress', { enabled: want }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Rust-side `audio:normalization-state` event payload. */
|
||||
export type NormalizationStatePayload = {
|
||||
@@ -231,19 +252,100 @@ export function handleAudioProgress(
|
||||
// Pre-buffer / pre-chain next track for gapless and crossfade.
|
||||
const {
|
||||
gaplessEnabled,
|
||||
hotCacheEnabled,
|
||||
crossfadeEnabled,
|
||||
crossfadeSecs,
|
||||
crossfadeTrimSilence,
|
||||
} = useAuthStore.getState();
|
||||
const remaining = dur - current_time;
|
||||
|
||||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||||
// Crossfade needs the next track bytes ready before the fade window.
|
||||
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
|
||||
const shouldBytePreloadForCrossfade =
|
||||
!hotCacheEnabled && !gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
|
||||
// Silence-aware crossfade — current track's trailing silence, derived once
|
||||
// from its cached waveform. Drives both the early A-tail advance AND a wider
|
||||
// pre-buffer window (the early advance must not outrun the next track's
|
||||
// download, or its stream starts late and the fade has nothing to overlap).
|
||||
const trimActive =
|
||||
crossfadeEnabled && crossfadeTrimSilence && !gaplessEnabled && !store.currentRadio;
|
||||
const curTrailSilenceSec = trimActive
|
||||
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
|
||||
: 0;
|
||||
|
||||
if (shouldChainGapless || shouldBytePreloadForCrossfade || gaplessEnabled) {
|
||||
// A-tail: start the next track early so the fade overlaps *audible* tail/head.
|
||||
// The overlap is content-driven ("by fact"): the planned value (A fade-out vs
|
||||
// B buildup) when ready, else A's own fade-out measured synchronously from its
|
||||
// already-loaded waveform. We only pre-empt the engine's own crossfade trigger
|
||||
// (which fires `crossfadeSecs` before the end) when we'd actually start
|
||||
// earlier — i.e. there's dead air to skip OR the content overlap is longer than
|
||||
// crossfadeSecs (a real fade/buildup). Plain loud→loud endings fall through to
|
||||
// the normal engine crossfade.
|
||||
// When a content fade is pending we suppress the engine's autonomous
|
||||
// crossfade timer (set below) so JS solely drives this transition; cleared
|
||||
// for plain loud→loud (engine keeps its normal crossfade) and non-AutoDJ.
|
||||
let autodjSuppressWant = false;
|
||||
if (trimActive && store.isPlaying && store.repeatMode !== 'one') {
|
||||
const nextIdx = store.queueIndex + 1;
|
||||
const nextRef = nextIdx < store.queueItems.length
|
||||
? store.queueItems[nextIdx]
|
||||
: (store.repeatMode === 'all' && store.queueItems.length > 0 ? store.queueItems[0] : null);
|
||||
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
|
||||
if (nextTrackId) {
|
||||
const cf = Math.max(0.1, Math.min(12, crossfadeSecs));
|
||||
const plan = getCrossfadeTransition(nextTrackId);
|
||||
let contentOverlap: number;
|
||||
// Scenario A: does A carry its own recorded fade-out? If so we let it ride
|
||||
// at full engine gain (no double fade) and bring B up underneath.
|
||||
let aRidesOwnFade: boolean;
|
||||
if (plan && plan.overlapSec > 0) {
|
||||
contentOverlap = plan.overlapSec;
|
||||
aRidesOwnFade = plan.outgoingFadeSec <= 0.001;
|
||||
} else {
|
||||
// No next-track envelope (cold plan) → judge A from its own waveform.
|
||||
const aShape = analyzeBoundary(store.waveformBins, dur);
|
||||
contentOverlap = aShape.outroFadeSec;
|
||||
aRidesOwnFade = aShape.outroFadeSec >= 1.0;
|
||||
}
|
||||
const wantEarly = curTrailSilenceSec > 0.3 || contentOverlap > cf + 0.3;
|
||||
if (wantEarly) {
|
||||
// This transition is ours to drive: stop the engine from firing its own
|
||||
// crossfade into a possibly-cold next track. If B never readies, A plays
|
||||
// out to its natural end and we degrade to a clean sequential start.
|
||||
autodjSuppressWant = true;
|
||||
let overlap = Math.max(0.5, Math.min(12, contentOverlap || 0.5));
|
||||
// Hard, loud→loud meeting reached by trimming protective silence (no
|
||||
// natural fade to ride): use a standard ~2 s blend instead of a near-cut.
|
||||
if (!aRidesOwnFade && overlap < STANDARD_BLEND_SEC) overlap = STANDARD_BLEND_SEC;
|
||||
const outgoingFade = aRidesOwnFade ? 0 : overlap;
|
||||
const triggerAt = Math.max(0, dur - curTrailSilenceSec - overlap);
|
||||
const gen = getPlayGeneration();
|
||||
// Readiness gate: only advance when B's audio is actually available (RAM
|
||||
// preload slot or local on disk). A cold stream can't sustain a stable
|
||||
// fade, so we leave the gen guard unset and re-check on later ticks — if
|
||||
// B readies before A ends we fade then; if never, A plays out (engine
|
||||
// timer suppressed) and the source-exhaustion end gives a clean cut.
|
||||
if (
|
||||
current_time >= triggerAt
|
||||
&& crossfadeTrimAdvanceGen !== gen
|
||||
&& isCrossfadeNextReady(
|
||||
nextTrackId,
|
||||
playbackProfileIdForRef(nextRef),
|
||||
playbackCacheKeyForRef(nextRef),
|
||||
)
|
||||
) {
|
||||
crossfadeTrimAdvanceGen = gen;
|
||||
armCrossfadeDynamicOverlap(nextTrackId, overlap, outgoingFade);
|
||||
store.next(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
syncAutodjSuppress(autodjSuppressWant);
|
||||
|
||||
// Crossfade pre-buffer (next-track byte download + leading-silence probe).
|
||||
// Self-gating; also invoked right after a seek into the window (see seekAction).
|
||||
maybeCrossfadeBytePreload(current_time, dur);
|
||||
|
||||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||||
|
||||
if (gaplessEnabled) {
|
||||
const { queueItems, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
// Next track for preload/chain. The resolver bridge keeps the window around
|
||||
@@ -281,9 +383,9 @@ export function handleAudioProgress(
|
||||
: getPlaybackIndexKey();
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — gapless backup or crossfade; runs early so bytes are ready by chain time.
|
||||
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
|
||||
if (
|
||||
(shouldBytePreloadForCrossfade || shouldBytePreloadForGaplessBackup)
|
||||
shouldBytePreloadForGaplessBackup
|
||||
&& nextTrack.id !== getBytePreloadingId()
|
||||
) {
|
||||
setBytePreloadingId(nextTrack.id);
|
||||
@@ -294,7 +396,6 @@ export function handleAudioProgress(
|
||||
console.info('[psysonic][preload-request]', {
|
||||
nextTrackId: nextTrack.id,
|
||||
nextUrl,
|
||||
shouldBytePreloadForCrossfade,
|
||||
shouldBytePreloadForGaplessBackup,
|
||||
remaining,
|
||||
gaplessEnabled,
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
| 'setReplayGainFallbackDb'
|
||||
| 'setCrossfadeEnabled'
|
||||
| 'setCrossfadeSecs'
|
||||
| 'setCrossfadeTrimSilence'
|
||||
| 'setGaplessEnabled'
|
||||
| 'setEnableHiRes'
|
||||
| 'setAudioOutputDevice'
|
||||
@@ -67,6 +68,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
},
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setCrossfadeTrimSilence: (v) => set({ crossfadeTrimSilence: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
||||
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
|
||||
|
||||
@@ -49,6 +49,9 @@ describe('hydration — loads existing localStorage shape', () => {
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.trackPreviewsEnabled).toBe(true);
|
||||
expect(s.crossfadeEnabled).toBe(false);
|
||||
// Existing installs that predate the toggle have no persisted field — it
|
||||
// must default OFF so behaviour is unchanged until the user opts in.
|
||||
expect(s.crossfadeTrimSilence).toBe(false);
|
||||
expect(s.gaplessEnabled).toBe(false);
|
||||
expect(s.replayGainEnabled).toBe(false);
|
||||
expect(s.normalizationEngine).toBe('off');
|
||||
|
||||
@@ -53,6 +53,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
replayGainFallbackDb: 0,
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
crossfadeTrimSilence: false,
|
||||
gaplessEnabled: false,
|
||||
trackPreviewsEnabled: true,
|
||||
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
|
||||
|
||||
@@ -119,6 +119,12 @@ export interface AuthState {
|
||||
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
/**
|
||||
* When crossfading, trim trailing silence of the outgoing track and leading
|
||||
* silence of the incoming one so the fade overlaps music, not dead air.
|
||||
* Default off — existing installs without this field keep today's behaviour.
|
||||
*/
|
||||
crossfadeTrimSilence: boolean;
|
||||
gaplessEnabled: boolean;
|
||||
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
|
||||
trackPreviewsEnabled: boolean;
|
||||
@@ -338,6 +344,7 @@ export interface AuthState {
|
||||
setReplayGainFallbackDb: (v: number) => void;
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setCrossfadeTrimSilence: (v: boolean) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setTrackPreviewsEnabled: (v: boolean) => void;
|
||||
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { computeWaveformSilence, planCrossfadeTransition } from '../utils/waveform/waveformSilence';
|
||||
import { findLocalPlaybackUrl } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { playbackCacheKeyForRef } from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||
import { useAuthStore } from './authStore';
|
||||
import {
|
||||
hasPlannedCrossfade,
|
||||
markPlannedCrossfade,
|
||||
setCrossfadeTransition,
|
||||
} from './crossfadeTrimCache';
|
||||
import { getBytePreloadingId, setBytePreloadingId } from './gaplessPreloadState';
|
||||
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { fetchWaveformBins } from './waveformRefresh';
|
||||
|
||||
// Crossfade pre-buffer budget: begin downloading the next track this many
|
||||
// seconds before it needs to play (the crossfade start), so a large lossless
|
||||
// file over HTTP has time to buffer + promote to cache before the fade. Generous
|
||||
// on purpose. The trailing-silence trim widens the window further so the early
|
||||
// A-tail advance keeps the full budget.
|
||||
export const CROSSFADE_PRELOAD_BUDGET_SECS = 30;
|
||||
|
||||
/**
|
||||
* Readiness gate for the AutoDJ early, content-driven advance. A stable fade
|
||||
* needs the *next* track's audio at the overlap moment — analysis (waveform)
|
||||
* alone is not enough. B counts as ready when its full bytes are in the engine
|
||||
* RAM preload slot (`enginePreloadedTrackId`) or it is local on disk: offline
|
||||
* library, favourite auto-sync, or hot-cache ephemeral tier. When B isn't ready
|
||||
* we skip the early advance and let the plain engine crossfade handle the
|
||||
* transition (graceful degrade) instead of fading over a buffering stream.
|
||||
*/
|
||||
export function isCrossfadeNextReady(
|
||||
trackId: string,
|
||||
profileId: string | null,
|
||||
cacheKey: string | null,
|
||||
): boolean {
|
||||
if (!trackId) return false;
|
||||
if (usePlayerStore.getState().enginePreloadedTrackId === trackId) return true;
|
||||
for (const sid of [profileId, cacheKey]) {
|
||||
if (!sid) continue;
|
||||
if (
|
||||
findLocalPlaybackUrl(trackId, sid, 'library')
|
||||
|| findLocalPlaybackUrl(trackId, sid, 'favorite-auto')
|
||||
|| findLocalPlaybackUrl(trackId, sid, 'ephemeral')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossfade-only byte pre-download for the next track + (when trim is on) its
|
||||
* leading-silence probe. Self-gating and idempotent (`bytePreloadingId` /
|
||||
* `hasFetchedCrossfadeLead` guards), so it is safe to call every progress tick
|
||||
* *and* immediately after a seek lands inside the pre-buffer window. No-ops for
|
||||
* the gapless / hot-cache paths (those pre-buffer elsewhere).
|
||||
*
|
||||
* Lives in its own module so `seekAction` can call it without pulling in
|
||||
* `audioEventHandlers` (which would close a `playerStore` import cycle).
|
||||
*/
|
||||
export function maybeCrossfadeBytePreload(currentTime: number, dur: number): void {
|
||||
if (!(dur > 0)) return;
|
||||
const {
|
||||
gaplessEnabled, hotCacheEnabled, crossfadeEnabled, crossfadeSecs, crossfadeTrimSilence,
|
||||
} = useAuthStore.getState();
|
||||
if (!crossfadeEnabled || gaplessEnabled) return;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track || store.currentRadio) return;
|
||||
const remaining = dur - currentTime;
|
||||
if (!(remaining > 0)) return;
|
||||
|
||||
const curTrailSilenceSec = crossfadeTrimSilence
|
||||
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
|
||||
: 0;
|
||||
const crossfadeWindowSecs = crossfadeSecs + curTrailSilenceSec + CROSSFADE_PRELOAD_BUDGET_SECS;
|
||||
if (remaining >= crossfadeWindowSecs) return;
|
||||
|
||||
const { queueItems, queueIndex, repeatMode } = store;
|
||||
if (repeatMode === 'one') return;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextRef = nextIdx < queueItems.length
|
||||
? queueItems[nextIdx]
|
||||
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
|
||||
if (!nextRef) return;
|
||||
const nextTrack = resolveQueueTrack(nextRef);
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
const serverId = playbackCacheKeyForRef(nextRef);
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — skipped when the hot cache is on (it already keeps the
|
||||
// upcoming queue on disk, which is also why hot cache makes the trim reliable:
|
||||
// the next track is local → seekable → starts instantly past its lead silence).
|
||||
if (!hotCacheEnabled && nextTrack.id !== getBytePreloadingId()) {
|
||||
setBytePreloadingId(nextTrack.id);
|
||||
// Loudness cache only — never refreshWaveformForTrack(next): it writes the
|
||||
// global waveformBins and would replace the current track's seekbar.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
invoke('audio_preload', {
|
||||
url: nextUrl,
|
||||
durationHint: nextTrack.duration,
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: serverId || null,
|
||||
// Crossfade/AutoDJ pre-buffer: skip the 8 s throttle so the RAM slot
|
||||
// fills before the fade — without the hot cache this is the only source
|
||||
// of B's bytes, and a late slot means no fade (or an audible jump).
|
||||
eager: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// B-head + dynamic overlap: plan the whole transition once (no store write) so
|
||||
// playTrack can start the incoming track past its dead head AND fade over a
|
||||
// content-adaptive overlap. Pairs the current track's envelope (already in the
|
||||
// store) with the next track's cached waveform; the alignment maths is cheap,
|
||||
// so it runs regardless of hot cache (which otherwise skips the byte
|
||||
// pre-download). Cold/un-analysed tracks fall back to a fixed overlap + no
|
||||
// head trim → today's behaviour.
|
||||
if (crossfadeTrimSilence && !hasPlannedCrossfade(nextTrack.id)) {
|
||||
markPlannedCrossfade(nextTrack.id);
|
||||
const planTrackId = nextTrack.id;
|
||||
const planDuration = nextTrack.duration;
|
||||
const curBins = store.waveformBins;
|
||||
void fetchWaveformBins(planTrackId, serverId || null)
|
||||
.then(nextBins => {
|
||||
// Overlap is derived purely from the audio (fade-out / buildup); the
|
||||
// user's crossfadeSecs is intentionally not a factor in this mode.
|
||||
const plan = planCrossfadeTransition(curBins, dur, nextBins, planDuration);
|
||||
setCrossfadeTransition(planTrackId, plan);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
_resetCrossfadeTrimCacheForTest,
|
||||
armCrossfadeDynamicOverlap,
|
||||
consumeCrossfadeDynamicOverlap,
|
||||
getCrossfadeTransition,
|
||||
hasPlannedCrossfade,
|
||||
markPlannedCrossfade,
|
||||
setCrossfadeTransition,
|
||||
} from './crossfadeTrimCache';
|
||||
|
||||
describe('crossfadeTrimCache', () => {
|
||||
beforeEach(() => _resetCrossfadeTrimCacheForTest());
|
||||
|
||||
it('returns null for unknown / empty track ids', () => {
|
||||
expect(getCrossfadeTransition('nope')).toBeNull();
|
||||
expect(getCrossfadeTransition('')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and reads a transition plan', () => {
|
||||
setCrossfadeTransition('t1', { bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
|
||||
expect(getCrossfadeTransition('t1')).toEqual({ bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
|
||||
});
|
||||
|
||||
it('clamps negative values to 0 and ignores empty ids', () => {
|
||||
setCrossfadeTransition('t2', { bStartSec: -1, overlapSec: -2, outgoingFadeSec: -3 });
|
||||
expect(getCrossfadeTransition('t2')).toEqual({ bStartSec: 0, overlapSec: 0, outgoingFadeSec: 0 });
|
||||
setCrossfadeTransition('', { bStartSec: 3, overlapSec: 3, outgoingFadeSec: 3 });
|
||||
expect(getCrossfadeTransition('')).toBeNull();
|
||||
});
|
||||
|
||||
it('tracks planned ids independently', () => {
|
||||
expect(hasPlannedCrossfade('t3')).toBe(false);
|
||||
markPlannedCrossfade('t3');
|
||||
expect(hasPlannedCrossfade('t3')).toBe(true);
|
||||
});
|
||||
|
||||
it('evicts oldest entries past the cap', () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
setCrossfadeTransition(`k${i}`, { bStartSec: i, overlapSec: 1, outgoingFadeSec: 1 });
|
||||
}
|
||||
// First entries should have been evicted (cap 32).
|
||||
expect(getCrossfadeTransition('k0')).toBeNull();
|
||||
expect(getCrossfadeTransition('k39')).toEqual({ bStartSec: 39, overlapSec: 1, outgoingFadeSec: 1 });
|
||||
});
|
||||
|
||||
it('arms and consumes the dynamic overlap once, for the matching track', () => {
|
||||
armCrossfadeDynamicOverlap('b1', 4, 0);
|
||||
// Mismatched id consumes nothing and leaves the armed value intact.
|
||||
expect(consumeCrossfadeDynamicOverlap('other')).toBeNull();
|
||||
expect(consumeCrossfadeDynamicOverlap('b1')).toEqual({ overlapSec: 4, outgoingFadeSec: 0 });
|
||||
// One-shot: a second consume returns null.
|
||||
expect(consumeCrossfadeDynamicOverlap('b1')).toBeNull();
|
||||
});
|
||||
|
||||
it('carries the engine fade-out length for A (non-scenario-A swaps)', () => {
|
||||
armCrossfadeDynamicOverlap('b2', 0.5, 0.5);
|
||||
expect(consumeCrossfadeDynamicOverlap('b2')).toEqual({ overlapSec: 0.5, outgoingFadeSec: 0.5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Silence-aware crossfade — tiny module cache bridging the pre-buffer stage and
|
||||
* `playTrack`. During the crossfade pre-buffer window (`crossfadePreload`) we
|
||||
* fetch the *next* track's cached waveform and, together with the current
|
||||
* track's envelope, derive a per-transition plan: where the incoming track
|
||||
* should begin (leading silence skipped) and the adaptive overlap length.
|
||||
* `playTrackAction` then reads it to pass `audio_play(start_secs, crossfade_secs_override)`,
|
||||
* and `audioEventHandlers` reads the overlap to re-anchor the early A-tail advance.
|
||||
*
|
||||
* Kept out of the persisted Zustand store on purpose: this is ephemeral,
|
||||
* per-transition playback data, not user state.
|
||||
*/
|
||||
import type { CrossfadeTransitionPlan } from '../utils/waveform/waveformSilence';
|
||||
|
||||
export type { CrossfadeTransitionPlan } from '../utils/waveform/waveformSilence';
|
||||
|
||||
/** trackId → planned transition for when this track starts under crossfade. */
|
||||
const planByTrackId = new Map<string, CrossfadeTransitionPlan>();
|
||||
/** trackIds we've already attempted a plan for (avoids per-tick refetch). */
|
||||
const plannedTrackIds = new Set<string>();
|
||||
|
||||
// Bound both sets so a long session can't grow them without limit.
|
||||
const MAX_ENTRIES = 32;
|
||||
|
||||
function trim(map: { delete: (k: string) => void; size: number; keys: () => IterableIterator<string> }): void {
|
||||
while (map.size > MAX_ENTRIES) {
|
||||
const oldest = map.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the computed transition plan for `trackId`. */
|
||||
export function setCrossfadeTransition(trackId: string, plan: CrossfadeTransitionPlan): void {
|
||||
if (!trackId) return;
|
||||
planByTrackId.set(trackId, {
|
||||
bStartSec: Math.max(0, plan.bStartSec),
|
||||
overlapSec: Math.max(0, plan.overlapSec),
|
||||
outgoingFadeSec: Math.max(0, plan.outgoingFadeSec),
|
||||
});
|
||||
trim(planByTrackId);
|
||||
}
|
||||
|
||||
/** Read the cached transition plan for `trackId` (null when none/unknown). */
|
||||
export function getCrossfadeTransition(trackId: string): CrossfadeTransitionPlan | null {
|
||||
if (!trackId) return null;
|
||||
return planByTrackId.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
/** True once we've already attempted to plan a transition into `trackId`. */
|
||||
export function hasPlannedCrossfade(trackId: string): boolean {
|
||||
return plannedTrackIds.has(trackId);
|
||||
}
|
||||
|
||||
/** Mark `trackId` as planned so the pre-buffer loop doesn't refetch every tick. */
|
||||
export function markPlannedCrossfade(trackId: string): void {
|
||||
if (!trackId) return;
|
||||
plannedTrackIds.add(trackId);
|
||||
trim(plannedTrackIds);
|
||||
}
|
||||
|
||||
// ── One-shot dynamic-overlap hand-off (A-tail advance → playTrack) ──────────────
|
||||
// When the JS early-advance fires it "arms" the content-driven overlap for the
|
||||
// incoming track. `playTrack` consumes it to pass `crossfade_secs_override`, so the
|
||||
// per-transition fade length is applied *only* when JS controlled the advance
|
||||
// timing. Engine-driven advances (plain loud→loud endings) leave it unset and keep
|
||||
// the normal crossfade length — avoids muting the outgoing track's tail.
|
||||
let armedOverlapTrackId: string | null = null;
|
||||
let armedOverlapSec = 0;
|
||||
let armedOutgoingFadeSec = 0;
|
||||
|
||||
/** The fade lengths JS armed for one incoming transition. */
|
||||
export interface ArmedCrossfadeOverlap {
|
||||
/** Track B's fade-in length (the overlap). */
|
||||
overlapSec: number;
|
||||
/** Track A's engine fade-out length (0 = A rides its own recorded fade). */
|
||||
outgoingFadeSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the content-driven fade lengths JS just positioned for the incoming
|
||||
* `trackId`: B's fade-in (`overlapSec`) and A's engine fade-out
|
||||
* (`outgoingFadeSec`; 0 ⇒ let A ride its own recorded fade — scenario A).
|
||||
*/
|
||||
export function armCrossfadeDynamicOverlap(
|
||||
trackId: string,
|
||||
overlapSec: number,
|
||||
outgoingFadeSec: number,
|
||||
): void {
|
||||
if (!trackId) return;
|
||||
armedOverlapTrackId = trackId;
|
||||
armedOverlapSec = Math.max(0, overlapSec);
|
||||
armedOutgoingFadeSec = Math.max(0, outgoingFadeSec);
|
||||
}
|
||||
|
||||
/** Consume + clear the armed fades for `trackId` (null when none/mismatched). */
|
||||
export function consumeCrossfadeDynamicOverlap(trackId: string): ArmedCrossfadeOverlap | null {
|
||||
if (!trackId || armedOverlapTrackId !== trackId) return null;
|
||||
const overlapSec = armedOverlapSec;
|
||||
const outgoingFadeSec = armedOutgoingFadeSec;
|
||||
armedOverlapTrackId = null;
|
||||
armedOverlapSec = 0;
|
||||
armedOutgoingFadeSec = 0;
|
||||
return overlapSec > 0 ? { overlapSec, outgoingFadeSec } : null;
|
||||
}
|
||||
|
||||
/** Test/reset hook. */
|
||||
export function _resetCrossfadeTrimCacheForTest(): void {
|
||||
planByTrackId.clear();
|
||||
plannedTrackIds.clear();
|
||||
armedOverlapTrackId = null;
|
||||
armedOverlapSec = 0;
|
||||
armedOutgoingFadeSec = 0;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
|
||||
import {
|
||||
bumpPlayGeneration,
|
||||
getPlayGeneration,
|
||||
@@ -361,6 +362,25 @@ export function runPlayTrack(
|
||||
isReplayGainActive(), authStateNow.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
|
||||
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
|
||||
// under crossfade, start past this track's leading silence (always, from the
|
||||
// plan) and — only when the JS A-tail advance positioned this transition —
|
||||
// fade over the content-driven overlap it armed. Engine-driven advances
|
||||
// (plain loud→loud) leave the overlap unset and keep the normal crossfade
|
||||
// length. Manual skips hard-cut and resumes keep their saved offset.
|
||||
const useTrim =
|
||||
!manual
|
||||
&& authStateNow.crossfadeEnabled
|
||||
&& authStateNow.crossfadeTrimSilence
|
||||
&& !authStateNow.gaplessEnabled
|
||||
&& initialTime <= 0.05;
|
||||
const crossfadePlan = useTrim ? getCrossfadeTransition(scopedTrack.id) : null;
|
||||
const armedOverlap = useTrim ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
|
||||
const crossfadeStartSecs = crossfadePlan?.bStartSec ?? 0;
|
||||
const crossfadeSecsOverride = armedOverlap ? armedOverlap.overlapSec : null;
|
||||
// Scenario A: 0 ⇒ don't fade A (it rides its own recorded fade); only sent
|
||||
// when JS drove this advance, so engine-driven swaps keep today's behaviour.
|
||||
const outgoingFadeSecsOverride = armedOverlap ? armedOverlap.outgoingFadeSec : null;
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
@@ -376,6 +396,9 @@ export function runPlayTrack(
|
||||
serverId: getPlaybackIndexKey() || null,
|
||||
streamFormatSuffix: scopedTrack.suffix ?? null,
|
||||
startPaused: false,
|
||||
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
|
||||
crossfadeSecsOverride,
|
||||
outgoingFadeSecsOverride,
|
||||
})
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { playbackReportSeek } from './playbackReportSession';
|
||||
import { isRecoverableSeekError } from '../utils/audio/seekErrors';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { maybeCrossfadeBytePreload } from './crossfadePreload';
|
||||
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
|
||||
import type { PlayerState } from './playerStoreTypes';
|
||||
import { armSeekDebounce } from './seekDebounce';
|
||||
@@ -68,6 +69,10 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
|
||||
setSeekTarget(time);
|
||||
setSeekFallbackVisualTarget(null);
|
||||
clearSeekFallbackRetry();
|
||||
// Seeking straight into the crossfade pre-buffer window must kick off the
|
||||
// next-track download now — the progress-tick path is gated by the seek
|
||||
// settle guard, which would otherwise delay the buffer past the fade.
|
||||
maybeCrossfadeBytePreload(time, dur);
|
||||
}).catch((err: unknown) => {
|
||||
// Release the progress-tick guard so the UI doesn't freeze
|
||||
// waiting for a target the engine will never reach.
|
||||
|
||||
@@ -22,6 +22,30 @@ export type WaveformCachePayload = {
|
||||
* track is still the current one. Best-effort: any failure leaves the
|
||||
* seekbar with the placeholder waveform.
|
||||
*/
|
||||
/**
|
||||
* Fetch a track's cached waveform bins **without touching the player store** —
|
||||
* used by the silence-aware crossfade to inspect the *next* track's leading
|
||||
* silence while a different track is still playing (writing `waveformBins` here
|
||||
* would replace the current track's seekbar). Returns `null` on a cold miss /
|
||||
* any failure so callers degrade to no-trim.
|
||||
*/
|
||||
export async function fetchWaveformBins(
|
||||
trackId: string,
|
||||
serverId?: string | null,
|
||||
): Promise<number[] | null> {
|
||||
if (!trackId) return null;
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
|
||||
trackId,
|
||||
serverId: serverId ?? getPlaybackIndexKey() ?? null,
|
||||
});
|
||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||
return bins && bins.length > 0 ? bins : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshWaveformForTrack(trackId: string): Promise<void> {
|
||||
if (!trackId) return;
|
||||
const gen = getWaveformRefreshGen(trackId);
|
||||
|
||||
Reference in New Issue
Block a user