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
+4
View File
@@ -566,6 +566,10 @@ export const settings = {
crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}} s',
crossfadeTrimSilence: 'Stille zwischen Tracks kürzen',
crossfadeTrimSilenceDesc: 'Stille am Ende des aktuellen und am Anfang des nächsten Tracks überspringen, damit die Überblendung Musik statt Leere überlappt. Für zuverlässige Ergebnisse den Hot-Playback-Cache aktivieren, damit der nächste Track rechtzeitig bereit ist',
autoDj: 'AutoDJ',
autoDjDesc: 'Keine feste Dauer — AutoDJ richtet sich nach dem tatsächlichen Klang und überlappt echte Aus- und Einblendungen statt einer festen Sekundenzahl. Für zuverlässige Ergebnisse den Hot-Playback-Cache aktivieren.',
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
gapless: 'Nahtlose Wiedergabe',
+4
View File
@@ -633,6 +633,10 @@ export const settings = {
crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}} s',
crossfadeTrimSilence: 'Trim silence between tracks',
crossfadeTrimSilenceDesc: 'Skip trailing silence of the current track and leading silence of the next so the fade overlaps music, not dead air. For reliable results, enable the Hot playback cache so the next track is ready in time',
autoDj: 'AutoDJ',
autoDjDesc: 'No fixed duration — AutoDJ follows the actual audio, blending real fade-outs and intros instead of a set number of seconds. For reliable results, enable the Hot playback cache.',
notWithGapless: 'Not available while Gapless is active',
notWithCrossfade: 'Not available while Crossfade is active',
gapless: 'Gapless Playback',
+4
View File
@@ -565,6 +565,10 @@ export const settings = {
crossfade: 'Crossfade',
crossfadeDesc: 'Transición entre pistas',
crossfadeSecs: '{{n}} s',
crossfadeTrimSilence: 'Recortar el silencio entre pistas',
crossfadeTrimSilenceDesc: 'Omite el silencio al final de la pista actual y al inicio de la siguiente para que la transición se solape con la música, no con el vacío. Para resultados fiables, activa la Caché de reproducción activa para que la siguiente pista esté lista a tiempo',
autoDj: 'AutoDJ',
autoDjDesc: 'Sin duración fija: AutoDJ se adapta al audio real y se solapa con los fundidos e introducciones reales en lugar de un número fijo de segundos. Para resultados fiables, activa la Caché de reproducción activa.',
notWithGapless: 'No disponible mientras Gapless está activo',
notWithCrossfade: 'No disponible mientras Crossfade está activo',
gapless: 'Reproducción Gapless',
+4
View File
@@ -553,6 +553,10 @@ export const settings = {
crossfade: 'Fondu enchaîné',
crossfadeDesc: 'Fondu entre les pistes',
crossfadeSecs: '{{n}} s',
crossfadeTrimSilence: 'Couper le silence entre les pistes',
crossfadeTrimSilenceDesc: 'Ignorer le silence en fin de piste actuelle et en début de la suivante pour que le fondu se superpose à la musique, pas au vide. Pour un résultat fiable, activez le Cache de lecture à chaud afin que la piste suivante soit prête à temps',
autoDj: 'AutoDJ',
autoDjDesc: 'Sans durée fixe : AutoDJ suit laudio réel et se superpose aux vrais fondus et intros plutôt qu’à un nombre fixe de secondes. Pour un résultat fiable, activez le Cache de lecture à chaud.',
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
gapless: 'Lecture sans blanc',
+4
View File
@@ -552,6 +552,10 @@ export const settings = {
crossfade: 'Crossfade',
crossfadeDesc: 'Tone mellom spor',
crossfadeSecs: '{{n}}s',
crossfadeTrimSilence: 'Trim stillhet mellom spor',
crossfadeTrimSilenceDesc: 'Hopp over stillhet på slutten av gjeldende spor og starten på neste, slik at toningen overlapper musikk, ikke stillhet. For pålitelige resultater bør du aktivere Varm avspillingsbuffer slik at neste spor er klart i tide',
autoDj: 'AutoDJ',
autoDjDesc: 'Ingen fast varighet — AutoDJ følger selve lyden og overlapper ekte inn-/uttoninger i stedet for et fast antall sekunder. For pålitelige resultater bør du aktivere Varm avspillingsbuffer.',
notWithGapless: 'Ikke tilgjengelig mens Gapless er aktiv',
notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv',
gapless: 'Gapless avspilling',
+4
View File
@@ -553,6 +553,10 @@ export const settings = {
crossfade: 'Overgang',
crossfadeDesc: 'Fade tussen nummers',
crossfadeSecs: '{{n}} s',
crossfadeTrimSilence: 'Stilte tussen nummers bijsnijden',
crossfadeTrimSilenceDesc: 'Sla de stilte aan het einde van het huidige nummer en aan het begin van het volgende over, zodat de overgang muziek overlapt en geen stilte. Schakel voor betrouwbare resultaten de Warme afspeelcache in zodat het volgende nummer op tijd klaarstaat',
autoDj: 'AutoDJ',
autoDjDesc: 'Geen vaste duur — AutoDJ volgt de werkelijke audio en overlapt echte fades en intros in plaats van een vast aantal seconden. Schakel voor betrouwbare resultaten de Warme afspeelcache in.',
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
gapless: 'Naadloos afspelen',
+4
View File
@@ -568,6 +568,10 @@ export const settings = {
crossfade: 'Crossfade',
crossfadeDesc: 'Estompează între piese',
crossfadeSecs: '{{n}} s',
crossfadeTrimSilence: 'Elimină liniștea dintre piese',
crossfadeTrimSilenceDesc: 'Sare peste liniștea de la finalul piesei curente și de la începutul celei următoare, astfel încât estomparea să se suprapună peste muzică, nu peste gol. Pentru rezultate fiabile, activează Cache-ul hot playback ca piesa următoare să fie gata la timp',
autoDj: 'AutoDJ',
autoDjDesc: 'Fără durată fixă — AutoDJ urmează sunetul real și se suprapune peste estompările și introducerile reale, în loc de un număr fix de secunde. Pentru rezultate fiabile, activează Cache-ul hot playback.',
notWithGapless: 'Nu este valabil cât Gapless este activ',
notWithCrossfade: 'Nu este valabil cât Crossfade este activ',
gapless: 'Playback Gapless',
+4
View File
@@ -653,6 +653,10 @@ export const settings = {
crossfade: 'Кроссфейд',
crossfadeDesc: 'Плавный переход между треками',
crossfadeSecs: '{{n}} с',
crossfadeTrimSilence: 'Обрезать тишину между треками',
crossfadeTrimSilenceDesc: 'Пропускать тишину в конце текущего трека и в начале следующего, чтобы переход накладывался на музыку, а не на пустоту. Для надёжной работы крайне желательно включить «Горячий кэш воспроизведения», чтобы следующий трек был готов вовремя',
autoDj: 'AutoDJ',
autoDjDesc: 'Без фиксированной длительности — AutoDJ подстраивается под само звучание, накладываясь на реальные затухания и вступления, а не на заданное число секунд. Для надёжной работы включите «Горячий кэш воспроизведения».',
notWithGapless: 'Недоступно при включённом режиме без пауз',
notWithCrossfade: 'Недоступно при включённом кроссфейде',
gapless: 'Без пауз между треками',
+4
View File
@@ -552,6 +552,10 @@ export const settings = {
crossfade: '交叉淡入淡出',
crossfadeDesc: '曲目间淡入淡出',
crossfadeSecs: '{{n}} 秒',
crossfadeTrimSilence: '修剪曲目间的静音',
crossfadeTrimSilenceDesc: '跳过当前曲目结尾和下一曲目开头的静音,让淡变叠加在音乐而非空白上。为获得稳定效果,强烈建议启用「热播放缓存」,以便下一曲目及时就绪',
autoDj: 'AutoDJ',
autoDjDesc: '没有固定时长——AutoDJ 跟随实际音频,叠加在真实的淡入淡出上,而不是固定的秒数。为获得稳定效果,请启用「热播放缓存」。',
notWithGapless: '无缝播放开启时不可用',
notWithCrossfade: '交叉淡入淡出开启时不可用',
gapless: '无缝播放',