mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
a6ee0668c8
* 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.
115 lines
4.6 KiB
TypeScript
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;
|
|
}
|