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.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);