Replaygain support for Web Player (#243)

* replaygain!

* resume context

* don't fire both players

* replaygain for jellyfin

* actually remove console.log

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Kendall Garner 2023-09-22 00:06:13 +00:00 committed by GitHub
parent fd264daffc
commit 65f28bb9dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 190 additions and 17 deletions

View file

@ -151,6 +151,11 @@ const normalizeSong = (
createdAt: item.DateCreated, createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
duration: item.RunTimeTicks / 10000, duration: item.RunTimeTicks / 10000,
gain: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
genres: item.GenreItems?.map((entry) => ({ genres: item.GenreItems?.map((entry) => ({
id: entry.Id, id: entry.Id,
imageUrl: null, imageUrl: null,
@ -165,6 +170,7 @@ const normalizeSong = (
lyrics: null, lyrics: null,
name: item.Name, name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null, path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0, playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId, playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null, // releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,

View file

@ -406,6 +406,7 @@ const song = z.object({
ImageTags: imageTags, ImageTags: imageTags,
IndexNumber: z.number(), IndexNumber: z.number(),
IsFolder: z.boolean(), IsFolder: z.boolean(),
LUFS: z.number().optional(),
LocationType: z.string(), LocationType: z.string(),
MediaSources: z.array(mediaSources), MediaSources: z.array(mediaSources),
MediaType: z.string(), MediaType: z.string(),

View file

@ -74,7 +74,6 @@ const normalizeSong = (
}); });
const imagePlaceholderUrl = null; const imagePlaceholderUrl = null;
return { return {
album: item.album, album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }], albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
@ -90,6 +89,10 @@ const normalizeSong = (
createdAt: item.createdAt.split('T')[0], createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber, discNumber: item.discNumber,
duration: item.duration * 1000, duration: item.duration * 1000,
gain:
item.rgAlbumGain || item.rgTrackGain
? { album: item.rgAlbumGain, track: item.rgTrackGain }
: null,
genres: item.genres?.map((genre) => ({ genres: item.genres?.map((genre) => ({
id: genre.id, id: genre.id,
imageUrl: null, imageUrl: null,
@ -104,6 +107,10 @@ const normalizeSong = (
lyrics: item.lyrics ? item.lyrics : null, lyrics: item.lyrics ? item.lyrics : null,
name: item.title, name: item.title,
path: item.path, path: item.path,
peak:
item.rgAlbumPeak || item.rgTrackPeak
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
: null,
playCount: item.playCount, playCount: item.playCount,
playlistItemId, playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(), releaseDate: new Date(item.year, 0, 1).toISOString(),

View file

@ -205,6 +205,10 @@ const song = z.object({
playCount: z.number(), playCount: z.number(),
playDate: z.string(), playDate: z.string(),
rating: z.number().optional(), rating: z.number().optional(),
rgAlbumGain: z.number().optional(),
rgAlbumPeak: z.number().optional(),
rgTrackGain: z.number().optional(),
rgTrackPeak: z.number().optional(),
size: z.number(), size: z.number(),
sortAlbumArtistName: z.string(), sortAlbumArtistName: z.string(),
sortArtistName: z.string(), sortArtistName: z.string(),

View file

@ -68,6 +68,7 @@ const normalizeSong = (
createdAt: item.created, createdAt: item.created,
discNumber: item.discNumber || 1, discNumber: item.discNumber || 1,
duration: item.duration || 0, duration: item.duration || 0,
gain: null,
genres: item.genre genres: item.genre
? [ ? [
{ {
@ -86,6 +87,7 @@ const normalizeSong = (
lyrics: null, lyrics: null,
name: item.title, name: item.title,
path: item.path, path: item.path,
peak: null,
playCount: item?.playCount || 0, playCount: item?.playCount || 0,
releaseDate: null, releaseDate: null,
releaseYear: item.year ? String(item.year) : null, releaseYear: item.year ? String(item.year) : null,

View file

@ -171,6 +171,11 @@ export type Album = {
userRating: number | null; userRating: number | null;
} & { songs?: Song[] }; } & { songs?: Song[] };
export type GainInfo = {
album?: number;
track?: number;
};
export type Song = { export type Song = {
album: string | null; album: string | null;
albumArtists: RelatedArtist[]; albumArtists: RelatedArtist[];
@ -186,6 +191,7 @@ export type Song = {
createdAt: string; createdAt: string;
discNumber: number; discNumber: number;
duration: number; duration: number;
gain: GainInfo | null;
genres: Genre[]; genres: Genre[];
id: string; id: string;
imagePlaceholderUrl: string | null; imagePlaceholderUrl: string | null;
@ -195,6 +201,7 @@ export type Song = {
lyrics: string | null; lyrics: string | null;
name: string; name: string;
path: string | null; path: string | null;
peak: GainInfo | null;
playCount: number; playCount: number;
playlistItemId?: string; playlistItemId?: string;
releaseDate: string | null; releaseDate: string | null;

View file

@ -1,7 +1,7 @@
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react'; import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import type { ReactPlayerProps } from 'react-player'; 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 type { Song } from '/@/renderer/api/types';
import { import {
crossfadeHandler, crossfadeHandler,
@ -33,6 +33,11 @@ const getDuration = (ref: any) => {
return ref.current?.player?.player?.player?.duration; return ref.current?.player?.player?.player?.duration;
}; };
type WebAudio = {
context: AudioContext;
gain: GainNode;
};
export const AudioPlayer = forwardRef( export const AudioPlayer = forwardRef(
( (
{ {
@ -49,10 +54,86 @@ export const AudioPlayer = forwardRef(
}: AudioPlayerProps, }: AudioPlayerProps,
ref: any, ref: any,
) => { ) => {
const player1Ref = useRef<any>(null); const player1Ref = useRef<ReactPlayer>(null);
const player2Ref = useRef<any>(null); const player2Ref = useRef<ReactPlayer>(null);
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId); const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties);
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(
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&section=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, () => ({ useImperativeHandle(ref, () => ({
get player1() { get player1() {
@ -159,10 +240,71 @@ export const AudioPlayer = forwardRef(
} }
}, [audioDeviceId]); }, [audioDeviceId]);
useEffect(() => {
if (webAudio && player1Source) {
if (player1 === undefined) {
player1Source.disconnect();
setPlayer1Source(null);
} else if (currentPlayer === 1) {
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player1), 0);
}
}
}, [calculateReplayGain, currentPlayer, player1, player1Source, webAudio]);
useEffect(() => {
if (webAudio && player2Source) {
if (player2 === undefined) {
player2Source.disconnect();
setPlayer2Source(null);
} else if (currentPlayer === 2) {
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player2), 0);
}
}
}, [calculateReplayGain, currentPlayer, player2, player2Source, webAudio]);
const handlePlayer1Start = useCallback(
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) {
const { context, gain } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gain);
setPlayer1Source(source);
}
},
[player1Source, webAudio],
);
const handlePlayer2Start = useCallback(
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) {
const { context, gain } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gain);
setPlayer2Source(source);
}
},
[player2Source, webAudio],
);
return ( return (
<> <>
<ReactPlayer <ReactPlayer
ref={player1Ref} ref={player1Ref}
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
height={0} height={0}
muted={muted} muted={muted}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING} playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
@ -174,9 +316,13 @@ export const AudioPlayer = forwardRef(
onProgress={ onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1 playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
} }
onReady={handlePlayer1Start}
/> />
<ReactPlayer <ReactPlayer
ref={player2Ref} ref={player2Ref}
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
height={0} height={0}
muted={muted} muted={muted}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING} playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
@ -188,6 +334,7 @@ export const AudioPlayer = forwardRef(
onProgress={ onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2 playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
} }
onReady={handlePlayer2Start}
/> />
</> </>
); );

View file

@ -195,7 +195,7 @@ export const MpvSettings = () => {
), ),
description: description:
'Select the output sample rate to be used if the sample frequency selected is different from that of the current media', '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', title: 'Sample rate',
}, },
{ {
@ -233,7 +233,6 @@ export const MpvSettings = () => {
), ),
description: description:
'Adjust volume gain according to replaygain values stored in the file metadata (--replaygain)', 'Adjust volume gain according to replaygain values stored in the file metadata (--replaygain)',
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required', note: 'Restart required',
title: 'ReplayGain mode', title: 'ReplayGain mode',
}, },
@ -247,7 +246,6 @@ export const MpvSettings = () => {
), ),
description: description:
'Pre-amplification gain in dB to apply to the selected replaygain gain (--replaygain-preamp)', 'Pre-amplification gain in dB to apply to the selected replaygain gain (--replaygain-preamp)',
isHidden: settings.type !== PlaybackType.LOCAL,
title: 'ReplayGain preamp (dB)', title: 'ReplayGain preamp (dB)',
}, },
{ {
@ -261,7 +259,6 @@ export const MpvSettings = () => {
), ),
description: description:
'Prevent clipping caused by replaygain by automatically lowering the gain (--replaygain-clip)', 'Prevent clipping caused by replaygain by automatically lowering the gain (--replaygain-clip)',
isHidden: settings.type !== PlaybackType.LOCAL,
title: 'ReplayGain clipping', title: 'ReplayGain clipping',
}, },
{ {
@ -274,7 +271,6 @@ export const MpvSettings = () => {
), ),
description: 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', '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)', title: 'ReplayGain fallback (dB)',
}, },
]; ];

View file

@ -1,4 +1,4 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense, useMemo } from 'react';
import { Divider, Stack } from '@mantine/core'; import { Divider, Stack } from '@mantine/core';
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings'; import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings'; import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings';
@ -12,13 +12,20 @@ const MpvSettings = lazy(() =>
); );
export const PlaybackTab = () => { export const PlaybackTab = () => {
const hasFancyAudio = useMemo(() => {
return isElectron() || 'AudioContext' in window;
}, []);
return ( return (
<Stack spacing="md"> <Stack spacing="md">
<AudioSettings /> <AudioSettings />
<Suspense fallback={<></>}>{isElectron() && <MpvSettings />}</Suspense> <Suspense fallback={<></>}>{hasFancyAudio && <MpvSettings />}</Suspense>
<Divider /> <Divider />
{isElectron() && (
<>
<ScrobbleSettings /> <ScrobbleSettings />
<Divider /> <Divider />
</>
)}
<LyricSettings /> <LyricSettings />
</Stack> </Stack>
); );

View file

@ -1,4 +1,3 @@
import isElectron from 'is-electron';
import { NumberInput, Slider, Switch, Text } from '/@/renderer/components'; import { NumberInput, Slider, Switch, Text } from '/@/renderer/components';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { SettingOption, SettingsSection } from '../settings-section'; import { SettingOption, SettingsSection } from '../settings-section';
@ -27,7 +26,6 @@ export const ScrobbleSettings = () => {
/> />
), ),
description: 'Enable or disable scrobbling to your media server', description: 'Enable or disable scrobbling to your media server',
isHidden: !isElectron(),
title: 'Scrobble', title: 'Scrobble',
}, },
{ {
@ -54,7 +52,6 @@ export const ScrobbleSettings = () => {
), ),
description: description:
'The percentage of the song that must be played before submitting a scrobble', 'The percentage of the song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble percentage*', title: 'Minimum scrobble percentage*',
}, },
{ {
@ -81,7 +78,6 @@ export const ScrobbleSettings = () => {
), ),
description: description:
'The duration in seconds of a song that must be played before submitting a scrobble', 'The duration in seconds of a song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble duration (seconds)*', title: 'Minimum scrobble duration (seconds)*',
}, },
]; ];