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
+7
View File
@@ -19,6 +19,7 @@ import { MiniControls } from './miniPlayer/MiniControls';
import { MiniToolbar } from './miniPlayer/MiniToolbar';
import { MiniQueue } from './miniPlayer/MiniQueue';
import { useMiniVolumePopover } from '../hooks/useMiniVolumePopover';
import { useMiniCrossfadePopover } from '../hooks/useMiniCrossfadePopover';
import { useMiniQueueDrag } from '../hooks/useMiniQueueDrag';
import { useMiniSync } from '../hooks/useMiniSync';
import { useMiniWindowSetup } from '../hooks/useMiniWindowSetup';
@@ -49,6 +50,7 @@ export default function MiniPlayer() {
return registerQueueDragHitTest(hitTest);
}, [queueOpen]);
const { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef } = useMiniVolumePopover();
const { crossfadeOpen, setCrossfadeOpen, crossfadePopStyle, crossfadeBtnRef, crossfadePopRef } = useMiniCrossfadePopover();
const {
isReorderDrag, psyDragFromIdxRef, dropTarget, setDropTarget, dropTargetRef, startDrag,
@@ -169,6 +171,11 @@ export default function MiniPlayer() {
volumePopStyle={volumePopStyle}
handleVolumeChange={handleVolumeChange}
toggleMute={toggleMute}
crossfadeOpen={crossfadeOpen}
setCrossfadeOpen={setCrossfadeOpen}
crossfadeBtnRef={crossfadeBtnRef}
crossfadePopRef={crossfadePopRef}
crossfadePopStyle={crossfadePopStyle}
queueOpen={queueOpen}
toggleQueue={toggleQueue}
t={t}
+4
View File
@@ -106,10 +106,12 @@ function QueuePanelHostOrSolo() {
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
const crossfadeTrimSilence = useAuthStore(s => s.crossfadeTrimSilence);
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setCrossfadeTrimSilence = useAuthStore(s => s.setCrossfadeTrimSilence);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
@@ -340,6 +342,8 @@ function QueuePanelHostOrSolo() {
setCrossfadeEnabled={setCrossfadeEnabled}
crossfadeSecs={crossfadeSecs}
setCrossfadeSecs={setCrossfadeSecs}
crossfadeTrimSilence={crossfadeTrimSilence}
setCrossfadeTrimSilence={setCrossfadeTrimSilence}
infiniteQueueEnabled={infiniteQueueEnabled}
setInfiniteQueueEnabled={setInfiniteQueueEnabled}
t={t}
+64 -2
View File
@@ -15,6 +15,11 @@ interface Props {
volumePopStyle: React.CSSProperties;
handleVolumeChange: (v: number) => void;
toggleMute: () => void;
crossfadeOpen: boolean;
setCrossfadeOpen: (updater: boolean | ((v: boolean) => boolean)) => void;
crossfadeBtnRef: React.RefObject<HTMLButtonElement | null>;
crossfadePopRef: React.RefObject<HTMLDivElement | null>;
crossfadePopStyle: React.CSSProperties;
queueOpen: boolean;
toggleQueue: () => void;
t: TFunction;
@@ -22,7 +27,9 @@ interface Props {
export function MiniToolbar({
state, volume, volumeOpen, setVolumeOpen, volumeBtnRef, volumePopRef, volumePopStyle,
handleVolumeChange, toggleMute, queueOpen, toggleQueue, t,
handleVolumeChange, toggleMute,
crossfadeOpen, setCrossfadeOpen, crossfadeBtnRef, crossfadePopRef, crossfadePopStyle,
queueOpen, toggleQueue, t,
}: Props) {
return (
<div className="mini-player__toolbar" data-tauri-drag-region="false">
@@ -111,15 +118,70 @@ export function MiniToolbar({
</button>
<button
ref={crossfadeBtnRef}
type="button"
className={`mini-player__tool${state.crossfadeEnabled ? ' mini-player__tool--active' : ''}`}
className={`mini-player__tool${state.crossfadeEnabled || crossfadeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
onContextMenu={(e) => { e.preventDefault(); setCrossfadeOpen(v => !v); }}
data-tauri-drag-region="false"
data-tooltip={t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
{crossfadeOpen && createPortal(
<div
ref={crossfadePopRef}
className="mini-player__crossfade-popover"
style={crossfadePopStyle}
data-tauri-drag-region="false"
>
<div className="mini-player__crossfade-modes">
<button
type="button"
className={`mini-player__crossfade-mode${!state.crossfadeTrimSilence ? ' active' : ''}`}
data-tauri-drag-region="false"
onClick={() => {
emit('mini:set-crossfade', { value: true }).catch(() => {});
emit('mini:set-crossfade-trim-silence', { value: false }).catch(() => {});
}}
>
{t('queue.crossfade')}
</button>
<button
type="button"
className={`mini-player__crossfade-mode${state.crossfadeTrimSilence ? ' active' : ''}`}
data-tauri-drag-region="false"
onClick={() => {
emit('mini:set-crossfade', { value: true }).catch(() => {});
emit('mini:set-crossfade-trim-silence', { value: true }).catch(() => {});
}}
>
{t('settings.autoDj')}
</button>
</div>
{!state.crossfadeTrimSilence && (
<>
<div className="mini-player__crossfade-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="mini-player__crossfade-value">{(state.crossfadeSecs ?? 3).toFixed(1)} s</span>
</div>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={state.crossfadeSecs ?? 3}
onChange={e => emit('mini:set-crossfade-secs', { value: parseFloat(e.target.value) }).catch(() => {})}
className="mini-player__crossfade-slider"
aria-label={t('queue.crossfade')}
/>
</>
)}
</div>,
document.body,
)}
<button
type="button"
+58 -28
View File
@@ -25,6 +25,8 @@ interface Props {
setCrossfadeEnabled: (v: boolean) => void;
crossfadeSecs: number;
setCrossfadeSecs: (v: number) => void;
crossfadeTrimSilence: boolean;
setCrossfadeTrimSilence: (v: boolean) => void;
infiniteQueueEnabled: boolean;
setInfiniteQueueEnabled: (v: boolean) => void;
t: TFunction;
@@ -34,7 +36,8 @@ export function QueueToolbar({
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
handleSave, handleLoad, handleCopyQueueShare, handleClear,
gaplessEnabled, setGaplessEnabled, crossfadeEnabled, setCrossfadeEnabled,
crossfadeSecs, setCrossfadeSecs, infiniteQueueEnabled, setInfiniteQueueEnabled,
crossfadeSecs, setCrossfadeSecs, crossfadeTrimSilence, setCrossfadeTrimSilence,
infiniteQueueEnabled, setInfiniteQueueEnabled,
t,
}: Props) {
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
@@ -124,14 +127,13 @@ export function QueueToolbar({
ref={crossfadeBtnRef}
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
onClick={() => {
if (crossfadeEnabled) {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setGaplessEnabled(false);
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
// Left click: toggle on/off only. Right click opens the popover.
if (!crossfadeEnabled) setGaplessEnabled(false);
setCrossfadeEnabled(!crossfadeEnabled);
}}
onContextMenu={(e) => {
e.preventDefault();
setShowCrossfadePopover(v => !v);
}}
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
aria-label={t('queue.crossfade')}
@@ -140,26 +142,54 @@ export function QueueToolbar({
</button>
{showCrossfadePopover && (
<div className="crossfade-popover" ref={crossfadePopoverRef}>
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>0.1s</span><span>10s</span>
<div className="crossfade-popover-modes">
<button
type="button"
className={`crossfade-popover-mode${!crossfadeTrimSilence ? ' active' : ''}`}
onClick={() => {
if (gaplessEnabled) setGaplessEnabled(false);
setCrossfadeTrimSilence(false);
setCrossfadeEnabled(true);
}}
>
{t('queue.crossfade')}
</button>
<button
type="button"
className={`crossfade-popover-mode${crossfadeTrimSilence ? ' active' : ''}`}
onClick={() => {
if (gaplessEnabled) setGaplessEnabled(false);
setCrossfadeTrimSilence(true);
setCrossfadeEnabled(true);
}}
>
{t('settings.autoDj')}
</button>
</div>
{!crossfadeTrimSilence && (
<>
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>0.1s</span><span>10s</span>
</div>
</>
)}
</div>
)}
</div>
@@ -10,8 +10,12 @@ interface Props {
* Crossfade ↔ Gapless are mutually exclusive — enabling one forces the
* other off (`setGaplessEnabled(false)` / `setCrossfadeEnabled(false)`
* on the toggle handlers) and the inactive row dims via opacity +
* pointerEvents:none. The crossfade-seconds slider only renders while
* crossfade is the active mode.
* pointerEvents:none.
*
* When crossfade is on, a "Crossfade | Smart crossfade" segmented switch
* (`crossfadeTrimSilence` false/true) picks the mode: classic crossfade
* exposes the seconds slider, smart crossfade is content-driven and has
* no duration to configure (just a short explainer).
*
* The `preservePlayNextOrder` toggle is independent of both and pinned
* to the bottom of the block.
@@ -37,20 +41,44 @@ export function PlaybackBehaviorBlock({ t }: Props) {
</label>
</div>
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={auth.crossfadeSecs}
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
id="crossfade-secs-slider"
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
</span>
<div style={{ paddingLeft: '1rem', marginTop: '0.6rem' }}>
<div className="settings-segmented">
<button
type="button"
className={`btn ${!auth.crossfadeTrimSilence ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => auth.setCrossfadeTrimSilence(false)}
>
{t('settings.crossfade')}
</button>
<button
type="button"
className={`btn ${auth.crossfadeTrimSilence ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => auth.setCrossfadeTrimSilence(true)}
>
{t('settings.autoDj')}
</button>
</div>
{auth.crossfadeTrimSilence ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.6rem' }}>
{t('settings.autoDjDesc')}
</div>
) : (
<div style={{ marginTop: '0.6rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={auth.crossfadeSecs}
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
id="crossfade-secs-slider"
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
</span>
</div>
)}
</div>
)}
+1 -1
View File
@@ -37,7 +37,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' },
{ tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
{ tab: 'audio', titleKey: 'settings.playbackRateTitle', keywords: 'speed playback rate tempo pitch varispeed preserve corrected time stretch' },
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade autodj auto dj smart crossfade gapless replaygain replay gain volume trim silence' },
{ tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
{ tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
{ tab: 'integrations', titleKey: 'musicNetwork.title', keywords: 'last.fm lastfm libre.fm rocksky listenbrainz maloja scrobble scrobbling music network' },