From cd2380dde6062e90175561089066eea3c701035d Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 10 Sep 2023 20:07:27 -0700 Subject: [PATCH 1/6] replaygain! --- .../api/jellyfin/jellyfin-normalize.ts | 2 + .../api/navidrome/navidrome-normalize.ts | 9 +- src/renderer/api/navidrome/navidrome-types.ts | 4 + .../api/subsonic/subsonic-normalize.ts | 2 + src/renderer/api/types.ts | 7 + .../components/audio-player/index.tsx | 147 +++++++++++++++++- .../components/playback/mpv-settings.tsx | 6 +- .../components/playback/playback-tab.tsx | 15 +- .../components/playback/scrobble-settings.tsx | 4 - 9 files changed, 179 insertions(+), 17 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index e0bd61081..6acbf0f44 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -151,6 +151,7 @@ const normalizeSong = ( createdAt: item.DateCreated, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, duration: item.RunTimeTicks / 10000000, + gain: null, genres: item.GenreItems?.map((entry) => ({ id: entry.Id, imageUrl: null, @@ -165,6 +166,7 @@ const normalizeSong = ( lyrics: null, name: item.Name, path: (item.MediaSources && item.MediaSources[0]?.Path) || null, + peak: null, playCount: (item.UserData && item.UserData.PlayCount) || 0, playlistItemId: item.PlaylistItemId, // releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null, diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index e3f75e240..646dd5ff9 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -74,7 +74,6 @@ const normalizeSong = ( }); const imagePlaceholderUrl = null; - return { album: item.album, albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }], @@ -90,6 +89,10 @@ const normalizeSong = ( createdAt: item.createdAt.split('T')[0], discNumber: item.discNumber, duration: item.duration, + gain: + item.rgAlbumGain || item.rgTrackGain + ? { album: item.rgAlbumGain, track: item.rgTrackGain } + : null, genres: item.genres?.map((genre) => ({ id: genre.id, imageUrl: null, @@ -104,6 +107,10 @@ const normalizeSong = ( lyrics: item.lyrics ? item.lyrics : null, name: item.title, path: item.path, + peak: + item.rgAlbumPeak || item.rgTrackPeak + ? { album: item.rgAlbumPeak, track: item.rgTrackPeak } + : null, playCount: item.playCount, playlistItemId, releaseDate: new Date(item.year, 0, 1).toISOString(), diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index 498fbb7c3..7260053cc 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -205,6 +205,10 @@ const song = z.object({ playCount: z.number(), playDate: z.string(), rating: z.number().optional(), + rgAlbumGain: z.number().optional(), + rgAlbumPeak: z.number().optional(), + rgTrackGain: z.number().optional(), + rgTrackPeak: z.number().optional(), size: z.number(), sortAlbumArtistName: z.string(), sortArtistName: z.string(), diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index f68dcceac..2aefb2049 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -68,6 +68,7 @@ const normalizeSong = ( createdAt: item.created, discNumber: item.discNumber || 1, duration: item.duration || 0, + gain: null, genres: item.genre ? [ { @@ -86,6 +87,7 @@ const normalizeSong = ( lyrics: null, name: item.title, path: item.path, + peak: null, playCount: item?.playCount || 0, releaseDate: null, releaseYear: item.year ? String(item.year) : null, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index ec6daaa38..fcac93039 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -171,6 +171,11 @@ export type Album = { userRating: number | null; } & { songs?: Song[] }; +export type GainInfo = { + album?: number; + track?: number; +}; + export type Song = { album: string | null; albumArtists: RelatedArtist[]; @@ -186,6 +191,7 @@ export type Song = { createdAt: string; discNumber: number; duration: number; + gain: GainInfo | null; genres: Genre[]; id: string; imagePlaceholderUrl: string | null; @@ -195,6 +201,7 @@ export type Song = { lyrics: string | null; name: string; path: string | null; + peak: GainInfo | null; playCount: number; playlistItemId?: string; releaseDate: string | null; diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index ee602baa7..8c39053e0 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -1,7 +1,7 @@ import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react'; import isElectron from 'is-electron'; import type { ReactPlayerProps } from 'react-player'; -import ReactPlayer from 'react-player'; +import ReactPlayer from 'react-player/lazy'; import type { Song } from '/@/renderer/api/types'; import { crossfadeHandler, @@ -33,6 +33,11 @@ const getDuration = (ref: any) => { return ref.current?.player?.player?.player?.duration; }; +type WebAudio = { + context: AudioContext; + gain: GainNode; +}; + export const AudioPlayer = forwardRef( ( { @@ -49,10 +54,86 @@ export const AudioPlayer = forwardRef( }: AudioPlayerProps, ref: any, ) => { - const player1Ref = useRef(null); - const player2Ref = useRef(null); + const player1Ref = useRef(null); + const player2Ref = useRef(null); const [isTransitioning, setIsTransitioning] = useState(false); const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId); + const playback = useSettingsStore((state) => state.playback.mpvProperties); + + const [webAudio, setWebAudio] = useState(null); + const [player1Source, setPlayer1Source] = useState( + null, + ); + const [player2Source, setPlayer2Source] = useState( + null, + ); + const calculateReplayGain = useCallback( + (song: Song): number => { + if (playback.replayGainMode === 'no') { + return 1; + } + + let gain: number | undefined; + let peak: number | undefined; + + if (playback.replayGainMode === 'track') { + gain = song.gain?.track ?? song.gain?.album; + peak = song.peak?.track ?? song.peak?.album; + } else { + gain = song.gain?.album ?? song.gain?.track; + peak = song.peak?.album ?? song.peak?.track; + } + + if (gain === undefined) { + gain = playback.replayGainFallbackDB; + + if (!gain) { + return 1; + } + } + + if (peak === undefined) { + peak = 1; + } + + const preAmp = playback.replayGainPreampDB ?? 0; + + // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19 + // Normalized to max gain + const expectedGain = 10 ** ((gain + preAmp) / 20); + + if (playback.replayGainClip) { + return Math.min(expectedGain, 1 / peak); + } + return expectedGain; + }, + [ + playback.replayGainClip, + playback.replayGainFallbackDB, + playback.replayGainMode, + playback.replayGainPreampDB, + ], + ); + + useEffect(() => { + if ('AudioContext' in window) { + const context = new AudioContext({ + latencyHint: 'playback', + sampleRate: playback.audioSampleRateHz || undefined, + }); + const gain = context.createGain(); + gain.connect(context.destination); + + setWebAudio({ context, gain }); + + return () => { + return context.close(); + }; + } + return () => {}; + // Intentionally ignore the sample rate dependency, as it makes things really messy + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useImperativeHandle(ref, () => ({ get player1() { @@ -159,10 +240,65 @@ export const AudioPlayer = forwardRef( } }, [audioDeviceId]); + useEffect(() => { + if (webAudio && player1Source) { + if (player1 === undefined) { + player1Source.disconnect(); + setPlayer1Source(null); + } else { + webAudio.gain.gain.setValueAtTime(calculateReplayGain(player1), 0); + } + } + }, [calculateReplayGain, player1, player1Source, webAudio]); + + useEffect(() => { + if (webAudio && player2Source) { + if (player2 === undefined) { + player2Source.disconnect(); + setPlayer2Source(null); + } else { + webAudio.gain.gain.setValueAtTime(calculateReplayGain(player2), 0); + } + } + }, [calculateReplayGain, player2, player2Source, webAudio]); + + const handlePlayer1Start = useCallback( + (player: ReactPlayer) => { + if (!webAudio || player1Source) return; + + const internal = player.getInternalPlayer() as HTMLMediaElement | undefined; + if (internal) { + const { context, gain } = webAudio; + const source = context.createMediaElementSource(internal); + source.connect(gain); + setPlayer1Source(source); + } + }, + [player1Source, webAudio], + ); + + const handlePlayer2Start = useCallback( + (player: ReactPlayer) => { + if (!webAudio || player2Source) return; + + const internal = player.getInternalPlayer() as HTMLMediaElement | undefined; + if (internal) { + const { context, gain } = webAudio; + const source = context.createMediaElementSource(internal); + source.connect(gain); + setPlayer2Source(source); + } + }, + [player2Source, webAudio], + ); + return ( <> ); diff --git a/src/renderer/features/settings/components/playback/mpv-settings.tsx b/src/renderer/features/settings/components/playback/mpv-settings.tsx index 913a6d7d5..702ee7e8e 100644 --- a/src/renderer/features/settings/components/playback/mpv-settings.tsx +++ b/src/renderer/features/settings/components/playback/mpv-settings.tsx @@ -195,7 +195,7 @@ export const MpvSettings = () => { ), description: 'Select the output sample rate to be used if the sample frequency selected is different from that of the current media', - isHidden: settings.type !== PlaybackType.LOCAL, + note: 'Page refresh required for web player', title: 'Sample rate', }, { @@ -233,7 +233,6 @@ export const MpvSettings = () => { ), description: 'Adjust volume gain according to replaygain values stored in the file metadata (--replaygain)', - isHidden: settings.type !== PlaybackType.LOCAL, note: 'Restart required', title: 'ReplayGain mode', }, @@ -247,7 +246,6 @@ export const MpvSettings = () => { ), description: 'Pre-amplification gain in dB to apply to the selected replaygain gain (--replaygain-preamp)', - isHidden: settings.type !== PlaybackType.LOCAL, title: 'ReplayGain preamp (dB)', }, { @@ -261,7 +259,6 @@ export const MpvSettings = () => { ), description: 'Prevent clipping caused by replaygain by automatically lowering the gain (--replaygain-clip)', - isHidden: settings.type !== PlaybackType.LOCAL, title: 'ReplayGain clipping', }, { @@ -274,7 +271,6 @@ export const MpvSettings = () => { ), description: 'Gain in dB to apply if the file has no replay gain tags. This option is always applied if the replaygain logic is somehow inactive. If this is applied, no other replaygain options are applied', - isHidden: settings.type !== PlaybackType.LOCAL, title: 'ReplayGain fallback (dB)', }, ]; diff --git a/src/renderer/features/settings/components/playback/playback-tab.tsx b/src/renderer/features/settings/components/playback/playback-tab.tsx index 3e2c82999..6cb77b8b9 100644 --- a/src/renderer/features/settings/components/playback/playback-tab.tsx +++ b/src/renderer/features/settings/components/playback/playback-tab.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useMemo } from 'react'; import { Divider, Stack } from '@mantine/core'; import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings'; import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings'; @@ -12,13 +12,20 @@ const MpvSettings = lazy(() => ); export const PlaybackTab = () => { + const hasFancyAudio = useMemo(() => { + return isElectron() || 'AudioContext' in window; + }, []); return ( - }>{isElectron() && } - - + }>{hasFancyAudio && } + {isElectron() && ( + <> + + + + )} ); diff --git a/src/renderer/features/settings/components/playback/scrobble-settings.tsx b/src/renderer/features/settings/components/playback/scrobble-settings.tsx index 764f29d6b..cfef1ffdf 100644 --- a/src/renderer/features/settings/components/playback/scrobble-settings.tsx +++ b/src/renderer/features/settings/components/playback/scrobble-settings.tsx @@ -1,4 +1,3 @@ -import isElectron from 'is-electron'; import { NumberInput, Slider, Switch, Text } from '/@/renderer/components'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; import { SettingOption, SettingsSection } from '../settings-section'; @@ -27,7 +26,6 @@ export const ScrobbleSettings = () => { /> ), description: 'Enable or disable scrobbling to your media server', - isHidden: !isElectron(), title: 'Scrobble', }, { @@ -54,7 +52,6 @@ export const ScrobbleSettings = () => { ), description: 'The percentage of the song that must be played before submitting a scrobble', - isHidden: !isElectron(), title: 'Minimum scrobble percentage*', }, { @@ -81,7 +78,6 @@ export const ScrobbleSettings = () => { ), description: 'The duration in seconds of a song that must be played before submitting a scrobble', - isHidden: !isElectron(), title: 'Minimum scrobble duration (seconds)*', }, ]; From 807016532aacefaa77975ffad814a446bc99e9ea Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 10 Sep 2023 21:14:42 -0700 Subject: [PATCH 2/6] resume context --- src/renderer/components/audio-player/index.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index 8c39053e0..8a3104804 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -263,8 +263,11 @@ export const AudioPlayer = forwardRef( }, [calculateReplayGain, player2, player2Source, webAudio]); const handlePlayer1Start = useCallback( - (player: ReactPlayer) => { + async (player: ReactPlayer) => { if (!webAudio || player1Source) return; + if (webAudio.context.state !== 'running') { + await webAudio.context.resume(); + } const internal = player.getInternalPlayer() as HTMLMediaElement | undefined; if (internal) { @@ -278,8 +281,11 @@ export const AudioPlayer = forwardRef( ); const handlePlayer2Start = useCallback( - (player: ReactPlayer) => { + async (player: ReactPlayer) => { if (!webAudio || player2Source) return; + if (webAudio.context.state !== 'running') { + await webAudio.context.resume(); + } const internal = player.getInternalPlayer() as HTMLMediaElement | undefined; if (internal) { From 2965cdc826721ecee663966b74474e7baf339d1d Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 11 Sep 2023 20:59:17 -0700 Subject: [PATCH 3/6] don't fire both players --- src/renderer/components/audio-player/index.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index 8a3104804..e6e643168 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -126,6 +126,8 @@ export const AudioPlayer = forwardRef( setWebAudio({ context, gain }); + console.log(context, gain); + return () => { return context.close(); }; @@ -245,22 +247,22 @@ export const AudioPlayer = forwardRef( if (player1 === undefined) { player1Source.disconnect(); setPlayer1Source(null); - } else { + } else if (currentPlayer === 1) { webAudio.gain.gain.setValueAtTime(calculateReplayGain(player1), 0); } } - }, [calculateReplayGain, player1, player1Source, webAudio]); + }, [calculateReplayGain, currentPlayer, player1, player1Source, webAudio]); useEffect(() => { if (webAudio && player2Source) { if (player2 === undefined) { player2Source.disconnect(); setPlayer2Source(null); - } else { + } else if (currentPlayer === 2) { webAudio.gain.gain.setValueAtTime(calculateReplayGain(player2), 0); } } - }, [calculateReplayGain, player2, player2Source, webAudio]); + }, [calculateReplayGain, currentPlayer, player2, player2Source, webAudio]); const handlePlayer1Start = useCallback( async (player: ReactPlayer) => { From 493f6994cd903c8239133c17f19d0c0af90e3040 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 16 Sep 2023 11:08:31 -0700 Subject: [PATCH 4/6] replaygain for jellyfin --- src/renderer/api/jellyfin/jellyfin-normalize.ts | 6 +++++- src/renderer/api/jellyfin/jellyfin-types.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index 6acbf0f44..33d30a70d 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -151,7 +151,11 @@ const normalizeSong = ( createdAt: item.DateCreated, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, duration: item.RunTimeTicks / 10000000, - gain: null, + gain: item.LUFS + ? { + track: -18 - item.LUFS, + } + : null, genres: item.GenreItems?.map((entry) => ({ id: entry.Id, imageUrl: null, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index a984ea289..2e039dff2 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -406,6 +406,7 @@ const song = z.object({ ImageTags: imageTags, IndexNumber: z.number(), IsFolder: z.boolean(), + LUFS: z.number().optional(), LocationType: z.string(), MediaSources: z.array(mediaSources), MediaType: z.string(), From 03cc72582810b13fb4074eacaef3b8bbdabbcfe7 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 16 Sep 2023 12:40:30 -0700 Subject: [PATCH 5/6] actually remove console.log --- src/renderer/components/audio-player/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index e6e643168..541620779 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -126,8 +126,6 @@ export const AudioPlayer = forwardRef( setWebAudio({ context, gain }); - console.log(context, gain); - return () => { return context.close(); }; From 36bb37882f52681ef3bdf58e6b980a3a2e794780 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 21 Sep 2023 11:29:23 -0700 Subject: [PATCH 6/6] Linting --- src/renderer/api/jellyfin/jellyfin-normalize.ts | 2 +- src/renderer/api/navidrome/navidrome-normalize.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index df4797333..12e654306 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -150,12 +150,12 @@ const normalizeSong = ( container: (item.MediaSources && item.MediaSources[0]?.Container) || null, createdAt: item.DateCreated, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, + duration: item.RunTimeTicks / 10000, gain: item.LUFS ? { track: -18 - item.LUFS, } : null, - duration: item.RunTimeTicks / 10000, genres: item.GenreItems?.map((entry) => ({ id: entry.Id, imageUrl: null, diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 1f0219734..17ba9508d 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -88,11 +88,11 @@ const normalizeSong = ( container: item.suffix, createdAt: item.createdAt.split('T')[0], discNumber: item.discNumber, + duration: item.duration * 1000, gain: item.rgAlbumGain || item.rgTrackGain ? { album: item.rgAlbumGain, track: item.rgTrackGain } : null, - duration: item.duration * 1000, genres: item.genres?.map((genre) => ({ id: genre.id, imageUrl: null,