From 21a0704ac1f0991d99de5a3e05a99213d9703691 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 3 Sep 2021 11:10:48 -0700 Subject: [PATCH] split gapless from fade, add additional fade types - add logic for gapless playback in redux store --- src/components/player/Player.tsx | 116 +++++++++++++++++++++++++--- src/components/player/PlayerBar.tsx | 24 ++++-- src/components/settings/Config.tsx | 66 ++++++++++++---- src/redux/playQueueSlice.ts | 15 ++++ 4 files changed, 192 insertions(+), 29 deletions(-) diff --git a/src/components/player/Player.tsx b/src/components/player/Player.tsx index da25e00..fed8d8d 100644 --- a/src/components/player/Player.tsx +++ b/src/components/player/Player.tsx @@ -26,6 +26,45 @@ import { setCurrentSeek } from '../../redux/playerSlice'; import cacheSong from '../shared/cacheSong'; import { getSongCachePath, isCached } from '../../shared/utils'; +const gaplessListenHandler = ( + currentPlayerRef: any, + nextPlayerRef: any, + playQueue: any, + currentPlayer: number, + dispatch: any, + pollingInterval: number +) => { + const seek = + Math.round(currentPlayerRef.current.audioEl.current.currentTime * 100) / + 100; + const duration = + Math.round(currentPlayerRef.current.audioEl.current.duration * 100) / 100; + + const seekable = + currentPlayerRef.current.audioEl.current.seekable.length >= 1 + ? currentPlayerRef.current.audioEl.current.seekable.end( + currentPlayerRef.current.audioEl.current.seekable.length - 1 + ) + : 0; + + if (playQueue.currentPlayer === currentPlayer) { + dispatch( + setCurrentSeek({ + seek, + seekable, + }) + ); + } + + // Add a bit of leeway for the second track to start since the + // seek value doesn't always reach the duration + const durationPadding = + pollingInterval <= 10 ? 0.13 : pollingInterval <= 20 ? 0.14 : 0.15; + if (seek + durationPadding >= duration) { + nextPlayerRef.current.audioEl.current.play(); + } +}; + const listenHandler = ( currentPlayerRef: any, nextPlayerRef: any, @@ -65,6 +104,7 @@ const listenHandler = ( let currentPlayerVolumeCalculation; let nextPlayerVolumeCalculation; let percentageOfFadeLeft; + let n; switch (fadeType) { case 'equalPower': // https://dsp.stackexchange.com/a/14755 @@ -88,6 +128,30 @@ const listenHandler = ( nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * playQueue.volume; break; + case fadeType.match(/constantPower.*/)?.input: + // https://math.stackexchange.com/a/26159 + n = + fadeType === 'constantPower' + ? 0 + : fadeType === 'constantPowerSlowFade' + ? 1 + : fadeType === 'constantPowerSlowCut' + ? 3 + : 10; + + percentageOfFadeLeft = timeLeft / fadeDuration; + currentPlayerVolumeCalculation = + Math.cos( + (Math.PI / 4) * + ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) - 1) + ) * playQueue.volume; + nextPlayerVolumeCalculation = + Math.cos( + (Math.PI / 4) * + ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1) + ) * playQueue.volume; + break; + default: currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * playQueue.volume; @@ -164,13 +228,14 @@ const listenHandler = ( } }; -const Player = ({ currentEntryList, debug, children }: any, ref: any) => { +const Player = ({ currentEntryList, children }: any, ref: any) => { const player1Ref = useRef(); const player2Ref = useRef(); const dispatch = useAppDispatch(); const playQueue = useAppSelector((state) => state.playQueue); const player = useAppSelector((state) => state.player); const cacheSongs = settings.getSync('cacheSongs'); + const [debug, setDebug] = useState(playQueue.showDebugWindow); const [title, setTitle] = useState(''); const [cachePath] = useState(path.join(getSongCachePath(), '/')); const [fadeDuration, setFadeDuration] = useState(playQueue.fadeDuration); @@ -228,6 +293,7 @@ const Player = ({ currentEntryList, debug, children }: any, ref: any) => { useEffect(() => { // Update playback settings when changed in redux store + setDebug(playQueue.showDebugWindow); setFadeDuration(playQueue.fadeDuration); setFadeType(playQueue.fadeType); setVolumeFade(playQueue.volumeFade); @@ -236,6 +302,7 @@ const Player = ({ currentEntryList, debug, children }: any, ref: any) => { playQueue.fadeDuration, playQueue.fadeType, playQueue.pollingInterval, + playQueue.showDebugWindow, playQueue.volumeFade, ]); @@ -302,9 +369,12 @@ const Player = ({ currentEntryList, debug, children }: any, ref: any) => { ) { dispatch(setCurrentPlayer(2)); dispatch(incrementPlayerIndex(1)); - dispatch(setPlayerVolume({ player: 1, volume: 0 })); - dispatch(setPlayerVolume({ player: 2, volume: playQueue.volume })); - dispatch(setIsFading(false)); + if (fadeDuration !== 0) { + dispatch(setPlayerVolume({ player: 1, volume: 0 })); + dispatch(setPlayerVolume({ player: 2, volume: playQueue.volume })); + dispatch(setIsFading(false)); + } + dispatch(setAutoIncremented(false)); } } @@ -343,9 +413,11 @@ const Player = ({ currentEntryList, debug, children }: any, ref: any) => { ) { dispatch(setCurrentPlayer(1)); dispatch(incrementPlayerIndex(2)); - dispatch(setPlayerVolume({ player: 1, volume: playQueue.volume })); - dispatch(setPlayerVolume({ player: 2, volume: 0 })); - dispatch(setIsFading(false)); + if (fadeDuration !== 0) { + dispatch(setPlayerVolume({ player: 1, volume: playQueue.volume })); + dispatch(setPlayerVolume({ player: 2, volume: 0 })); + dispatch(setIsFading(false)); + } dispatch(setAutoIncremented(false)); } } @@ -374,6 +446,28 @@ const Player = ({ currentEntryList, debug, children }: any, ref: any) => { setTitle(`${playStatus} ${songTitle}`); }, [currentEntryList, playQueue, playQueue.currentIndex, player.status]); + const handleGaplessPlayer1 = () => { + gaplessListenHandler( + player1Ref, + player2Ref, + playQueue, + 1, + dispatch, + pollingInterval + ); + }; + + const handleGaplessPlayer2 = () => { + gaplessListenHandler( + player2Ref, + player1Ref, + playQueue, + 2, + dispatch, + pollingInterval + ); + }; + return ( <> @@ -395,7 +489,9 @@ const Player = ({ currentEntryList, debug, children }: any, ref: any) => { } listenInterval={pollingInterval} preload="auto" - onListen={handleListenPlayer1} + onListen={ + fadeDuration === 0 ? handleGaplessPlayer1 : handleListenPlayer1 + } onEnded={handleOnEndedPlayer1} volume={playQueue.player1.volume} autoPlay={ @@ -419,7 +515,9 @@ const Player = ({ currentEntryList, debug, children }: any, ref: any) => { } listenInterval={pollingInterval} preload="auto" - onListen={handleListenPlayer2} + onListen={ + fadeDuration === 0 ? handleGaplessPlayer2 : handleListenPlayer2 + } onEnded={handleOnEndedPlayer2} volume={playQueue.player2.volume} autoPlay={ diff --git a/src/components/player/PlayerBar.tsx b/src/components/player/PlayerBar.tsx index 3ae50ca..b1d9c91 100644 --- a/src/components/player/PlayerBar.tsx +++ b/src/components/player/PlayerBar.tsx @@ -56,7 +56,6 @@ const PlayerBar = () => { const [seekBackwardInterval] = useState( Number(settings.getSync('seekBackwardInterval')) || 5 ); - const [debug] = useState(Boolean(settings.getSync('showDebugWindow'))); const playersRef = useRef(); const history = useHistory(); @@ -79,13 +78,27 @@ const PlayerBar = () => { volume: localVolume, }) ); + if (playQueue.fadeDuration === 0) { + dispatch( + setPlayerVolume({ + player: playQueue.currentPlayer === 1 ? 2 : 1, + volume: localVolume, + }) + ); + } settings.setSync('volume', localVolume); } setIsDragging(false); }, 10); return () => clearTimeout(debounce); - }, [dispatch, isDraggingVolume, localVolume, playQueue.currentPlayer]); + }, [ + dispatch, + isDraggingVolume, + localVolume, + playQueue.currentPlayer, + playQueue.fadeDuration, + ]); useEffect(() => { setSeek(player.currentSeek); @@ -309,8 +322,10 @@ const PlayerBar = () => { }; return ( - - {debug && } + + {playQueue.showDebugWindow && ( + + )} { }} /> - {/* Next Song Button */} { const dispatch = useAppDispatch(); + const playQueue = useAppSelector((state) => state.playQueue); const [isScanning, setIsScanning] = useState(false); const [scanProgress, setScanProgress] = useState(0); const [imgCacheSize, setImgCacheSize] = useState(0); @@ -50,7 +54,7 @@ const Config = () => { const [isEditingCachePath, setIsEditingCachePath] = useState(false); const [newCachePath, setNewCachePath] = useState(''); const [errorMessage, setErrorMessage] = useState(''); - const [requiresReload, setRequiresReload] = useState(false); + const [requiresReload] = useState(false); const songCols: any = settings.getSync('songListColumns'); const albumCols: any = settings.getSync('albumListColumns'); @@ -178,18 +182,25 @@ const Config = () => { Fading works by polling the audio player on an interval to determine when to start fading to the next track. Due to this, you may notice the fade timing may not be 100% perfect. Lowering the player polling - interval may increase the accuracy of the fade, but may also decrease - application performance. You may find that lowering the polling - interval between 1 and 50 can cause - application instabilities, so use at your own risk. + interval can increase the accuracy of the fade, but may also decrease + application performance as calculations are running for the fade.

If volume fade is disabled, then the fading-in track will start at the - specified crossfade duration at full volume. This is to tentatively - support gapless playback without fade, but due to - tiny inconsistencies with the audio polling interval, you may find the - fade better. Experiment with these settings to find your sweet spot. + specified crossfade duration at full volume. +

+ +

+ Setting the crossfade duration to 0 will enable{' '} + gapless playback. All other playback settings except + the polling interval will be ignored. It is recommended that you use a + polling interval of 1 - 20 for increased transition + accuracy. +

+

+ *Enable the debug window if you want to view the differences between + each fade type

@@ -200,10 +211,22 @@ const Config = () => { min={0} max={100} onChange={(e) => { - settings.setSync('fadeDuration', e); + settings.setSync('fadeDuration', Number(e)); dispatch( - setPlaybackSetting({ setting: 'fadeDuration', value: e }) + setPlaybackSetting({ + setting: 'fadeDuration', + value: Number(e), + }) ); + + if (Number(e) === 0) { + dispatch( + setPlayerVolume({ player: 1, volume: playQueue.volume }) + ); + dispatch( + setPlayerVolume({ player: 2, volume: playQueue.volume }) + ); + } }} style={{ width: '150px' }} /> @@ -216,9 +239,12 @@ const Config = () => { min={1} max={1000} onChange={(e) => { - settings.setSync('pollingInterval', e); + settings.setSync('pollingInterval', Number(e)); dispatch( - setPlaybackSetting({ setting: 'pollingInterval', value: e }) + setPlaybackSetting({ + setting: 'pollingInterval', + value: Number(e), + }) ); }} style={{ width: '150px' }} @@ -237,6 +263,16 @@ const Config = () => { Equal Power Linear Dipped + Constant Power + + Constant Power (slow fade) + + + Constant Power (slow cut) + + + Constant Power (fast cut) +
Volume fade diff --git a/src/redux/playQueueSlice.ts b/src/redux/playQueueSlice.ts index fca8cac..17da68e 100644 --- a/src/redux/playQueueSlice.ts +++ b/src/redux/playQueueSlice.ts @@ -161,6 +161,12 @@ const getCurrentEntryIndex = (entries: any[], currentSongId: string) => { return entries.findIndex((entry: any) => entry.id === currentSongId); }; +const handleGaplessPlayback = (state: PlayQueue) => { + if (state.fadeDuration === 0) { + state.player2.volume = state.volume; + } +}; + const playQueueSlice = createSlice({ name: 'nowPlaying', initialState, @@ -169,6 +175,7 @@ const playQueueSlice = createSlice({ const currentEntry = entrySelect(state); resetPlayerDefaults(state); + handleGaplessPlayback(state); state.currentSongId = state[currentEntry][0].id; }, @@ -370,6 +377,7 @@ const playQueueSlice = createSlice({ incrementCurrentIndex: (state, action: PayloadAction) => { const currentEntry = entrySelect(state); + if (state[currentEntry].length >= 1 && state.repeat !== 'one') { if (state.currentIndex < state[currentEntry].length - 1) { // Check that current index isn't on the last track of the queue @@ -400,6 +408,7 @@ const playQueueSlice = createSlice({ } } + handleGaplessPlayback(state); state.currentSongId = state[currentEntry][state.currentIndex].id; }, @@ -417,6 +426,7 @@ const playQueueSlice = createSlice({ ) { // Reset the player on the end of the playlist if no repeat resetPlayerDefaults(state); + handleGaplessPlayback(state); } else if (state.player1.index + 2 >= state[currentEntry].length) { /* If incrementing would be greater than the total number of entries, reset it back to 0. Also check if player1 is already set to 0. */ @@ -436,6 +446,7 @@ const playQueueSlice = createSlice({ ) { // Reset the player on the end of the playlist if no repeat resetPlayerDefaults(state); + handleGaplessPlayback(state); } else if (state.player2.index + 2 >= state[currentEntry].length) { /* If incrementing would be greater than the total number of entries, reset it back to 0. Also check if player1 is already set to 0. */ @@ -502,6 +513,7 @@ const playQueueSlice = createSlice({ state.player2.volume = 0; } + handleGaplessPlayback(state); state.currentSongId = state[currentEntry][state.currentIndex].id; } }, @@ -524,6 +536,7 @@ const playQueueSlice = createSlice({ state.repeat, state.currentIndex ); + handleGaplessPlayback(state); }, setCurrentIndex: (state, action: PayloadAction) => { @@ -545,6 +558,7 @@ const playQueueSlice = createSlice({ ) => { // Used with gridview where you just want to set the entry queue directly resetPlayerDefaults(state); + handleGaplessPlayback(state); action.payload.entries.map((entry: any) => state.entry.push(entry)); if (state.shuffle) { @@ -570,6 +584,7 @@ const playQueueSlice = createSlice({ Setting the entry queue by row will add all entries, but set the current index to the row that was double clicked */ resetPlayerDefaults(state); + handleGaplessPlayback(state); action.payload.entries.map((entry: any) => state.entry.push(entry)); if (state.shuffle) {