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:
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user