mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(autodj): configurable overlap cap (auto or 2–30 s) (#1173)
This commit is contained in:
@@ -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)**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
<div style={{ paddingLeft: '1rem', fontSize: 12, color: 'var(--text-muted)', marginTop: '0.7rem' }}>
|
||||
{t('settings.autoDjDesc')}
|
||||
</div>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.9rem' }}>
|
||||
<p className="settings-row-label" style={{ marginBottom: '0.45rem' }}>
|
||||
{t('settings.autodjOverlapCapTitle')}
|
||||
</p>
|
||||
<p className="settings-row-desc" style={{ marginBottom: '0.6rem' }}>
|
||||
{t('settings.autodjOverlapCapDesc')}
|
||||
</p>
|
||||
<div className="settings-segmented" style={{ marginBottom: auth.autodjOverlapCapMode === 'limit' ? '0.65rem' : 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.autodjOverlapCapMode === 'auto' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
disabled={hostControlled}
|
||||
onClick={() => auth.setAutodjOverlapCapMode('auto')}
|
||||
>
|
||||
{t('settings.autodjOverlapCapAuto')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.autodjOverlapCapMode === 'limit' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
disabled={hostControlled}
|
||||
onClick={() => auth.setAutodjOverlapCapMode('limit')}
|
||||
>
|
||||
{t('settings.autodjOverlapCapLimit')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.autodjOverlapCapMode === 'limit' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={AUTODJ_OVERLAP_CAP_MIN_SEC}
|
||||
max={AUTODJ_OVERLAP_CAP_MAX_SEC}
|
||||
step={1}
|
||||
value={auth.autodjOverlapCapSec}
|
||||
disabled={hostControlled}
|
||||
onChange={e => auth.setAutodjOverlapCapSec(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
id="autodj-overlap-cap-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 48 }}>
|
||||
{t('settings.autodjOverlapCapSecs', { n: auth.autodjOverlapCapSec })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.7rem' }}>
|
||||
<SettingsToggle
|
||||
label={t('settings.autodjSmoothSkip')}
|
||||
|
||||
@@ -175,6 +175,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Custom HTTP headers for reverse-proxy gates — per-server header editor, TS/Rust registry sync, full playback/sync/cover path coverage, log redaction (PR #1156)',
|
||||
'CI: ESLint strict workflow and path-aware ci-ok merge gate (PR #1170)',
|
||||
'Hi-Res transition blend rate — configurable 44.1/88.2/96 kHz resampling for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate (PR #1171)',
|
||||
'AutoDJ overlap cap — Auto (12 s content cap) or Limit (2–30 s slider, default 15 s) in track-transition settings; Orbit sync + engine override up to 30 s (PR #1173)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -608,6 +608,11 @@ export const settings = {
|
||||
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.',
|
||||
autodjSmoothSkip: 'Weicher Skip',
|
||||
autodjSmoothSkipDesc: 'Beim Wechsel zum nächsten oder vorherigen Titel während der Wiedergabe blendet der aktuelle Song kurz aus, statt abrupt abgeschnitten zu werden.',
|
||||
autodjOverlapCapTitle: 'Überlappungslimit',
|
||||
autodjOverlapCapDesc: 'Begrenzt, wie lange AutoDJ Titel überlappen darf. Auto folgt der Inhaltsanalyse (bis 12 s). Limit setzt Ihr eigenes Maximum.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Limit',
|
||||
autodjOverlapCapSecs: '{{n}} s',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
transitionsTitle: 'Übergänge zwischen Tracks',
|
||||
transitionsDesc: 'Wie aufeinanderfolgende Tracks ineinander übergehen. Es kann immer nur ein Modus aktiv sein.',
|
||||
|
||||
@@ -675,6 +675,11 @@ export const settings = {
|
||||
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.',
|
||||
autodjSmoothSkip: 'Smooth skip',
|
||||
autodjSmoothSkipDesc: 'When you skip to the next or previous track while playing, blend into it with the same AutoDJ rules (overlap length, intro trim) from your current position instead of an abrupt cut. Works best when the next track is already buffered.',
|
||||
autodjOverlapCapTitle: 'Overlap limit',
|
||||
autodjOverlapCapDesc: 'Caps how long AutoDJ may overlap tracks. Auto follows content analysis (up to 12 s). Limit sets your own maximum.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Limit',
|
||||
autodjOverlapCapSecs: '{{n}} s',
|
||||
gapless: 'Gapless Playback',
|
||||
transitionsTitle: 'Track transitions',
|
||||
transitionsDesc: 'How consecutive tracks blend together. Only one mode can be active at a time.',
|
||||
|
||||
@@ -607,6 +607,11 @@ export const settings = {
|
||||
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.',
|
||||
autodjSmoothSkip: 'Salto suave',
|
||||
autodjSmoothSkipDesc: 'Al pasar a la siguiente o anterior pista mientras suena una canción, esta se desvanece brevemente en lugar de cortarse de golpe.',
|
||||
autodjOverlapCapTitle: 'Límite de solapamiento',
|
||||
autodjOverlapCapDesc: 'Limita cuánto pueden solaparse las pistas con AutoDJ. Auto sigue el análisis del contenido (hasta 12 s). Límite fija su propio máximo.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Límite',
|
||||
autodjOverlapCapSecs: '{{n}} s',
|
||||
gapless: 'Reproducción Gapless',
|
||||
transitionsTitle: 'Transiciones entre pistas',
|
||||
transitionsDesc: 'Cómo se enlazan las pistas consecutivas. Solo un modo puede estar activo a la vez.',
|
||||
|
||||
@@ -595,6 +595,11 @@ export const settings = {
|
||||
autoDjDesc: 'Sans durée fixe : AutoDJ suit l’audio 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.',
|
||||
autodjSmoothSkip: 'Passage en fondu',
|
||||
autodjSmoothSkipDesc: 'Lors d’un passage à la piste suivante ou précédente pendant la lecture, la piste en cours s’estompe brièvement au lieu d’être coupée net.',
|
||||
autodjOverlapCapTitle: 'Limite de chevauchement',
|
||||
autodjOverlapCapDesc: 'Limite la durée de chevauchement AutoDJ. Auto suit l’analyse du contenu (jusqu’à 12 s). Limite fixe votre propre maximum.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Limite',
|
||||
autodjOverlapCapSecs: '{{n}} s',
|
||||
gapless: 'Lecture sans blanc',
|
||||
transitionsTitle: 'Transitions entre pistes',
|
||||
transitionsDesc: 'Comment les pistes consécutives s’enchaînent. Un seul mode peut être actif à la fois.',
|
||||
|
||||
@@ -675,6 +675,11 @@ export const settings = {
|
||||
autoDjDesc: 'Nincs fix időtartam — az AutoDJ a tényleges hangot követi, valós fade-outokat és intrókat keverve egy beállított másodpercszám helyett. A megbízható eredményekért engedélyezd az aktív lejátszási gyorsítótárat.',
|
||||
autodjSmoothSkip: 'Sima átugrás',
|
||||
autodjSmoothSkipDesc: 'Amikor lejátszás közben a következő vagy előző számra ugrasz, ugyanazokkal az AutoDJ-szabályokkal (átfedéshossz, intró-vágás) tűnik át bele a jelenlegi pozíciódból, éles vágás helyett. Akkor működik a legjobban, ha a következő szám már pufferelt.',
|
||||
autodjOverlapCapTitle: 'Átfedési limit',
|
||||
autodjOverlapCapDesc: 'Korlátozza, meddig fedhetik egymást a számok AutoDJ mellett. Auto a tartalomelemzést követi (max. 12 s). Limit saját felső határt ad.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Limit',
|
||||
autodjOverlapCapSecs: '{{n}} mp',
|
||||
gapless: 'Szünetmentes lejátszás',
|
||||
transitionsTitle: 'Számátmenetek',
|
||||
transitionsDesc: 'Hogyan tűnik át egymásba az egymást követő számok. Egyszerre csak egy mód lehet aktív.',
|
||||
|
||||
@@ -669,6 +669,11 @@ export const settings = {
|
||||
autoDjDesc: '固定時間ではありません。AutoDJ は実際の音声に追従し、秒数固定ではなく本物のフェードアウトとイントロをブレンドします。安定した結果にはホット再生キャッシュを有効にしてください。',
|
||||
autodjSmoothSkip: 'スムーズスキップ',
|
||||
autodjSmoothSkipDesc: '再生中に次または前のトラックへスキップすると、突然切り替えず、現在位置から同じ AutoDJ ルール (重なり長、イントロトリム) でブレンドします。次のトラックがすでにバッファ済みだと最も安定します。',
|
||||
autodjOverlapCapTitle: '重なり上限',
|
||||
autodjOverlapCapDesc: 'AutoDJ の重なり時間の上限です。自動はコンテンツ分析に従います (最大 12 秒)。上限は自分で設定します。',
|
||||
autodjOverlapCapAuto: '自動',
|
||||
autodjOverlapCapLimit: '上限',
|
||||
autodjOverlapCapSecs: '{{n}} 秒',
|
||||
gapless: 'ギャップレス再生',
|
||||
transitionsTitle: 'トラック遷移',
|
||||
transitionsDesc: '連続するトラックのつなぎ方です。一度に有効にできるモードは 1 つだけです。',
|
||||
|
||||
@@ -594,6 +594,11 @@ export const settings = {
|
||||
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.',
|
||||
autodjSmoothSkip: 'Myk hopp',
|
||||
autodjSmoothSkipDesc: 'Når du hopper til neste eller forrige spor under avspilling, toner det pågående sporet kort ut i stedet for å kuttes brått.',
|
||||
autodjOverlapCapTitle: 'Overlappgrense',
|
||||
autodjOverlapCapDesc: 'Begrenser hvor lenge AutoDJ kan overlappe spor. Auto følger innholdsanalyse (opptil 12 s). Grense setter ditt eget maksimum.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Grense',
|
||||
autodjOverlapCapSecs: '{{n}} s',
|
||||
gapless: 'Gapless avspilling',
|
||||
transitionsTitle: 'Overganger mellom spor',
|
||||
transitionsDesc: 'Hvordan etterfølgende spor går over i hverandre. Bare én modus kan være aktiv om gangen.',
|
||||
|
||||
@@ -595,6 +595,11 @@ export const settings = {
|
||||
autoDjDesc: 'Geen vaste duur — AutoDJ volgt de werkelijke audio en overlapt echte fades en intro’s in plaats van een vast aantal seconden. Schakel voor betrouwbare resultaten de Warme afspeelcache in.',
|
||||
autodjSmoothSkip: 'Zacht overslaan',
|
||||
autodjSmoothSkipDesc: 'Bij overslaan naar het volgende of vorige nummer tijdens het afspelen fadet het huidige nummer kort uit in plaats van abrupt te stoppen.',
|
||||
autodjOverlapCapTitle: 'Overlaplimiet',
|
||||
autodjOverlapCapDesc: 'Beperkt hoe lang AutoDJ nummers mag overlappen. Auto volgt inhoudsanalyse (tot 12 s). Limiet stelt uw eigen maximum in.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Limiet',
|
||||
autodjOverlapCapSecs: '{{n}} s',
|
||||
gapless: 'Naadloos afspelen',
|
||||
transitionsTitle: 'Overgangen tussen nummers',
|
||||
transitionsDesc: 'Hoe opeenvolgende nummers in elkaar overvloeien. Er kan maar één modus tegelijk actief zijn.',
|
||||
|
||||
@@ -610,6 +610,11 @@ export const settings = {
|
||||
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.',
|
||||
autodjSmoothSkip: 'Salt lin',
|
||||
autodjSmoothSkipDesc: 'La trecerea la piesa următoare sau anterioară în timpul redării, piesa curentă se estompează scurt în loc să fie tăiată brusc.',
|
||||
autodjOverlapCapTitle: 'Limită suprapunere',
|
||||
autodjOverlapCapDesc: 'Limitează cât pot suprapune piesele cu AutoDJ. Auto urmează analiza conținutului (până la 12 s). Limită setează maximul dvs.',
|
||||
autodjOverlapCapAuto: 'Auto',
|
||||
autodjOverlapCapLimit: 'Limită',
|
||||
autodjOverlapCapSecs: '{{n}} s',
|
||||
gapless: 'Playback Gapless',
|
||||
transitionsTitle: 'Tranziții între piese',
|
||||
transitionsDesc: 'Cum se îmbină piesele consecutive. Doar un mod poate fi activ la un moment dat.',
|
||||
|
||||
@@ -695,6 +695,11 @@ export const settings = {
|
||||
autoDjDesc: 'Без фиксированной длительности — AutoDJ подстраивается под само звучание, накладываясь на реальные затухания и вступления, а не на заданное число секунд. Для надёжной работы включите «Горячий кэш воспроизведения».',
|
||||
autodjSmoothSkip: 'Плавный пропуск',
|
||||
autodjSmoothSkipDesc: 'При переходе на следующий или предыдущий трек во время воспроизведения склеивает его по тем же правилам AutoDJ (длина наложения, обрезка вступления) с текущей позиции, а не резким обрывом. Надёжнее, когда следующий трек уже в буфере.',
|
||||
autodjOverlapCapTitle: 'Лимит наложения',
|
||||
autodjOverlapCapDesc: 'Ограничивает максимальную длительность наложения AutoDJ. Авто — по анализу контента (до 12 с). Лимит — ваш верхний предел.',
|
||||
autodjOverlapCapAuto: 'Авто',
|
||||
autodjOverlapCapLimit: 'Лимит',
|
||||
autodjOverlapCapSecs: '{{n}} с',
|
||||
gapless: 'Без пауз между треками',
|
||||
transitionsTitle: 'Переходы между треками',
|
||||
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
|
||||
|
||||
@@ -594,6 +594,11 @@ export const settings = {
|
||||
autoDjDesc: '没有固定时长——AutoDJ 跟随实际音频,叠加在真实的淡入淡出上,而不是固定的秒数。为获得稳定效果,请启用「热播放缓存」。',
|
||||
autodjSmoothSkip: '平滑跳过',
|
||||
autodjSmoothSkipDesc: '播放中跳到下一首或上一首时,当前歌曲会短暂淡出,而不是突然切断。',
|
||||
autodjOverlapCapTitle: '重叠上限',
|
||||
autodjOverlapCapDesc: '限制 AutoDJ 轨道重叠的最长时间。自动模式按内容分析(最多 12 秒)。上限由您自行设定。',
|
||||
autodjOverlapCapAuto: '自动',
|
||||
autodjOverlapCapLimit: '上限',
|
||||
autodjOverlapCapSecs: '{{n}} 秒',
|
||||
gapless: '无缝播放',
|
||||
transitionsTitle: '曲目间过渡',
|
||||
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
} from './seekTargetState';
|
||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||
import { analyzeBoundary, computeWaveformSilence } from '../utils/waveform/waveformSilence';
|
||||
import { autodjMaxOverlapCapSec } from '../utils/playback/autodjOverlapCap';
|
||||
import {
|
||||
autodjJsTriggerAtSec,
|
||||
clampCrossfadeSecs,
|
||||
@@ -307,7 +308,8 @@ export function handleAudioProgress(
|
||||
}
|
||||
if (shouldJsDriveAutodjTransition(curTrailSilenceSec, contentOverlap, cf, aRidesOwnFade)) {
|
||||
autodjSuppressWant = true;
|
||||
const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade);
|
||||
const maxCapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
|
||||
const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade, maxCapSec);
|
||||
const triggerAt = autodjJsTriggerAtSec(dur, curTrailSilenceSec, overlapSec);
|
||||
const gen = getPlayGeneration();
|
||||
// Readiness gate: only advance when B's audio is actually available (RAM
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { clampStoredLoudnessPreAnalysisAttenuationRefDb } from '../utils/audio/loudnessPreAnalysisSlider';
|
||||
import {
|
||||
sanitizeAutodjOverlapCapMode,
|
||||
sanitizeAutodjOverlapCapSec,
|
||||
} from '../utils/playback/autodjOverlapCap';
|
||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from './authStoreDefaults';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
@@ -29,6 +33,8 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
| 'setCrossfadeSecs'
|
||||
| 'setCrossfadeTrimSilence'
|
||||
| 'setAutodjSmoothSkip'
|
||||
| 'setAutodjOverlapCapMode'
|
||||
| 'setAutodjOverlapCapSec'
|
||||
| 'setGaplessEnabled'
|
||||
| 'setEnableHiRes'
|
||||
| 'setHiResCrossfadeResampleHz'
|
||||
@@ -72,6 +78,8 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
setCrossfadeSecs: (v) => 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 }),
|
||||
|
||||
@@ -56,6 +56,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeSecs: 3,
|
||||
crossfadeTrimSilence: false,
|
||||
autodjSmoothSkip: true,
|
||||
autodjOverlapCapMode: 'auto',
|
||||
autodjOverlapCapSec: 15,
|
||||
gaplessEnabled: false,
|
||||
trackPreviewsEnabled: true,
|
||||
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
|
||||
|
||||
@@ -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<AuthState
|
||||
hiResCrossfadeResampleHz: sanitizeHiResCrossfadeResampleHz(
|
||||
(state as { hiResCrossfadeResampleHz?: unknown }).hiResCrossfadeResampleHz,
|
||||
),
|
||||
autodjOverlapCapMode: sanitizeAutodjOverlapCapMode(
|
||||
(state as { autodjOverlapCapMode?: unknown }).autodjOverlapCapMode,
|
||||
),
|
||||
autodjOverlapCapSec: sanitizeAutodjOverlapCapSec(
|
||||
(state as { autodjOverlapCapSec?: unknown }).autodjOverlapCapSec,
|
||||
),
|
||||
...lyricsSourcesMigrated,
|
||||
...youLyPlusMigrated,
|
||||
...wheelSmoothOneTime,
|
||||
|
||||
@@ -152,6 +152,12 @@ export interface AuthState {
|
||||
* playing (avoids an abrupt cut). Default on for new installs.
|
||||
*/
|
||||
autodjSmoothSkip: boolean;
|
||||
/**
|
||||
* AutoDJ: upper bound for content-driven overlap. `auto` keeps the built-in
|
||||
* 12 s cap; `limit` uses `autodjOverlapCapSec` (2–30 s, default 15).
|
||||
*/
|
||||
autodjOverlapCapMode: 'auto' | 'limit';
|
||||
autodjOverlapCapSec: number;
|
||||
gaplessEnabled: boolean;
|
||||
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
|
||||
trackPreviewsEnabled: boolean;
|
||||
@@ -375,6 +381,8 @@ export interface AuthState {
|
||||
setCrossfadeSecs: (v: number) => 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;
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -10,6 +10,8 @@ const { store, setState } = vi.hoisted(() => {
|
||||
crossfadeTrimSilence: false,
|
||||
autodjSmoothSkip: true,
|
||||
gaplessEnabled: false,
|
||||
autodjOverlapCapMode: 'auto',
|
||||
autodjOverlapCapSec: 15,
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
const setState = vi.fn((patch: Record<string, unknown>) => {
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AuthState, 'autodjOverlapCapMode' | 'autodjOverlapCapSec'>,
|
||||
): number {
|
||||
if (auth.autodjOverlapCapMode === 'auto') {
|
||||
return DYNAMIC_OVERLAP_HARD_CAP_SEC;
|
||||
}
|
||||
return sanitizeAutodjOverlapCapSec(auth.autodjOverlapCapSec);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user