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
+6
View File
@@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Organise your playlists into folders on the Playlists page and in the sidebar — create folders, drag playlists into them (or use the right-click "Move to folder" menu), rename, collapse and switch between the folder view and a single flat list. Folders are saved locally on this device only, since the Subsonic API has no folder support.
### AutoDJ — content-aware crossfade
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1122](https://github.com/Psychotoxical/psysonic/pull/1122)**
* New **AutoDJ** crossfade mode (next to classic Crossfade in the audio settings and the crossfade popover). Instead of a fixed crossfade time, it blends what you actually hear: it trims the dead silence at the end of one track and the start of the next, and picks the overlap from the music itself — a track that fades out rides its own fade while the next one rises underneath, and two tracks that both start/end loud get a short musical blend instead of an abrupt cut. Off by default; classic Crossfade is unchanged. Works most reliably with the Hot playback cache enabled, since the next track's audio needs to be ready for the blend.
## Fixed
@@ -20,7 +20,7 @@ use super::sink_swap::{
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
SinkSwapInputs,
};
use super::playback_rate::preserve_pitch_will_run;
use super::playback_rate::{preserve_pitch_will_run, raw_counter_samples_for_content_position};
use super::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task;
use super::state::{ChainedInfo, PreloadedTrack};
@@ -57,10 +57,26 @@ pub async fn audio_play(
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
// `false` so older/external `audio_play` callers that omit it still work.
start_paused: Option<bool>,
// Silence-aware crossfade (B-head): begin playback past the next track's
// leading silence. Optional + defaults to `0` so existing callers are
// unaffected; only applied when the freshly built source is seekable.
start_secs: Option<f64>,
// Dynamic crossfade (phase 2): per-transition overlap length, computed by the
// frontend from both tracks' waveform envelopes. Caps the fade for *this*
// transition instead of the global `crossfade_secs`. `None` → use the global
// setting (today's behaviour); always still clamped to the measured remaining.
crossfade_secs_override: Option<f32>,
// Scenario A (dynamic crossfade): engine fade-out length for the *outgoing*
// track A, decoupled from B's fade-in. `Some(0)` → don't fade A at all (it
// already fades out in the recording, so let it ride at full engine gain
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
// B's fade (today's behaviour). Always clamped to A's measured remaining.
outgoing_fade_secs_override: Option<f32>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let start_paused = start_paused.unwrap_or(false);
let start_secs = start_secs.unwrap_or(0.0).max(0.0);
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
@@ -222,7 +238,11 @@ pub async fn audio_play(
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
// Per-transition override (dynamic crossfade) caps the fade for this swap;
// otherwise fall back to the global crossfade length. Both clamped the same.
let crossfade_secs_val = crossfade_secs_override
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
.clamp(0.5, 12.0);
// Measure how much audio Track A actually has left right now.
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
@@ -247,6 +267,19 @@ pub async fn audio_play(
Duration::from_millis(5)
};
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
// override means A already fades out in the recording, so we leave it at
// full engine gain (scenario A). Never longer than A's remaining audio.
let outgoing_fade_secs: f32 = if crossfade_enabled {
match outgoing_fade_secs_override {
Some(v) => v.max(0.0).min(actual_fade_secs),
None => actual_fade_secs,
}
} else {
0.0
};
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
@@ -286,8 +319,9 @@ pub async fn audio_play(
e
})?;
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
let source_seekable = playback_source.is_seekable;
let built = playback_source.built;
let source = built.source;
let mut source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
let output_channels = built.output_channels;
@@ -397,6 +431,17 @@ pub async fn audio_play(
}
}
// Silence-aware crossfade (B-head): skip the next track's leading silence by
// seeking the freshly built source before it is appended. The outermost
// `CountingSource` stores the sample counter on a successful seek; we still
// re-seed `samples_played` + `seek_offset` explicitly after the swap (below)
// so the seekbar and the crossfade-remaining math are content-relative.
let did_start_seek = if start_secs > 0.05 && source_seekable {
source.try_seek(Duration::from_secs_f64(start_secs)).is_ok()
} else {
false
};
sink.append(source);
if needs_prefill {
@@ -423,9 +468,29 @@ pub async fn audio_play(
fadeout_samples: built.fadeout_samples,
crossfade_enabled,
actual_fade_secs,
outgoing_fade_secs,
start_paused,
});
// B-head: `swap_in_new_sink` resets `seek_offset` to 0 and starts the play
// clock — re-anchor both the wall-clock baseline (`seek_offset`) and the
// sample counter to the content offset so position reporting is correct.
if did_start_seek {
{
let mut cur = state.current.lock().unwrap();
cur.seek_offset = start_secs;
}
state.samples_played.store(
raw_counter_samples_for_content_position(
start_secs,
output_rate,
output_channels as u32,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
if defer_playback_start {
if !start_paused {
let mut cur = state.current.lock().unwrap();
@@ -455,6 +520,7 @@ pub async fn audio_play(
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
state.autodj_suppress_autocrossfade.clone(),
done_flag,
app,
Some(analysis_app),
@@ -212,6 +212,7 @@ pub(crate) async fn try_resume_after_device_change(
fadeout_samples: ps.built.fadeout_samples,
crossfade_enabled: false,
actual_fade_secs: 0.0,
outgoing_fade_secs: 0.0,
start_paused: false,
},
);
@@ -261,6 +262,7 @@ pub(crate) async fn try_resume_after_device_change(
engine.chained_info.clone(),
engine.crossfade_enabled.clone(),
engine.crossfade_secs.clone(),
engine.autodj_suppress_autocrossfade.clone(),
done_flag,
app.clone(),
Some(analysis_app),
@@ -60,6 +60,12 @@ pub struct AudioEngine {
pub(crate) stream_playback_armed: Arc<AtomicBool>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>,
/// AutoDJ: when true, the progress task does NOT fire its autonomous
/// `crossfade_secs`-before-end `audio:ended` timer — the JS A-tail logic
/// drives every advance (gated on the next track being playable). Prevents
/// the engine from starting a still-buffering next track and fading over it
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
/// When true, audio_play chains sources to the existing Sink instead of
/// creating a new one, achieving sample-accurate gapless transitions.
@@ -475,6 +481,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
@@ -143,6 +143,17 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
/// AutoDJ: when `true`, the progress task stops firing its autonomous
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
/// (only when the next track is actually playable). When `false`, the engine's
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
#[tauri::command]
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
state
.autodj_suppress_autocrossfade
.store(enabled, Ordering::Relaxed);
}
#[tauri::command]
pub fn audio_set_playback_rate(
enabled: bool,
@@ -119,6 +119,7 @@ pub async fn audio_preload(
duration_hint: f64,
analysis_track_id: Option<String>,
server_id: Option<String>,
eager: Option<bool>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -183,12 +184,17 @@ pub async fn audio_preload(
// Throttle: wait 8 s before starting the background download so it does not
// compete with the decode + sink-feed work of the just-started current track.
// If the user skips during the wait the generation counter changes and we abort.
// Eager callers (crossfade/AutoDJ pre-buffer, fired ~30 s before the fade
// when the current track is long-settled) skip the wait so the RAM slot
// fills in time for the fade to fire. If the user skips during the wait the
// generation counter changes and we abort.
let gen_snapshot = state.generation.load(Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
if !eager.unwrap_or(false) {
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
}
}
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
@@ -61,6 +61,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
crossfade_enabled_arc: Arc<AtomicBool>,
crossfade_secs_arc: Arc<AtomicU32>,
autodj_suppress_arc: Arc<AtomicBool>,
initial_done: Arc<AtomicBool>,
emitter: E,
analysis_app: Option<AppHandle>,
@@ -245,7 +246,12 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
continue;
}
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
// AutoDJ may suppress the autonomous crossfade trigger so JS drives
// every advance (gated on the next track being playable). Treat it
// like crossfade-off here: only emit `audio:ended` on real source
// exhaustion (above) or the watchdog — never the early timer.
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed)
&& !autodj_suppress_arc.load(Ordering::Relaxed);
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
@@ -335,6 +341,7 @@ mod tests {
chained: Arc<Mutex<Option<ChainedInfo>>>,
crossfade_enabled: Arc<AtomicBool>,
crossfade_secs: Arc<AtomicU32>,
autodj_suppress: Arc<AtomicBool>,
done: Arc<AtomicBool>,
samples_played: Arc<AtomicU64>,
sample_rate: Arc<AtomicU32>,
@@ -365,6 +372,7 @@ mod tests {
chained: Arc::new(Mutex::new(None)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
autodj_suppress: Arc::new(AtomicBool::new(false)),
done: Arc::new(AtomicBool::new(false)),
samples_played: Arc::new(AtomicU64::new(0)),
sample_rate: Arc::new(AtomicU32::new(44_100)),
@@ -384,6 +392,7 @@ mod tests {
self.chained.clone(),
self.crossfade_enabled.clone(),
self.crossfade_secs.clone(),
self.autodj_suppress.clone(),
self.done.clone(),
emitter,
None,
@@ -639,4 +648,34 @@ mod tests {
);
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn autodj_suppress_does_not_fire_crossfade_timer() {
// AutoDJ suppression on: even with crossfade enabled and the position
// inside the crossfade window, the autonomous timer must NOT emit
// audio:ended (JS drives the advance, gated on the next track being
// ready). The real end is still reached via source exhaustion.
let h = TaskHarness::new(120.0);
h.crossfade_enabled.store(true, Ordering::SeqCst);
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
h.autodj_suppress.store(true, Ordering::SeqCst);
// Position inside the crossfade window (>= dur - 5 s), source not done.
let played = (117.0 * 44_100.0 * 2.0) as u64;
h.samples_played.store(played, Ordering::SeqCst);
let emitter = Arc::new(MockEmitter::default());
h.spawn_with(emitter.clone());
tokio::time::sleep(Duration::from_millis(1300)).await;
assert_eq!(
emitter.ended_count(),
0,
"suppressed AutoDJ must not fire the autonomous crossfade timer"
);
// Source exhausts → audio:ended fires (clean sequential end).
h.done.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(emitter.ended_count(), 1, "audio:ended fires on exhaustion");
}
}
@@ -184,6 +184,7 @@ pub async fn audio_play_radio(
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
state.autodj_suppress_autocrossfade.clone(),
done_flag,
app,
None,
@@ -90,6 +90,9 @@ pub(crate) struct SinkSwapInputs {
pub(crate) fadeout_samples: Arc<AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
pub(crate) outgoing_fade_secs: f32,
pub(crate) start_paused: bool,
}
@@ -107,6 +110,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
outgoing_fade_secs,
start_paused,
} = inputs;
@@ -135,15 +139,21 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
// Trigger sample-level fade-out on Track A via TriggeredFadeOut
// unless `outgoing_fade_secs` is 0 (scenario A): then A keeps full
// engine gain and its own recorded fade carries it down while B
// rises underneath. Either way the old sink is kept alive until B's
// fade-in window elapses, so A plays out its tail before being
// dropped.
if outgoing_fade_secs > 0.0 {
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (outgoing_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
}
// Keep old sink alive until the fade completes + small margin,
@@ -151,7 +161,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs.max(outgoing_fade_secs) + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
+10 -5
View File
@@ -221,13 +221,18 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// For mid-track seeks: skip straight to unity gain so the new position
// plays at full volume immediately — no audible fade-in glitch.
// For seeks to the very start (< 100 ms): keep the micro-fade to
// suppress any DC-offset click from the fresh decode.
if pos.as_millis() < 100 {
if self.sample_count == 0 {
// Seek before any audio has played → this is the initial start-offset
// seek (B-head: skip the incoming track's leading silence). Keep the
// fade-in (`sample_count` stays 0) so a crossfaded track still rises
// in from its trimmed start instead of popping in at full gain.
} else if pos.as_millis() < 100 {
// Mid-playback seek to the very start: keep the micro-fade to
// suppress any DC-offset click from the fresh decode.
self.sample_count = 0;
} else {
// Mid-playback seek elsewhere (user dragging the seekbar): skip
// straight to unity gain so the new position is at full volume.
self.sample_count = self.fade_samples;
}
self.inner.try_seek(pos)
@@ -204,6 +204,7 @@ mod tests {
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0)),
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
+1
View File
@@ -735,6 +735,7 @@ pub fn run() {
audio::preview::audio_preview_set_volume,
audio::mix_commands::audio_set_crossfade,
audio::mix_commands::audio_set_gapless,
audio::mix_commands::audio_set_autodj_suppress,
audio::mix_commands::audio_set_normalization,
audio::device_commands::audio_list_devices,
audio::device_commands::audio_canonicalize_selected_device,
+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' },
+1
View File
@@ -166,6 +166,7 @@ const CONTRIBUTOR_ENTRIES = [
'OpenSubsonic playbackReport — live now-playing state, gliding position bar, and immediate pause/resume on Navidrome ≥0.62 (PR #1080)',
'Playback speed follow-up — Semitones varispeed strategy, two-decimal speed label, per-strategy tooltips, and Advanced fine-step toggle (PR #1084)',
'Streamed Opus/Ogg seeking via on-demand HTTP Range fetches — seek mid-stream without a full pre-download (PR #1110)',
'AutoDJ — content-aware crossfade: waveform-driven silence trim, content-driven overlap, scenario-A self-fade, readiness gate + engine auto-crossfade suppression (PR #1122)',
],
},
{
+73
View File
@@ -0,0 +1,73 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
/** Shared open-state, refs, and fixed positioning for a portaled mini-player
* popover anchored to a toolbar button. The trigger sits inside a short
* window, so the popover flips above when there's not enough room below.
* Closes on outside click or Escape. Volume + crossfade popovers share this. */
export function useMiniAnchoredPopover(popWidth: number, popHeight: number) {
const [open, setOpen] = useState(false);
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const updatePopStyle = () => {
if (!btnRef.current) return;
const rect = btnRef.current.getBoundingClientRect();
const MARGIN = 6;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < popHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left + rect.width / 2 - popWidth / 2, 6),
window.innerWidth - popWidth - 6,
);
setPopStyle({
position: 'fixed',
left,
width: popWidth,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!open) return;
updatePopStyle();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
const onReposition = () => updatePopStyle();
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
return () => {
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (!btnRef.current?.contains(target) && !popRef.current?.contains(target)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return { open, setOpen, popStyle, btnRef, popRef };
}
+15
View File
@@ -0,0 +1,15 @@
import { useMiniAnchoredPopover } from './useMiniAnchoredPopover';
/** Open-state, refs, and fixed positioning of the portaled mini-player
* crossfade settings popover (seconds slider + trim-silence toggle). Opened by
* right-click on the crossfade toolbar button. */
export function useMiniCrossfadePopover() {
const { open, setOpen, popStyle, btnRef, popRef } = useMiniAnchoredPopover(190, 120);
return {
crossfadeOpen: open,
setCrossfadeOpen: setOpen,
crossfadePopStyle: popStyle,
crossfadeBtnRef: btnRef,
crossfadePopRef: popRef,
};
}
+11 -70
View File
@@ -1,74 +1,15 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useMiniAnchoredPopover } from './useMiniAnchoredPopover';
/** Manages the open-state, refs, and fixed positioning of the portaled
* mini-player volume popover. The trigger sits inside a short window, so the
* popover flips above when there's not enough room below. Closes on outside
* click or Escape. */
/** Open-state, refs, and fixed positioning of the portaled mini-player volume
* popover. Thin wrapper over {@link useMiniAnchoredPopover} that preserves the
* `volume*` field names its consumer expects. */
export function useMiniVolumePopover() {
const [volumeOpen, setVolumeOpen] = useState(false);
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
const volumeBtnRef = useRef<HTMLButtonElement>(null);
const volumePopRef = useRef<HTMLDivElement>(null);
const updateVolumePopStyle = () => {
if (!volumeBtnRef.current) return;
const rect = volumeBtnRef.current.getBoundingClientRect();
const MARGIN = 6;
const POP_W = 40;
const POP_H = 150;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < POP_H && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.left + rect.width / 2 - POP_W / 2, 6),
window.innerWidth - POP_W - 6,
);
setVolumePopStyle({
position: 'fixed',
left,
width: POP_W,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
zIndex: 99998,
});
const { open, setOpen, popStyle, btnRef, popRef } = useMiniAnchoredPopover(40, 150);
return {
volumeOpen: open,
setVolumeOpen: setOpen,
volumePopStyle: popStyle,
volumeBtnRef: btnRef,
volumePopRef: popRef,
};
useLayoutEffect(() => {
if (!volumeOpen) return;
updateVolumePopStyle();
}, [volumeOpen]);
useEffect(() => {
if (!volumeOpen) return;
const onReposition = () => updateVolumePopStyle();
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
return () => {
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
};
}, [volumeOpen]);
useEffect(() => {
if (!volumeOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
!volumeBtnRef.current?.contains(target) &&
!volumePopRef.current?.contains(target)
) setVolumeOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setVolumeOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [volumeOpen]);
return { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef };
}
+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: '无缝播放',
+111 -10
View File
@@ -85,6 +85,27 @@ import {
getSeekTargetSetAt,
} from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
import { analyzeBoundary, computeWaveformSilence, STANDARD_BLEND_SEC } from '../utils/waveform/waveformSilence';
import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from './crossfadePreload';
import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
// Silence-aware crossfade (A-tail): guards the early advance to once per play
// generation so a single playback instance triggers at most one trim-advance
// (re-arms automatically on the next play / repeat-all loop, never loops on a
// backward seek within the same playback).
let crossfadeTrimAdvanceGen = -1;
// AutoDJ: mirror of the engine's `autodj_suppress_autocrossfade` flag so we only
// invoke the setter on change. When a content fade is pending for the upcoming
// transition we suppress the engine's autonomous crossfade timer and let the JS
// A-tail advance drive it (gated on the next track being playable) — otherwise
// the engine would start a still-buffering next track and fade over it (a jump).
let autodjSuppressSent: boolean | null = null;
function syncAutodjSuppress(want: boolean): void {
if (autodjSuppressSent === want) return;
autodjSuppressSent = want;
invoke('audio_set_autodj_suppress', { enabled: want }).catch(() => {});
}
/** Rust-side `audio:normalization-state` event payload. */
export type NormalizationStatePayload = {
@@ -231,19 +252,100 @@ export function handleAudioProgress(
// Pre-buffer / pre-chain next track for gapless and crossfade.
const {
gaplessEnabled,
hotCacheEnabled,
crossfadeEnabled,
crossfadeSecs,
crossfadeTrimSilence,
} = useAuthStore.getState();
const remaining = dur - current_time;
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
// Crossfade needs the next track bytes ready before the fade window.
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
const shouldBytePreloadForCrossfade =
!hotCacheEnabled && !gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
// Silence-aware crossfade — current track's trailing silence, derived once
// from its cached waveform. Drives both the early A-tail advance AND a wider
// pre-buffer window (the early advance must not outrun the next track's
// download, or its stream starts late and the fade has nothing to overlap).
const trimActive =
crossfadeEnabled && crossfadeTrimSilence && !gaplessEnabled && !store.currentRadio;
const curTrailSilenceSec = trimActive
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
: 0;
if (shouldChainGapless || shouldBytePreloadForCrossfade || gaplessEnabled) {
// A-tail: start the next track early so the fade overlaps *audible* tail/head.
// The overlap is content-driven ("by fact"): the planned value (A fade-out vs
// B buildup) when ready, else A's own fade-out measured synchronously from its
// already-loaded waveform. We only pre-empt the engine's own crossfade trigger
// (which fires `crossfadeSecs` before the end) when we'd actually start
// earlier — i.e. there's dead air to skip OR the content overlap is longer than
// crossfadeSecs (a real fade/buildup). Plain loud→loud endings fall through to
// the normal engine crossfade.
// When a content fade is pending we suppress the engine's autonomous
// crossfade timer (set below) so JS solely drives this transition; cleared
// for plain loud→loud (engine keeps its normal crossfade) and non-AutoDJ.
let autodjSuppressWant = false;
if (trimActive && store.isPlaying && store.repeatMode !== 'one') {
const nextIdx = store.queueIndex + 1;
const nextRef = nextIdx < store.queueItems.length
? store.queueItems[nextIdx]
: (store.repeatMode === 'all' && store.queueItems.length > 0 ? store.queueItems[0] : null);
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
if (nextTrackId) {
const cf = Math.max(0.1, Math.min(12, crossfadeSecs));
const plan = getCrossfadeTransition(nextTrackId);
let contentOverlap: number;
// Scenario A: does A carry its own recorded fade-out? If so we let it ride
// at full engine gain (no double fade) and bring B up underneath.
let aRidesOwnFade: boolean;
if (plan && plan.overlapSec > 0) {
contentOverlap = plan.overlapSec;
aRidesOwnFade = plan.outgoingFadeSec <= 0.001;
} else {
// No next-track envelope (cold plan) → judge A from its own waveform.
const aShape = analyzeBoundary(store.waveformBins, dur);
contentOverlap = aShape.outroFadeSec;
aRidesOwnFade = aShape.outroFadeSec >= 1.0;
}
const wantEarly = curTrailSilenceSec > 0.3 || contentOverlap > cf + 0.3;
if (wantEarly) {
// This transition is ours to drive: stop the engine from firing its own
// crossfade into a possibly-cold next track. If B never readies, A plays
// out to its natural end and we degrade to a clean sequential start.
autodjSuppressWant = true;
let overlap = Math.max(0.5, Math.min(12, contentOverlap || 0.5));
// Hard, loud→loud meeting reached by trimming protective silence (no
// natural fade to ride): use a standard ~2 s blend instead of a near-cut.
if (!aRidesOwnFade && overlap < STANDARD_BLEND_SEC) overlap = STANDARD_BLEND_SEC;
const outgoingFade = aRidesOwnFade ? 0 : overlap;
const triggerAt = Math.max(0, dur - curTrailSilenceSec - overlap);
const gen = getPlayGeneration();
// Readiness gate: only advance when B's audio is actually available (RAM
// preload slot or local on disk). A cold stream can't sustain a stable
// fade, so we leave the gen guard unset and re-check on later ticks — if
// B readies before A ends we fade then; if never, A plays out (engine
// timer suppressed) and the source-exhaustion end gives a clean cut.
if (
current_time >= triggerAt
&& crossfadeTrimAdvanceGen !== gen
&& isCrossfadeNextReady(
nextTrackId,
playbackProfileIdForRef(nextRef),
playbackCacheKeyForRef(nextRef),
)
) {
crossfadeTrimAdvanceGen = gen;
armCrossfadeDynamicOverlap(nextTrackId, overlap, outgoingFade);
store.next(false);
return;
}
}
}
}
syncAutodjSuppress(autodjSuppressWant);
// Crossfade pre-buffer (next-track byte download + leading-silence probe).
// Self-gating; also invoked right after a seek into the window (see seekAction).
maybeCrossfadeBytePreload(current_time, dur);
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
if (gaplessEnabled) {
const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
// Next track for preload/chain. The resolver bridge keeps the window around
@@ -281,9 +383,9 @@ export function handleAudioProgress(
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — gapless backup or crossfade; runs early so bytes are ready by chain time.
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
if (
(shouldBytePreloadForCrossfade || shouldBytePreloadForGaplessBackup)
shouldBytePreloadForGaplessBackup
&& nextTrack.id !== getBytePreloadingId()
) {
setBytePreloadingId(nextTrack.id);
@@ -294,7 +396,6 @@ export function handleAudioProgress(
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
nextUrl,
shouldBytePreloadForCrossfade,
shouldBytePreloadForGaplessBackup,
remaining,
gaplessEnabled,
+2
View File
@@ -27,6 +27,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
| 'setReplayGainFallbackDb'
| 'setCrossfadeEnabled'
| 'setCrossfadeSecs'
| 'setCrossfadeTrimSilence'
| 'setGaplessEnabled'
| 'setEnableHiRes'
| 'setAudioOutputDevice'
@@ -67,6 +68,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
},
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setCrossfadeTrimSilence: (v) => set({ crossfadeTrimSilence: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
+3
View File
@@ -49,6 +49,9 @@ describe('hydration — loads existing localStorage shape', () => {
const s = useAuthStore.getState();
expect(s.trackPreviewsEnabled).toBe(true);
expect(s.crossfadeEnabled).toBe(false);
// Existing installs that predate the toggle have no persisted field — it
// must default OFF so behaviour is unchanged until the user opts in.
expect(s.crossfadeTrimSilence).toBe(false);
expect(s.gaplessEnabled).toBe(false);
expect(s.replayGainEnabled).toBe(false);
expect(s.normalizationEngine).toBe('off');
+1
View File
@@ -53,6 +53,7 @@ export const useAuthStore = create<AuthState>()(
replayGainFallbackDb: 0,
crossfadeEnabled: false,
crossfadeSecs: 3,
crossfadeTrimSilence: false,
gaplessEnabled: false,
trackPreviewsEnabled: true,
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
+7
View File
@@ -119,6 +119,12 @@ export interface AuthState {
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
crossfadeEnabled: boolean;
crossfadeSecs: number;
/**
* When crossfading, trim trailing silence of the outgoing track and leading
* silence of the incoming one so the fade overlaps music, not dead air.
* Default off existing installs without this field keep today's behaviour.
*/
crossfadeTrimSilence: boolean;
gaplessEnabled: boolean;
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
trackPreviewsEnabled: boolean;
@@ -338,6 +344,7 @@ export interface AuthState {
setReplayGainFallbackDb: (v: number) => void;
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setCrossfadeTrimSilence: (v: boolean) => void;
setGaplessEnabled: (v: boolean) => void;
setTrackPreviewsEnabled: (v: boolean) => void;
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
+137
View File
@@ -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(() => {});
}
}
+60
View File
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_resetCrossfadeTrimCacheForTest,
armCrossfadeDynamicOverlap,
consumeCrossfadeDynamicOverlap,
getCrossfadeTransition,
hasPlannedCrossfade,
markPlannedCrossfade,
setCrossfadeTransition,
} from './crossfadeTrimCache';
describe('crossfadeTrimCache', () => {
beforeEach(() => _resetCrossfadeTrimCacheForTest());
it('returns null for unknown / empty track ids', () => {
expect(getCrossfadeTransition('nope')).toBeNull();
expect(getCrossfadeTransition('')).toBeNull();
});
it('stores and reads a transition plan', () => {
setCrossfadeTransition('t1', { bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
expect(getCrossfadeTransition('t1')).toEqual({ bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
});
it('clamps negative values to 0 and ignores empty ids', () => {
setCrossfadeTransition('t2', { bStartSec: -1, overlapSec: -2, outgoingFadeSec: -3 });
expect(getCrossfadeTransition('t2')).toEqual({ bStartSec: 0, overlapSec: 0, outgoingFadeSec: 0 });
setCrossfadeTransition('', { bStartSec: 3, overlapSec: 3, outgoingFadeSec: 3 });
expect(getCrossfadeTransition('')).toBeNull();
});
it('tracks planned ids independently', () => {
expect(hasPlannedCrossfade('t3')).toBe(false);
markPlannedCrossfade('t3');
expect(hasPlannedCrossfade('t3')).toBe(true);
});
it('evicts oldest entries past the cap', () => {
for (let i = 0; i < 40; i++) {
setCrossfadeTransition(`k${i}`, { bStartSec: i, overlapSec: 1, outgoingFadeSec: 1 });
}
// First entries should have been evicted (cap 32).
expect(getCrossfadeTransition('k0')).toBeNull();
expect(getCrossfadeTransition('k39')).toEqual({ bStartSec: 39, overlapSec: 1, outgoingFadeSec: 1 });
});
it('arms and consumes the dynamic overlap once, for the matching track', () => {
armCrossfadeDynamicOverlap('b1', 4, 0);
// Mismatched id consumes nothing and leaves the armed value intact.
expect(consumeCrossfadeDynamicOverlap('other')).toBeNull();
expect(consumeCrossfadeDynamicOverlap('b1')).toEqual({ overlapSec: 4, outgoingFadeSec: 0 });
// One-shot: a second consume returns null.
expect(consumeCrossfadeDynamicOverlap('b1')).toBeNull();
});
it('carries the engine fade-out length for A (non-scenario-A swaps)', () => {
armCrossfadeDynamicOverlap('b2', 0.5, 0.5);
expect(consumeCrossfadeDynamicOverlap('b2')).toEqual({ overlapSec: 0.5, outgoingFadeSec: 0.5 });
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* 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;
}
+23
View File
@@ -20,6 +20,7 @@ import {
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { useAuthStore } from './authStore';
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
import {
bumpPlayGeneration,
getPlayGeneration,
@@ -361,6 +362,25 @@ export function runPlayTrack(
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
// under crossfade, start past this track's leading silence (always, from the
// plan) and — only when the JS A-tail advance positioned this transition —
// fade over the content-driven overlap it armed. Engine-driven advances
// (plain loud→loud) leave the overlap unset and keep the normal crossfade
// length. Manual skips hard-cut and resumes keep their saved offset.
const useTrim =
!manual
&& authStateNow.crossfadeEnabled
&& authStateNow.crossfadeTrimSilence
&& !authStateNow.gaplessEnabled
&& initialTime <= 0.05;
const crossfadePlan = useTrim ? getCrossfadeTransition(scopedTrack.id) : null;
const armedOverlap = useTrim ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
const crossfadeStartSecs = crossfadePlan?.bStartSec ?? 0;
const crossfadeSecsOverride = armedOverlap ? armedOverlap.overlapSec : null;
// Scenario A: 0 ⇒ don't fade A (it rides its own recorded fade); only sent
// when JS drove this advance, so engine-driven swaps keep today's behaviour.
const outgoingFadeSecsOverride = armedOverlap ? armedOverlap.outgoingFadeSec : null;
invoke('audio_play', {
url,
volume: state.volume,
@@ -376,6 +396,9 @@ export function runPlayTrack(
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: scopedTrack.suffix ?? null,
startPaused: false,
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
crossfadeSecsOverride,
outgoingFadeSecsOverride,
})
.then(() => {
if (getPlayGeneration() !== gen) return;
+5
View File
@@ -3,6 +3,7 @@ import { playbackReportSeek } from './playbackReportSession';
import { isRecoverableSeekError } from '../utils/audio/seekErrors';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from './authStore';
import { maybeCrossfadeBytePreload } from './crossfadePreload';
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
import type { PlayerState } from './playerStoreTypes';
import { armSeekDebounce } from './seekDebounce';
@@ -68,6 +69,10 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
setSeekTarget(time);
setSeekFallbackVisualTarget(null);
clearSeekFallbackRetry();
// Seeking straight into the crossfade pre-buffer window must kick off the
// next-track download now — the progress-tick path is gated by the seek
// settle guard, which would otherwise delay the buffer past the fade.
maybeCrossfadeBytePreload(time, dur);
}).catch((err: unknown) => {
// Release the progress-tick guard so the UI doesn't freeze
// waiting for a target the engine will never reach.
+24
View File
@@ -22,6 +22,30 @@ export type WaveformCachePayload = {
* track is still the current one. Best-effort: any failure leaves the
* seekbar with the placeholder waveform.
*/
/**
* Fetch a track's cached waveform bins **without touching the player store**
* used by the silence-aware crossfade to inspect the *next* track's leading
* silence while a different track is still playing (writing `waveformBins` here
* would replace the current track's seekbar). Returns `null` on a cold miss /
* any failure so callers degrade to no-trim.
*/
export async function fetchWaveformBins(
trackId: string,
serverId?: string | null,
): Promise<number[] | null> {
if (!trackId) return null;
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
trackId,
serverId: serverId ?? getPlaybackIndexKey() ?? null,
});
const bins = row ? coerceWaveformBins(row.bins) : null;
return bins && bins.length > 0 ? bins : null;
} catch {
return null;
}
}
export async function refreshWaveformForTrack(trackId: string): Promise<void> {
if (!trackId) return;
const gen = getWaveformRefreshGen(trackId);
@@ -201,6 +201,78 @@
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
}
.mini-player__crossfade-popover {
/* Positioned via inline `style` (createPortal'd to document.body). */
position: fixed;
z-index: 99998;
min-width: 168px;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
}
.mini-player__crossfade-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.03em;
}
.mini-player__crossfade-value {
margin-left: auto;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.mini-player__crossfade-slider {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
}
.mini-player__crossfade-modes {
display: flex;
gap: 4px;
}
.mini-player__crossfade-mode {
flex: 1 1 0;
min-width: 0;
padding: 5px 6px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.mini-player__crossfade-mode:hover {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-subtle));
color: var(--text-primary);
}
.mini-player__crossfade-mode.active {
background: var(--accent);
border-color: var(--accent);
color: var(--bg-app);
}
.mini-player__volume-pct {
font-size: 10px;
color: var(--text-muted);
+33
View File
@@ -129,6 +129,39 @@
color: var(--text-muted);
}
.crossfade-popover-modes {
display: flex;
gap: 4px;
}
.crossfade-popover-mode {
flex: 1 1 0;
min-width: 0;
padding: 5px 6px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.crossfade-popover-mode:hover {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
color: var(--text-primary);
}
.crossfade-popover-mode.active {
background: var(--accent);
border-color: var(--accent);
color: var(--bg-app);
}
.queue-toolbar-sep {
width: 1px;
height: 18px;
@@ -74,6 +74,8 @@ export function initialSnapshot(): MiniSyncPayload {
volume: s.volume ?? 1,
gaplessEnabled: false,
crossfadeEnabled: false,
crossfadeSecs: 3,
crossfadeTrimSilence: false,
infiniteQueueEnabled: false,
isMobile: false,
};
@@ -81,6 +83,7 @@ export function initialSnapshot(): MiniSyncPayload {
return {
track: null, queue: [], queueIndex: 0, queueServerId: null, isPlaying: false,
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
crossfadeSecs: 3, crossfadeTrimSilence: false,
infiniteQueueEnabled: false, isMobile: false,
};
}
+20
View File
@@ -31,6 +31,8 @@ export interface MiniSyncPayload {
volume: number;
gaplessEnabled: boolean;
crossfadeEnabled: boolean;
crossfadeSecs: number;
crossfadeTrimSilence: boolean;
infiniteQueueEnabled: boolean;
isMobile: false;
}
@@ -83,6 +85,8 @@ function snapshot(): MiniSyncPayload {
volume: s.volume,
gaplessEnabled: !!a.gaplessEnabled,
crossfadeEnabled: !!a.crossfadeEnabled,
crossfadeSecs: a.crossfadeSecs,
crossfadeTrimSilence: !!a.crossfadeTrimSilence,
infiniteQueueEnabled: !!a.infiniteQueueEnabled,
isMobile: false,
};
@@ -114,6 +118,8 @@ export function initMiniPlayerBridgeOnMain(): () => void {
payload.volume,
payload.gaplessEnabled,
payload.crossfadeEnabled,
payload.crossfadeSecs,
payload.crossfadeTrimSilence,
payload.infiniteQueueEnabled,
queueIds,
].join('|');
@@ -139,6 +145,8 @@ export function initMiniPlayerBridgeOnMain(): () => void {
const unsubAuth = useAuthStore.subscribe((state, prev) => {
if (state.gaplessEnabled !== prev.gaplessEnabled
|| state.crossfadeEnabled !== prev.crossfadeEnabled
|| state.crossfadeSecs !== prev.crossfadeSecs
|| state.crossfadeTrimSilence !== prev.crossfadeTrimSilence
|| state.infiniteQueueEnabled !== prev.infiniteQueueEnabled) {
push();
}
@@ -253,6 +261,16 @@ export function initMiniPlayerBridgeOnMain(): () => void {
a.setCrossfadeEnabled(v);
});
const crossfadeSecsUnlisten = listen<{ value: number }>('mini:set-crossfade-secs', (e) => {
const v = e.payload?.value;
if (typeof v !== 'number' || !Number.isFinite(v)) return;
useAuthStore.getState().setCrossfadeSecs(Math.max(0.1, Math.min(10, v)));
});
const crossfadeTrimSilenceUnlisten = listen<{ value: boolean }>('mini:set-crossfade-trim-silence', (e) => {
useAuthStore.getState().setCrossfadeTrimSilence(!!e.payload?.value);
});
const infiniteQueueUnlisten = listen<{ value: boolean }>('mini:set-infinite-queue', (e) => {
const v = !!e.payload?.value;
useAuthStore.getState().setInfiniteQueueEnabled(v);
@@ -284,6 +302,8 @@ export function initMiniPlayerBridgeOnMain(): () => void {
redoQueueUnlisten.then(fn => fn()).catch(() => {});
gaplessUnlisten.then(fn => fn()).catch(() => {});
crossfadeUnlisten.then(fn => fn()).catch(() => {});
crossfadeSecsUnlisten.then(fn => fn()).catch(() => {});
crossfadeTrimSilenceUnlisten.then(fn => fn()).catch(() => {});
infiniteQueueUnlisten.then(fn => fn()).catch(() => {});
songInfoUnlisten.then(fn => fn()).catch(() => {});
};
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import { analyzeBoundary, computeWaveformSilence, planCrossfadeTransition } from './waveformSilence';
/** Build a 500-bin peak curve: `lead` silent bins, a loud middle, `trail` silent bins. */
function curve(lead: number, mid: number, trail: number, loud = 200, quiet = 4): number[] {
return [
...Array(lead).fill(quiet),
...Array(mid).fill(loud),
...Array(trail).fill(quiet),
];
}
/** Linear ramp of `n` values from `from`→`to` (inclusive), rounded to ints. */
function ramp(n: number, from: number, to: number): number[] {
return Array.from({ length: n }, (_, i) => Math.round(from + ((to - from) * i) / Math.max(1, n - 1)));
}
describe('computeWaveformSilence', () => {
it('returns no trim for null bins or invalid duration', () => {
expect(computeWaveformSilence(null, 200)).toEqual({
leadSilenceSec: 0, trailSilenceSec: 0, contentStartSec: 0, contentEndSec: 200,
});
expect(computeWaveformSilence([0, 200, 0], 0).contentEndSec).toBe(0);
expect(computeWaveformSilence([0, 200, 0], NaN).contentEndSec).toBe(0);
});
it('does not trim a loud-throughout track', () => {
const bins = Array(500).fill(180);
const r = computeWaveformSilence(bins, 240);
expect(r.leadSilenceSec).toBe(0);
expect(r.trailSilenceSec).toBe(0);
expect(r.contentStartSec).toBe(0);
expect(r.contentEndSec).toBe(240);
});
it('trims leading and trailing silence and maps bins to seconds', () => {
// 500 bins over 250 s → 0.5 s/bin. 20 lead silent bins = 10 s,
// capped to 5 s; 10 trail silent bins = 5 s (exactly at cap).
const bins = curve(20, 470, 10);
const r = computeWaveformSilence(bins, 250);
expect(r.leadSilenceSec).toBeCloseTo(5, 5); // 10 s raw, capped to 5
expect(r.trailSilenceSec).toBeCloseTo(5, 5);
expect(r.contentStartSec).toBeCloseTo(5, 5);
expect(r.contentEndSec).toBeCloseTo(245, 5);
});
it('maps small silences below the cap precisely', () => {
// 100 bins over 100 s → 1 s/bin. 3 lead silent, 2 trail silent.
const bins = curve(3, 95, 2);
const r = computeWaveformSilence(bins, 100);
expect(r.leadSilenceSec).toBeCloseTo(3, 5);
expect(r.trailSilenceSec).toBeCloseTo(2, 5);
expect(r.contentStartSec).toBeCloseTo(3, 5);
expect(r.contentEndSec).toBeCloseTo(98, 5);
});
it('respects a custom cap', () => {
const bins = curve(50, 400, 50); // 100 bins over 100 s → 50 s each side raw
const r = computeWaveformSilence(bins, 100, { maxTrimSec: 8 });
expect(r.leadSilenceSec).toBe(8);
expect(r.trailSilenceSec).toBe(8);
});
it('never trims a fully-silent curve to nothing', () => {
const bins = Array(500).fill(3);
const r = computeWaveformSilence(bins, 120);
expect(r.leadSilenceSec).toBe(0);
expect(r.trailSilenceSec).toBe(0);
expect(r.contentEndSec).toBe(120);
});
it('uses only the peak half of a dual-curve (1000-byte) payload', () => {
// Peak half: 5 lead silent + loud. Mean half differs (all loud) — must be ignored.
const peak = curve(5, 495, 0);
const mean = Array(500).fill(150);
const bins = [...peak, ...mean];
const r = computeWaveformSilence(bins, 500); // 500 bins → 1 s/bin
expect(r.leadSilenceSec).toBeCloseTo(5, 5);
expect(r.trailSilenceSec).toBe(0);
});
it('honours a custom cut threshold', () => {
// Intro bins at 30 are "loud" by default (cut 12) but silent at cut 40.
const bins = [...Array(4).fill(30), ...Array(96).fill(200)];
expect(computeWaveformSilence(bins, 100).leadSilenceSec).toBe(0);
expect(computeWaveformSilence(bins, 100, { cut: 40 }).leadSilenceSec).toBeCloseTo(4, 5);
});
});
describe('analyzeBoundary', () => {
it('reports ~0 rise/fade for a hard-cut, loud-throughout track', () => {
const r = analyzeBoundary(Array(100).fill(200), 100); // 1 s/bin
expect(r.introRiseSec).toBeCloseTo(0, 5);
expect(r.outroFadeSec).toBeCloseTo(0, 5);
});
it('measures a long trailing fade-out', () => {
// 80 loud bins + 20-bin decay to near-silence over 100 s (1 s/bin).
const bins = [...Array(80).fill(200), ...ramp(20, 200, 20)];
const r = analyzeBoundary(bins, 100);
expect(r.outroFadeSec).toBeGreaterThan(2);
expect(r.introRiseSec).toBeCloseTo(0, 5); // loud from the very start
});
it('measures a long quiet buildup intro', () => {
const bins = [...ramp(20, 20, 200), ...Array(80).fill(200)];
const r = analyzeBoundary(bins, 100);
expect(r.introRiseSec).toBeGreaterThan(2);
expect(r.outroFadeSec).toBeCloseTo(0, 5);
});
});
describe('planCrossfadeTransition', () => {
it('uses a standard ~2s blend for two hard-edged (loud) tracks', () => {
// No fade-out, no buildup, but both edges known → standard blend (not a cut).
const a = Array(100).fill(200);
const b = Array(100).fill(200);
const plan = planCrossfadeTransition(a, 100, b, 100);
expect(plan.overlapSec).toBeCloseTo(2, 5);
expect(plan.bStartSec).toBeCloseTo(0, 5);
// A has no natural fade → engine supplies one (== the overlap).
expect(plan.outgoingFadeSec).toBeCloseTo(2, 5);
});
it('uses a long, content-driven overlap when a fade-out meets a buildup', () => {
const a = [...Array(80).fill(200), ...ramp(20, 200, 20)]; // fade-out tail
const b = [...ramp(20, 20, 200), ...Array(80).fill(200)]; // quiet buildup head
const plan = planCrossfadeTransition(a, 100, b, 100);
// Spans the gentle regions — far longer than the hard-cut case, ≤ engine cap.
expect(plan.overlapSec).toBeGreaterThan(3);
expect(plan.overlapSec).toBeLessThanOrEqual(12);
});
it('extends the overlap to cover a long fade-out even against a hard start', () => {
const a = [...Array(80).fill(200), ...ramp(20, 200, 20)]; // long fade-out
const b = Array(100).fill(200); // hard, loud start
const plan = planCrossfadeTransition(a, 100, b, 100);
expect(plan.overlapSec).toBeGreaterThan(3);
});
it('lets A ride its own recorded fade-out (scenario A): no engine fade on A', () => {
const a = [...Array(80).fill(200), ...ramp(20, 200, 20)]; // long fade-out tail
const b = Array(100).fill(200); // hard, loud start (no buildup)
const plan = planCrossfadeTransition(a, 100, b, 100);
// A's own fade dominates → engine fade-out suppressed (0); B still fades in.
expect(plan.outgoingFadeSec).toBe(0);
expect(plan.overlapSec).toBeGreaterThan(3);
});
it('keeps an engine fade on A when A is a hard cut into a quiet buildup', () => {
const a = Array(100).fill(200); // hard end, no fade
const b = [...ramp(20, 20, 200), ...Array(80).fill(200)]; // long quiet buildup
const plan = planCrossfadeTransition(a, 100, b, 100);
// Overlap is driven by B's rise, A has no fade → engine must fade A.
expect(plan.overlapSec).toBeGreaterThan(3);
expect(plan.outgoingFadeSec).toBeCloseTo(plan.overlapSec, 5);
});
it('starts the incoming track past its leading silence', () => {
const a = Array(100).fill(200);
const b = [...Array(5).fill(4), ...Array(95).fill(200)]; // 5 s true silence, then loud
const plan = planCrossfadeTransition(a, 100, b, 100);
expect(plan.bStartSec).toBeGreaterThanOrEqual(5);
});
it('falls back to the minimum overlap when an envelope is missing', () => {
const plan = planCrossfadeTransition(null, 100, Array(100).fill(200), 100);
expect(plan.overlapSec).toBeCloseTo(0.5, 5);
expect(plan.bStartSec).toBeCloseTo(0, 5);
});
});
+259
View File
@@ -0,0 +1,259 @@
/**
* Derive leading / trailing "empty tail" offsets for a track straight from the
* cached waveform bins we already have no extra analysis pass, no new cache
* fields. The bins are the peak (+ mean) curve produced by the analysis decode
* and are **percentile-normalised** (silence floors near the bottom of the
* 0255 range, ~8 on the PCM path / 0 on the byte-envelope fallback), so we use
* a low absolute cut that catches both. Bin seconds uses the known track
* duration (`sec_per_bin = duration / bins`).
*
* Granularity is one bin (~0.5 s for a 4-min track at 500 bins) by design;
* this is for trimming dead air between crossfaded tracks, not sample-accurate
* editing. The per-side trim is capped so a long musical fade-out cannot be
* mistaken for silence and eaten whole.
*/
export interface WaveformSilenceBounds {
/** Seconds of leading silence to skip (0 when none / unknown). */
leadSilenceSec: number;
/** Seconds of trailing silence to skip (0 when none / unknown). */
trailSilenceSec: number;
/** Playback start offset past the leading silence. */
contentStartSec: number;
/** End of musical content (track end minus trailing silence). */
contentEndSec: number;
}
export interface WaveformSilenceOptions {
/** Bins at/below this 0…255 value count as silence. Default 12. */
cut?: number;
/** Hard cap on trim per side, in seconds. Default 5. */
maxTrimSec?: number;
}
const DEFAULT_SILENCE_CUT = 12;
const DEFAULT_MAX_TRIM_SEC = 5;
/**
* Dual-curve payload is peak ++ mean; use the peak half. Legacy single curve
* (length === peak length) is used as-is.
*/
function peakHalf(bins: number[]): number[] {
return bins.length >= 1000 ? bins.slice(0, Math.floor(bins.length / 2)) : bins;
}
/** High-percentile ("plateau") level of `peak[startBin..endBin)` above the cut. */
function plateauLevel(peak: number[], startBin: number, endBin: number, cut: number): number {
const loud: number[] = [];
for (let i = Math.max(0, startBin); i < Math.min(peak.length, endBin); i++) {
if (peak[i] > cut) loud.push(peak[i]);
}
if (loud.length === 0) return 0;
loud.sort((a, b) => a - b);
return loud[Math.min(loud.length - 1, Math.floor(loud.length * 0.75))];
}
/**
* Compute silence bounds for `bins` over a track of `durationSec`.
* Returns a no-trim result (`lead/trail = 0`, content = full track) whenever the
* input is missing, the duration is invalid, or the track is effectively silent.
*/
export function computeWaveformSilence(
bins: number[] | null | undefined,
durationSec: number,
opts: WaveformSilenceOptions = {},
): WaveformSilenceBounds {
const dur = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 0;
const none: WaveformSilenceBounds = {
leadSilenceSec: 0,
trailSilenceSec: 0,
contentStartSec: 0,
contentEndSec: dur,
};
if (!bins || dur <= 0) return none;
const peak = peakHalf(bins);
const n = peak.length;
if (n === 0) return none;
const cut = opts.cut ?? DEFAULT_SILENCE_CUT;
const maxTrimSec = opts.maxTrimSec ?? DEFAULT_MAX_TRIM_SEC;
// Guard against an all-quiet curve (silent / undecoded track): never trim a
// whole track to nothing.
let anyLoud = false;
for (let i = 0; i < n; i++) {
if (peak[i] > cut) { anyLoud = true; break; }
}
if (!anyLoud) return none;
let leadBins = 0;
while (leadBins < n && peak[leadBins] <= cut) leadBins++;
let trailBins = 0;
while (trailBins < n && peak[n - 1 - trailBins] <= cut) trailBins++;
const secPerBin = dur / n;
const leadSilenceSec = Math.min(leadBins * secPerBin, maxTrimSec);
const trailSilenceSec = Math.min(trailBins * secPerBin, maxTrimSec);
// Degenerate overlap (shouldn't happen given the all-quiet guard, but keep
// the contract: always leave a positive content window).
if (leadSilenceSec + trailSilenceSec >= dur) return none;
return {
leadSilenceSec,
trailSilenceSec,
contentStartSec: leadSilenceSec,
contentEndSec: dur - trailSilenceSec,
};
}
/** Boundary shape: silence bounds + the length of the gentle fade/rise regions. */
export interface BoundaryShape extends WaveformSilenceBounds {
/** Seconds of trailing decay (plateau → floor) just before `contentEndSec`. */
outroFadeSec: number;
/** Seconds of leading rise (floor → plateau) just after `contentStartSec`. */
introRiseSec: number;
}
/**
* Extend {@link computeWaveformSilence} with the *shape* of the track's edges:
* how long the music takes to rise to full level at the start (`introRiseSec`)
* and how long it decays at the end (`outroFadeSec`). A long musical fade-out or
* a quiet count-in produces a large value; a hard cut/abrupt start ~0. These
* drive the dynamic crossfade overlap (phase 2).
*/
export function analyzeBoundary(
bins: number[] | null | undefined,
durationSec: number,
opts: WaveformSilenceOptions = {},
): BoundaryShape {
const base = computeWaveformSilence(bins, durationSec, opts);
const dur = base.contentEndSec + base.trailSilenceSec; // == sanitised duration
if (!bins || !(dur > 0)) return { ...base, outroFadeSec: 0, introRiseSec: 0 };
const peak = peakHalf(bins);
const n = peak.length;
if (n === 0) return { ...base, outroFadeSec: 0, introRiseSec: 0 };
const cut = opts.cut ?? DEFAULT_SILENCE_CUT;
const secPerBin = dur / n;
const startBin = Math.min(n - 1, Math.max(0, Math.round(base.contentStartSec / secPerBin)));
const endBin = Math.min(n, Math.max(startBin + 1, Math.round(base.contentEndSec / secPerBin)));
const plateau = plateauLevel(peak, startBin, endBin, cut);
// "Full level" target for the rise/decay edges: halfway between cut and plateau.
const riseTarget = Math.max(cut + 1, plateau * 0.5);
let i = startBin;
while (i < endBin && peak[i] < riseTarget) i++;
const introRiseSec = (i - startBin) * secPerBin;
let j = endBin - 1;
while (j >= startBin && peak[j] < riseTarget) j--;
const outroFadeSec = Math.max(0, (endBin - 1 - j) * secPerBin);
return { ...base, outroFadeSec, introRiseSec };
}
/** Engine fade min/max — the override is clamped to the same range on the Rust side. */
const DYNAMIC_OVERLAP_MIN_SEC = 0.5;
const DYNAMIC_OVERLAP_HARD_CAP_SEC = 12;
/**
* Standard pleasant blend used when *both* edges are known but neither fades
* a hard, loudloud meeting (e.g. a track that ends loud but had protective
* trailing silence we trim away, butting up against a loud intro). A bare
* anti-click floor (~0.5 s) would sound like an abrupt cut, so we equal-power
* crossfade over this many seconds instead.
*/
export const STANDARD_BLEND_SEC = 2.0;
/**
* A's own outro fade must be at least this long (2 waveform bins of decay at
* 500 bins / 4-min track) before we trust it enough to suppress the engine
* fade-out and let the *recording* carry A down to silence (scenario A).
*/
const OWN_FADE_TRUST_SEC = 1.0;
/** A per-transition crossfade plan derived from both tracks' envelopes. */
export interface CrossfadeTransitionPlan {
/** Where the incoming track should begin playing (leading silence skipped). */
bStartSec: number;
/** Fade length both sides use, derived purely from the audio's fade/rise shape. */
overlapSec: number;
/**
* Engine fade-out length for the *outgoing* track A, decoupled from B's
* fade-in (`overlapSec`):
* `0` A already fades out in the recording, so don't double-fade it
* it rides at full engine gain while B rises underneath (scenario A);
* else fade A over this many seconds (== `overlapSec`; A has no natural
* fade, e.g. a hard cut, so the engine supplies one).
*/
outgoingFadeSec: number;
}
export interface CrossfadePlanOptions extends WaveformSilenceOptions {
/** Floor on the overlap (anti-click). Default 0.5 s (matches the engine clamp). */
minOverlapSec?: number;
/** Hard cap on the overlap. Default 12 s (engine max). */
maxOverlapSec?: number;
}
/**
* Pick a crossfade overlap + incoming start offset purely from what the two
* tracks *actually sound like* at the boundary the user's `crossfadeSecs` is
* **not** involved in this mode ("work by fact"):
*
* `overlap = clamp( max(outroFadeA, introRiseB), min, cap )`
*
* The overlap spans exactly the outgoing track's natural fade-out and/or the
* incoming track's quiet buildup, positioned to **end** at A's content end
* (`audioEventHandlers` advances at `contentEndA overlap`) with B starting past
* its own leading silence. So:
* a real fade-out / buildup a long blend that overlaps the *audible* tail
* and head (B rises under A instead of blaring in after A went quiet);
* two hard edges (no fade, no buildup) collapses to the `min` floor a
* quick blend, because there is simply nothing gradual to mix.
*
* Equal-power fades keep the summed loudness flat. Returns `overlapSec = min`
* (and `bStartSec = 0`) when an envelope is missing the caller then leaves the
* normal engine-driven crossfade in charge.
*/
export function planCrossfadeTransition(
aBins: number[] | null | undefined,
aDurationSec: number,
bBins: number[] | null | undefined,
bDurationSec: number,
opts: CrossfadePlanOptions = {},
): CrossfadeTransitionPlan {
const min = Math.max(0.1, opts.minOverlapSec ?? DYNAMIC_OVERLAP_MIN_SEC);
const cap = Math.min(DYNAMIC_OVERLAP_HARD_CAP_SEC, Math.max(min, opts.maxOverlapSec ?? DYNAMIC_OVERLAP_HARD_CAP_SEC));
const aShape = analyzeBoundary(aBins, aDurationSec, opts);
const bShape = analyzeBoundary(bBins, bDurationSec, opts);
const bStartSec = bShape.contentStartSec;
// Don't overlap more than ~90 % of the shorter content window (very short tracks).
const aContentLen = Math.max(0, aShape.contentEndSec - aShape.contentStartSec);
const bContentLen = Math.max(0, bShape.contentEndSec - bShape.contentStartSec);
const sustainable = Math.min(aContentLen || cap, bContentLen || cap) * 0.9;
const wanted = Math.max(aShape.outroFadeSec, bShape.introRiseSec);
// When we've analysed both edges and nothing fades (a hard, loud→loud meeting
// — typically a loud ending whose protective trailing silence we trim, into a
// loud intro), don't butt them together with a near-cut: blend over a standard
// ~2 s instead. A real fade-out/buildup keeps its (longer) content-driven span.
const haveBothEdges = !!aBins && !!bBins;
const target = haveBothEdges ? Math.max(wanted, STANDARD_BLEND_SEC) : (wanted || min);
const overlapSec = Math.max(min, Math.min(cap, sustainable, target));
// Scenario A: when A's own outro fade is the reason for the overlap (a real,
// trustworthy fade that's at least as long as B's intro rise), let the
// recording fade A out and skip the engine's fade-out — otherwise A would be
// attenuated twice (recording × engine) and vanish too soon under B.
const aRidesOwnFade =
aShape.outroFadeSec >= OWN_FADE_TRUST_SEC && aShape.outroFadeSec >= bShape.introRiseSec;
const outgoingFadeSec = aRidesOwnFade ? 0 : overlapSec;
return { overlapSec, bStartSec, outgoingFadeSec };
}