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:
cucadmuh
2026-06-18 02:15:20 +03:00
committed by GitHub
parent ed52a9991f
commit a6ee0668c8
48 changed files with 1537 additions and 150 deletions
+111 -10
View File
@@ -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,
+2
View File
@@ -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 }),
+3
View File
@@ -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');
+1
View File
@@ -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 },
+7
View File
@@ -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;
+137
View File
@@ -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(() => {});
}
}
+60
View File
@@ -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 });
});
});
+114
View File
@@ -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;
}
+23
View File
@@ -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;
+5
View File
@@ -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.
+24
View File
@@ -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);