Files
Psychotoxical-psysonic/src/store/crossfadeTrimCache.ts
T
cucadmuh a6ee0668c8 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.
2026-06-18 02:15:20 +03:00

115 lines
4.6 KiB
TypeScript

/**
* 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;
}