diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67c78816..e03b3220 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Crossfade / AutoDJ:** both sides resample to the chosen rate; the output stream reopens when needed and the outgoing track rebuilds from cache so mixed 88.2 ↔ 44.1 kHz transitions no longer tear mid-fade.
* **Gapless:** the next track chains at the blend rate and the current track realigns when the stream Hz differs, instead of falling back to a hard cut.
+### AutoDJ — configurable overlap cap
+
+**By [@cucadmuh](https://github.com/cucadmuh), PR [#1173](https://github.com/Psychotoxical/psysonic/pull/1173)**
+
+* **Settings → Audio → Track transitions → AutoDJ:** choose **Auto** (content-driven overlap, up to 12 s) or **Limit** (slider 2–30 s, default 15 s when enabled) to cap how long AutoDJ may overlap tracks.
+* The cap applies to end-of-track planning, JS auto-advance, smooth skip, and Orbit transition sync; the audio engine accepts dynamic overlap overrides up to 30 s.
+
### Theme store — version numbers and an animated/static filter
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1104](https://github.com/Psychotoxical/psysonic/pull/1104)**
diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs
index 79eb5d40..cbad8b0b 100644
--- a/src-tauri/crates/psysonic-audio/src/commands.rs
+++ b/src-tauri/crates/psysonic-audio/src/commands.rs
@@ -247,9 +247,12 @@ pub async fn audio_play(
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
// 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);
+ let crossfade_secs_val = if let Some(override_secs) = crossfade_secs_override {
+ override_secs.clamp(0.5, 30.0)
+ } 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
diff --git a/src/api/orbit.ts b/src/api/orbit.ts
index 128c8acc..bf5d018d 100644
--- a/src/api/orbit.ts
+++ b/src/api/orbit.ts
@@ -129,6 +129,9 @@ export interface OrbitTransitionSettings {
crossfadeTrimSilence: boolean;
autodjSmoothSkip: boolean;
gaplessEnabled: boolean;
+ /** Optional — absent on sessions hosted by builds before overlap-cap sync. */
+ autodjOverlapCapMode?: 'auto' | 'limit';
+ autodjOverlapCapSec?: number;
}
export interface OrbitSettings {
diff --git a/src/components/settings/audio/TrackTransitionsBlock.tsx b/src/components/settings/audio/TrackTransitionsBlock.tsx
index 06e5ceb7..cc1bc909 100644
--- a/src/components/settings/audio/TrackTransitionsBlock.tsx
+++ b/src/components/settings/audio/TrackTransitionsBlock.tsx
@@ -2,6 +2,10 @@ import React from 'react';
import type { TFunction } from 'i18next';
import { useAuthStore } from '../../../store/authStore';
import { useOrbitStore } from '../../../store/orbitStore';
+import {
+ AUTODJ_OVERLAP_CAP_MAX_SEC,
+ AUTODJ_OVERLAP_CAP_MIN_SEC,
+} from '../../../utils/playback/autodjOverlapCap';
import {
getTransitionMode,
setTransitionMode,
@@ -21,8 +25,7 @@ interface Props {
* transition-mode helper.
*
* Classic crossfade exposes the seconds slider; AutoDJ is content-driven and
- * has no duration to configure (just a short explainer + the smooth-skip
- * toggle).
+ * exposes an optional overlap cap (auto vs manual limit).
*
* Rendered as its own top-level "Track transitions" category in the Audio tab,
* so the boxed `SettingsGroup` is title-less — the `SettingsSubSection` header
@@ -89,6 +92,50 @@ export function TrackTransitionsBlock({ t }: Props) {
{t('settings.autoDjDesc')}
+
+
+ {t('settings.autodjOverlapCapTitle')}
+
+
+ {t('settings.autodjOverlapCapDesc')}
+
+
+ auth.setAutodjOverlapCapMode('auto')}
+ >
+ {t('settings.autodjOverlapCapAuto')}
+
+ auth.setAutodjOverlapCapMode('limit')}
+ >
+ {t('settings.autodjOverlapCapLimit')}
+
+
+ {auth.autodjOverlapCapMode === 'limit' && (
+
+ auth.setAutodjOverlapCapSec(parseInt(e.target.value, 10))}
+ style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
+ id="autodj-overlap-cap-slider"
+ />
+
+ {t('settings.autodjOverlapCapSecs', { n: auth.autodjOverlapCapSec })}
+
+
+ )}
+
set({ crossfadeSecs: v }),
setCrossfadeTrimSilence: (v) => set({ crossfadeTrimSilence: v }),
setAutodjSmoothSkip: (v) => set({ autodjSmoothSkip: v }),
+ setAutodjOverlapCapMode: (v) => set({ autodjOverlapCapMode: sanitizeAutodjOverlapCapMode(v) }),
+ setAutodjOverlapCapSec: (v) => set({ autodjOverlapCapSec: sanitizeAutodjOverlapCapSec(v) }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setHiResCrossfadeResampleHz: (v) => set({ hiResCrossfadeResampleHz: v }),
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index 8c131fce..0b3853fb 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -56,6 +56,8 @@ export const useAuthStore = create()(
crossfadeSecs: 3,
crossfadeTrimSilence: false,
autodjSmoothSkip: true,
+ autodjOverlapCapMode: 'auto',
+ autodjOverlapCapSec: 15,
gaplessEnabled: false,
trackPreviewsEnabled: true,
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts
index 903a5822..8152957f 100644
--- a/src/store/authStoreRehydrate.ts
+++ b/src/store/authStoreRehydrate.ts
@@ -1,5 +1,9 @@
import { IS_LINUX } from '../utils/platform';
import { sanitizeHiResCrossfadeResampleHz } from '../utils/audio/hiResCrossfadeResample';
+import {
+ sanitizeAutodjOverlapCapMode,
+ sanitizeAutodjOverlapCapSec,
+} from '../utils/playback/autodjOverlapCap';
import {
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
clampStoredLoudnessPreAnalysisAttenuationRefDb,
@@ -248,6 +252,12 @@ export function computeAuthStoreRehydration(state: AuthState): Partial void;
setCrossfadeTrimSilence: (v: boolean) => void;
setAutodjSmoothSkip: (v: boolean) => void;
+ setAutodjOverlapCapMode: (v: 'auto' | 'limit') => void;
+ setAutodjOverlapCapSec: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setTrackPreviewsEnabled: (v: boolean) => void;
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
diff --git a/src/store/crossfadePreload.ts b/src/store/crossfadePreload.ts
index cb4205c7..6e2ee6db 100644
--- a/src/store/crossfadePreload.ts
+++ b/src/store/crossfadePreload.ts
@@ -1,11 +1,12 @@
import { invoke } from '@tauri-apps/api/core';
+import { useAuthStore } from './authStore';
+import { autodjMaxOverlapCapSec } from '../utils/playback/autodjOverlapCap';
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 type { Track } from './playerStoreTypes';
-import { useAuthStore } from './authStore';
import {
hasPlannedCrossfade,
markPlannedCrossfade,
@@ -185,7 +186,8 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi
.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);
+ const maxOverlapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
+ const plan = planCrossfadeTransition(curBins, dur, nextBins, planDuration, { maxOverlapSec });
setCrossfadeTransition(planTrackId, plan);
})
.catch(() => {});
diff --git a/src/utils/orbit/transitions.test.ts b/src/utils/orbit/transitions.test.ts
index 7d043425..3870a309 100644
--- a/src/utils/orbit/transitions.test.ts
+++ b/src/utils/orbit/transitions.test.ts
@@ -10,6 +10,8 @@ const { store, setState } = vi.hoisted(() => {
crossfadeTrimSilence: false,
autodjSmoothSkip: true,
gaplessEnabled: false,
+ autodjOverlapCapMode: 'auto',
+ autodjOverlapCapSec: 15,
} as Record,
};
const setState = vi.fn((patch: Record) => {
@@ -36,6 +38,8 @@ const GUEST_OWN: OrbitTransitionSettings = {
crossfadeTrimSilence: false,
autodjSmoothSkip: true,
gaplessEnabled: false,
+ autodjOverlapCapMode: 'auto',
+ autodjOverlapCapSec: 15,
};
const HOST: OrbitTransitionSettings = {
crossfadeEnabled: true,
@@ -43,6 +47,8 @@ const HOST: OrbitTransitionSettings = {
crossfadeTrimSilence: true,
autodjSmoothSkip: false,
gaplessEnabled: false,
+ autodjOverlapCapMode: 'limit',
+ autodjOverlapCapSec: 20,
};
beforeEach(() => {
@@ -52,7 +58,7 @@ beforeEach(() => {
});
describe('read/apply transition settings', () => {
- it('reads the five transition fields from the store', () => {
+ it('reads the transition fields from the store', () => {
expect(readOrbitTransitionSettings()).toEqual(GUEST_OWN);
});
diff --git a/src/utils/orbit/transitions.ts b/src/utils/orbit/transitions.ts
index 75b6aadb..3dab092c 100644
--- a/src/utils/orbit/transitions.ts
+++ b/src/utils/orbit/transitions.ts
@@ -1,5 +1,9 @@
import { useAuthStore } from '../../store/authStore';
import type { OrbitTransitionSettings } from '../../api/orbit';
+import {
+ sanitizeAutodjOverlapCapMode,
+ sanitizeAutodjOverlapCapSec,
+} from '../playback/autodjOverlapCap';
/**
* Bridge between the local playback-transition settings (in `authStore`) and
@@ -29,13 +33,24 @@ export function readOrbitTransitionSettings(): OrbitTransitionSettings {
crossfadeTrimSilence: s.crossfadeTrimSilence,
autodjSmoothSkip: s.autodjSmoothSkip,
gaplessEnabled: s.gaplessEnabled,
+ autodjOverlapCapMode: s.autodjOverlapCapMode,
+ autodjOverlapCapSec: s.autodjOverlapCapSec,
};
}
/** True when the local settings already equal `t` (nothing to apply). */
function alreadyInSync(t: OrbitTransitionSettings): boolean {
- const s = useAuthStore.getState() as unknown as OrbitTransitionSettings;
- return FIELDS.every(f => s[f] === t[f]);
+ const s = useAuthStore.getState();
+ if (!FIELDS.every(f => s[f] === t[f])) return false;
+ if (t.autodjOverlapCapMode !== undefined
+ && s.autodjOverlapCapMode !== sanitizeAutodjOverlapCapMode(t.autodjOverlapCapMode)) {
+ return false;
+ }
+ if (t.autodjOverlapCapSec !== undefined
+ && s.autodjOverlapCapSec !== sanitizeAutodjOverlapCapSec(t.autodjOverlapCapSec)) {
+ return false;
+ }
+ return true;
}
/**
@@ -51,6 +66,12 @@ export function applyOrbitTransitionSettings(t: OrbitTransitionSettings): void {
crossfadeTrimSilence: t.crossfadeTrimSilence,
autodjSmoothSkip: t.autodjSmoothSkip,
gaplessEnabled: t.gaplessEnabled,
+ ...(t.autodjOverlapCapMode !== undefined
+ ? { autodjOverlapCapMode: sanitizeAutodjOverlapCapMode(t.autodjOverlapCapMode) }
+ : {}),
+ ...(t.autodjOverlapCapSec !== undefined
+ ? { autodjOverlapCapSec: sanitizeAutodjOverlapCapSec(t.autodjOverlapCapSec) }
+ : {}),
});
}
diff --git a/src/utils/playback/autodjAutoAdvance.ts b/src/utils/playback/autodjAutoAdvance.ts
index bcbe347b..839e0ec6 100644
--- a/src/utils/playback/autodjAutoAdvance.ts
+++ b/src/utils/playback/autodjAutoAdvance.ts
@@ -1,4 +1,4 @@
-import { STANDARD_BLEND_SEC } from '../waveform/waveformSilence';
+import { DYNAMIC_OVERLAP_HARD_CAP_SEC, STANDARD_BLEND_SEC } from '../waveform/waveformSilence';
/** Clamp engine crossfade setting to the same bounds used in progress handling. */
export function clampCrossfadeSecs(crossfadeSecs: number): number {
@@ -26,8 +26,9 @@ export function shouldJsDriveAutodjTransition(
export function computeAutodjJsOverlap(
contentOverlap: number,
aRidesOwnFade: boolean,
+ maxCapSec = DYNAMIC_OVERLAP_HARD_CAP_SEC,
): { overlapSec: number; outgoingFadeSec: number } {
- let overlap = Math.max(0.5, Math.min(12, contentOverlap || 0.5));
+ let overlap = Math.max(0.5, Math.min(maxCapSec, contentOverlap || 0.5));
if (!aRidesOwnFade && overlap < STANDARD_BLEND_SEC) overlap = STANDARD_BLEND_SEC;
const outgoingFadeSec = aRidesOwnFade ? 0 : overlap;
return { overlapSec: overlap, outgoingFadeSec };
diff --git a/src/utils/playback/autodjManualBlend.ts b/src/utils/playback/autodjManualBlend.ts
index 9a91525d..e8f3df65 100644
--- a/src/utils/playback/autodjManualBlend.ts
+++ b/src/utils/playback/autodjManualBlend.ts
@@ -1,4 +1,5 @@
import { useAuthStore } from '../../store/authStore';
+import { autodjMaxOverlapCapSec } from './autodjOverlapCap';
import {
analyzeBoundary,
planCrossfadeTransition,
@@ -58,15 +59,19 @@ export function computeAutodjManualBlendPlan(
const bDur = Number.isFinite(bDurationSec) && bDurationSec > 0 ? bDurationSec : 0;
if (aDur <= 0 || bDur <= 0) return null;
- const base = planCrossfadeTransition(aBins, aDur, bBins, bDur);
+ const base = planCrossfadeTransition(aBins, aDur, bBins, bDur, {
+ maxOverlapSec: autodjMaxOverlapCapSec(useAuthStore.getState()),
+ });
if (!(base.overlapSec > 0)) return null;
+ const maxCapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
+
const aShape = analyzeBoundary(aBins, aDur);
const bShape = analyzeBoundary(bBins, bDur);
const aRemaining = aShape.contentEndSec - Math.max(0, skipFromTimeSec);
if (aRemaining < MIN_A_REMAINING_SEC) return null;
- let overlap = Math.max(0.5, Math.min(12, base.overlapSec, aRemaining));
+ let overlap = Math.max(0.5, Math.min(maxCapSec, base.overlapSec, aRemaining));
const bPlayable = Math.max(0, bShape.contentEndSec - base.bStartSec);
if (bPlayable > 0) overlap = Math.min(overlap, bPlayable * 0.9);
diff --git a/src/utils/playback/autodjOverlapCap.test.ts b/src/utils/playback/autodjOverlapCap.test.ts
new file mode 100644
index 00000000..4f46c15c
--- /dev/null
+++ b/src/utils/playback/autodjOverlapCap.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import {
+ autodjMaxOverlapCapSec,
+ DEFAULT_AUTODJ_OVERLAP_CAP_SEC,
+ sanitizeAutodjOverlapCapSec,
+} from './autodjOverlapCap';
+
+describe('autodjOverlapCap', () => {
+ it('sanitizes cap seconds to 2–30', () => {
+ expect(sanitizeAutodjOverlapCapSec(1)).toBe(2);
+ expect(sanitizeAutodjOverlapCapSec(40)).toBe(30);
+ expect(sanitizeAutodjOverlapCapSec(DEFAULT_AUTODJ_OVERLAP_CAP_SEC)).toBe(15);
+ });
+
+ it('uses 12 s in auto mode', () => {
+ expect(autodjMaxOverlapCapSec({ autodjOverlapCapMode: 'auto', autodjOverlapCapSec: 20 })).toBe(12);
+ });
+
+ it('uses configured cap in limit mode', () => {
+ expect(autodjMaxOverlapCapSec({ autodjOverlapCapMode: 'limit', autodjOverlapCapSec: 20 })).toBe(20);
+ });
+});
diff --git a/src/utils/playback/autodjOverlapCap.ts b/src/utils/playback/autodjOverlapCap.ts
new file mode 100644
index 00000000..8b2ef965
--- /dev/null
+++ b/src/utils/playback/autodjOverlapCap.ts
@@ -0,0 +1,28 @@
+import type { AuthState } from '../../store/authStoreTypes';
+import { DYNAMIC_OVERLAP_HARD_CAP_SEC } from '../waveform/waveformSilence';
+
+export type AutodjOverlapCapMode = 'auto' | 'limit';
+
+export const AUTODJ_OVERLAP_CAP_MIN_SEC = 2;
+export const AUTODJ_OVERLAP_CAP_MAX_SEC = 30;
+export const DEFAULT_AUTODJ_OVERLAP_CAP_SEC = 15;
+
+export function sanitizeAutodjOverlapCapSec(value: unknown): number {
+ const n = typeof value === 'number' ? value : Number(value);
+ if (!Number.isFinite(n)) return DEFAULT_AUTODJ_OVERLAP_CAP_SEC;
+ return Math.min(AUTODJ_OVERLAP_CAP_MAX_SEC, Math.max(AUTODJ_OVERLAP_CAP_MIN_SEC, n));
+}
+
+export function sanitizeAutodjOverlapCapMode(value: unknown): AutodjOverlapCapMode {
+ return value === 'limit' ? 'limit' : 'auto';
+}
+
+/** Upper bound (seconds) for content-driven AutoDJ overlap calculations. */
+export function autodjMaxOverlapCapSec(
+ auth: Pick,
+): number {
+ if (auth.autodjOverlapCapMode === 'auto') {
+ return DYNAMIC_OVERLAP_HARD_CAP_SEC;
+ }
+ return sanitizeAutodjOverlapCapSec(auth.autodjOverlapCapSec);
+}
diff --git a/src/utils/waveform/waveformSilence.ts b/src/utils/waveform/waveformSilence.ts
index 40d7bb0b..e10eea0f 100644
--- a/src/utils/waveform/waveformSilence.ts
+++ b/src/utils/waveform/waveformSilence.ts
@@ -157,7 +157,8 @@ export function analyzeBoundary(
/** 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;
+export const DYNAMIC_OVERLAP_HARD_CAP_SEC = 12;
+export const DYNAMIC_OVERLAP_ABSOLUTE_MAX_SEC = 30;
/**
* Standard pleasant blend used when *both* edges are known but neither fades —
@@ -227,7 +228,8 @@ export function planCrossfadeTransition(
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 capUpper = opts.maxOverlapSec ?? DYNAMIC_OVERLAP_HARD_CAP_SEC;
+ const cap = Math.max(min, Math.min(capUpper, DYNAMIC_OVERLAP_ABSOLUTE_MAX_SEC));
const aShape = analyzeBoundary(aBins, aDurationSec, opts);
const bShape = analyzeBoundary(bBins, bDurationSec, opts);