diff --git a/conf/configuration.go b/conf/configuration.go index b323de03c..abd84f594 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -63,6 +63,7 @@ type configOptions struct { ReverseProxyUserHeader string ReverseProxyWhitelist string Prometheus prometheusOptions + EnableReplayGain bool Scanner scannerOptions @@ -265,6 +266,8 @@ func init() { viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.metricspath", "/metrics") + viper.SetDefault("enablereplaygain", false) + viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor) viper.SetDefault("scanner.genreseparators", ";/,") diff --git a/db/migration/20230117155559_add_replaygain_metadata.go b/db/migration/20230117155559_add_replaygain_metadata.go new file mode 100644 index 000000000..6ce480e22 --- /dev/null +++ b/db/migration/20230117155559_add_replaygain_metadata.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(upAddReplaygainMetadata, downAddReplaygainMetadata) +} + +func upAddReplaygainMetadata(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file add + rg_album_gain real; +alter table media_file add + rg_album_peak real; +alter table media_file add + rg_track_gain real; +alter table media_file add + rg_track_peak real; +`) + if err != nil { + return err + } + + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddReplaygainMetadata(tx *sql.Tx) error { + return nil +} diff --git a/model/mediafile.go b/model/mediafile.go index 263315848..71a8226bc 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -18,50 +18,55 @@ type MediaFile struct { Annotations `structs:"-"` Bookmarkable `structs:"-"` - ID string `structs:"id" json:"id" orm:"pk;column(id)"` - Path string `structs:"path" json:"path"` - Title string `structs:"title" json:"title"` - Album string `structs:"album" json:"album"` - ArtistID string `structs:"artist_id" json:"artistId" orm:"pk;column(artist_id)"` - Artist string `structs:"artist" json:"artist"` - AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"pk;column(album_artist_id)"` - AlbumArtist string `structs:"album_artist" json:"albumArtist"` - AlbumID string `structs:"album_id" json:"albumId" orm:"pk;column(album_id)"` - HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` - TrackNumber int `structs:"track_number" json:"trackNumber"` - DiscNumber int `structs:"disc_number" json:"discNumber"` - DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` - Year int `structs:"year" json:"year"` - Size int64 `structs:"size" json:"size"` - Suffix string `structs:"suffix" json:"suffix"` - Duration float32 `structs:"duration" json:"duration"` - BitRate int `structs:"bit_rate" json:"bitRate"` - Channels int `structs:"channels" json:"channels"` - Genre string `structs:"genre" json:"genre"` - Genres Genres `structs:"-" json:"genres"` - FullText string `structs:"full_text" json:"fullText"` - SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` - SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` - SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` - SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` - OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` - OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` - OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` - OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` - Compilation bool `structs:"compilation" json:"compilation"` - Comment string `structs:"comment" json:"comment,omitempty"` - Lyrics string `structs:"lyrics" json:"lyrics,omitempty"` - Bpm int `structs:"bpm" json:"bpm,omitempty"` - CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` - MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"` - MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty" orm:"column(mbz_release_track_id)"` - MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"` - MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"` - MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"` - MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Time this entry was created in the DB - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime) + ID string `structs:"id" json:"id" orm:"pk;column(id)"` + Path string `structs:"path" json:"path"` + Title string `structs:"title" json:"title"` + Album string `structs:"album" json:"album"` + ArtistID string `structs:"artist_id" json:"artistId" orm:"pk;column(artist_id)"` + Artist string `structs:"artist" json:"artist"` + AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"pk;column(album_artist_id)"` + AlbumArtist string `structs:"album_artist" json:"albumArtist"` + AlbumID string `structs:"album_id" json:"albumId" orm:"pk;column(album_id)"` + HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` + TrackNumber int `structs:"track_number" json:"trackNumber"` + DiscNumber int `structs:"disc_number" json:"discNumber"` + DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` + Year int `structs:"year" json:"year"` + Size int64 `structs:"size" json:"size"` + Suffix string `structs:"suffix" json:"suffix"` + Duration float32 `structs:"duration" json:"duration"` + BitRate int `structs:"bit_rate" json:"bitRate"` + Channels int `structs:"channels" json:"channels"` + Genre string `structs:"genre" json:"genre"` + Genres Genres `structs:"-" json:"genres"` + FullText string `structs:"full_text" json:"fullText"` + SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` + SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` + SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` + SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` + OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` + OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` + OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` + OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` + Compilation bool `structs:"compilation" json:"compilation"` + Comment string `structs:"comment" json:"comment,omitempty"` + Lyrics string `structs:"lyrics" json:"lyrics,omitempty"` + Bpm int `structs:"bpm" json:"bpm,omitempty"` + CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` + MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"` + MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty" orm:"column(mbz_release_track_id)"` + MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"` + MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"` + MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"` + MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` + RGAlbumGain float64 `structs:"rg_album_gain" json:"rgAlbumGain" orm:"column(rg_album_gain)"` + RGAlbumPeak float64 `structs:"rg_album_peak" json:"rgAlbumPeak" orm:"column(rg_album_peak)"` + RGTrackGain float64 `structs:"rg_track_gain" json:"rgTrackGain" orm:"column(rg_track_gain)"` + RGTrackPeak float64 `structs:"rg_track_peak" json:"rgTrackPeak" orm:"column(rg_track_peak)"` + + CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Time this entry was created in the DB + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime) } func (mf MediaFile) ContentType() string { diff --git a/scanner/mapping.go b/scanner/mapping.go index 79367f1d1..eab71100f 100644 --- a/scanner/mapping.go +++ b/scanner/mapping.go @@ -75,6 +75,13 @@ func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile { mf.CreatedAt = time.Now() mf.UpdatedAt = md.ModificationTime() + if conf.Server.EnableReplayGain { + mf.RGAlbumGain = md.RGAlbumGain() + mf.RGAlbumPeak = md.RGAlbumPeak() + mf.RGTrackGain = md.RGTrackGain() + mf.RGTrackPeak = md.RGTrackPeak() + } + return *mf } diff --git a/scanner/metadata/ffmpeg/ffmpeg_test.go b/scanner/metadata/ffmpeg/ffmpeg_test.go index e4bc96f06..67a3b2b4a 100644 --- a/scanner/metadata/ffmpeg/ffmpeg_test.go +++ b/scanner/metadata/ffmpeg/ffmpeg_test.go @@ -302,4 +302,23 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu md, _ := e.extractMetadata("tests/fixtures/test.ogg", output) Expect(md).To(HaveKeyWithValue("fbpm", []string{"141.7"})) }) + + It("parses replaygain data correctly", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + REPLAYGAIN_ALBUM_PEAK: 0.9125 + REPLAYGAIN_TRACK_PEAK: 0.4512 + REPLAYGAIN_TRACK_GAIN: -1.48 dB + REPLAYGAIN_ALBUM_GAIN: +3.21518 dB + Side data: + replaygain: track gain - -1.480000, track peak - 0.000011, album gain - 3.215180, album peak - 0.000021, + ` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"})) + Expect(md).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"})) + Expect(md).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"})) + Expect(md).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"})) + + }) }) diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 1eecba086..29898ce3e 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -143,6 +143,39 @@ func (t Tags) Size() int64 { return t.fileInfo.Size() } func (t Tags) FilePath() string { return t.filePath } func (t Tags) Suffix() string { return strings.ToLower(strings.TrimPrefix(path.Ext(t.filePath), ".")) } +// Replaygain Properties +func (t Tags) RGAlbumGain() float64 { return t.getGainValue("replaygain_album_gain") } +func (t Tags) RGAlbumPeak() float64 { return t.getPeakValue("replaygain_album_peak") } +func (t Tags) RGTrackGain() float64 { return t.getGainValue("replaygain_track_gain") } +func (t Tags) RGTrackPeak() float64 { return t.getPeakValue("replaygain_track_peak") } + +func (t Tags) getGainValue(tagName string) float64 { + // Gain is in the form [-]a.bb dB + var tag = t.getFirstTagValue(tagName) + + if tag == "" { + return 0 + } + + tag = strings.TrimSpace(strings.Replace(tag, "dB", "", 1)) + + var value, err = strconv.ParseFloat(tag, 64) + if err != nil { + return 0 + } + return value +} + +func (t Tags) getPeakValue(tagName string) float64 { + var tag = t.getFirstTagValue(tagName) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil { + // A default of 1 for peak value resulds in no changes + return 1 + } + return value +} + func (t Tags) getTags(tagNames ...string) []string { for _, tag := range tagNames { if v, ok := t.tags[tag]; ok { diff --git a/scanner/metadata/metadata_test.go b/scanner/metadata/metadata_test.go index 06165400b..07ad83957 100644 --- a/scanner/metadata/metadata_test.go +++ b/scanner/metadata/metadata_test.go @@ -41,6 +41,10 @@ var _ = Describe("Tags", func() { Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3")) Expect(m.Suffix()).To(Equal("mp3")) Expect(m.Size()).To(Equal(int64(51876))) + Expect(m.RGAlbumGain()).To(Equal(3.21518)) + Expect(m.RGAlbumPeak()).To(Equal(0.9125)) + Expect(m.RGTrackGain()).To(Equal(-1.48)) + Expect(m.RGTrackPeak()).To(Equal(0.4512)) m = mds["tests/fixtures/test.ogg"] Expect(err).To(BeNil()) diff --git a/server/serve_index.go b/server/serve_index.go index f73cb414a..5cb20d616 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -52,6 +52,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { "lastFMApiKey": conf.Server.LastFM.ApiKey, "devShowArtistPage": conf.Server.DevShowArtistPage, "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, + "enableReplayGain": conf.Server.EnableReplayGain, } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { appConfig["loginBackgroundURL"] = path.Join(conf.Server.BaseURL, conf.Server.UILoginBackgroundURL) diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 7defea659..1ccdd452d 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -289,6 +289,17 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("listenBrainzEnabled", true)) }) + It("sets the enableReplayGain", func() { + conf.Server.EnableReplayGain = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableReplayGain", true)) + }) + Describe("loginBackgroundURL", func() { Context("empty BaseURL", func() { BeforeEach(func() { diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 index 6f7c494c9..49518e34f 100644 Binary files a/tests/fixtures/test.mp3 and b/tests/fixtures/test.mp3 differ diff --git a/ui/src/App.js b/ui/src/App.js index bcf5894bc..5b94ae313 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -26,6 +26,7 @@ import { albumViewReducer, activityReducer, settingsReducer, + replayGainReducer, downloadMenuDialogReducer, } from './reducers' import createAdminStore from './store/createAdminStore' @@ -59,6 +60,7 @@ const adminStore = createAdminStore({ listenBrainzTokenDialog: listenBrainzTokenDialogReducer, activity: activityReducer, settings: settingsReducer, + replayGain: replayGainReducer, }, }) diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index 206ac0fb3..a319f7a69 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -2,5 +2,6 @@ export * from './player' export * from './themes' export * from './albumView' export * from './dialogs' +export * from './replayGain' export * from './serverEvents' export * from './settings' diff --git a/ui/src/actions/replayGain.js b/ui/src/actions/replayGain.js new file mode 100644 index 000000000..a41af8dcc --- /dev/null +++ b/ui/src/actions/replayGain.js @@ -0,0 +1,12 @@ +export const CHANGE_GAIN = 'CHANGE_GAIN' +export const CHANGE_PREAMP = 'CHANGE_PREAMP' + +export const changeGain = (gain) => ({ + type: CHANGE_GAIN, + payload: gain, +}) + +export const changePreamp = (preamp) => ({ + type: CHANGE_PREAMP, + payload: preamp, +}) diff --git a/ui/src/audioplayer/AudioTitle.js b/ui/src/audioplayer/AudioTitle.js index b8ba77a8d..89d046db6 100644 --- a/ui/src/audioplayer/AudioTitle.js +++ b/ui/src/audioplayer/AudioTitle.js @@ -5,7 +5,7 @@ import clsx from 'clsx' import { QualityInfo } from '../common' import useStyle from './styles' -const AudioTitle = React.memo(({ audioInfo, isMobile }) => { +const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { const classes = useStyle() const className = classes.audioTitle const isDesktop = useMediaQuery('(min-width:810px)') @@ -15,7 +15,12 @@ const AudioTitle = React.memo(({ audioInfo, isMobile }) => { } const song = audioInfo.song - const qi = { suffix: song.suffix, bitRate: song.bitRate } + const qi = { + suffix: song.suffix, + bitRate: song.bitRate, + albumGain: song.rgAlbumGain, + trackGain: song.rgTrackGain, + } return ( { {song.title} {isDesktop && ( - + )} {isMobile ? ( diff --git a/ui/src/audioplayer/Player.js b/ui/src/audioplayer/Player.js index 7f4314516..245b099bf 100644 --- a/ui/src/audioplayer/Player.js +++ b/ui/src/audioplayer/Player.js @@ -24,6 +24,16 @@ import locale from './locale' import { keyMap } from '../hotkeys' import keyHandlers from './keyHandlers' +function calculateReplayGain(preAmp, gain, peak) { + if (gain === undefined || peak === undefined) { + return 1 + } + + // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19 + // Normalized to max gain + return Math.min(10 ** ((gain + preAmp) / 20), 1 / peak) +} + const Player = () => { const theme = useCurrentTheme() const translate = useTranslate() @@ -50,6 +60,70 @@ const Player = () => { const showNotifications = useSelector( (state) => state.settings.notifications || false ) + const gainInfo = useSelector((state) => state.replayGain) + const [context, setContext] = useState(null) + const [gainNode, setGainNode] = useState(null) + + useEffect(() => { + if ( + context === null && + audioInstance && + config.enableReplayGain && + 'AudioContext' in window + ) { + const ctx = new AudioContext() + // we need this to support radios in firefox + audioInstance.crossOrigin = 'anonymous' + const source = ctx.createMediaElementSource(audioInstance) + const gain = ctx.createGain() + + source.connect(gain) + gain.connect(ctx.destination) + + setContext(ctx) + setGainNode(gain) + } + }, [audioInstance, context]) + + useEffect(() => { + if (gainNode) { + const current = playerState.current || {} + const song = current.song || {} + + let numericGain + + switch (gainInfo.gainMode) { + case 'album': { + numericGain = calculateReplayGain( + gainInfo.preAmp, + song.rgAlbumGain, + song.rgAlbumPeak + ) + break + } + case 'track': { + numericGain = calculateReplayGain( + gainInfo.preAmp, + song.rgTrackGain, + song.rgTrackPeak + ) + break + } + default: { + numericGain = 1 + } + } + + gainNode.gain.setValueAtTime(numericGain, context.currentTime) + } + }, [ + audioInstance, + context, + gainNode, + gainInfo.gainMode, + gainInfo.preAmp, + playerState, + ]) const defaultOptions = useMemo( () => ({ @@ -75,11 +149,15 @@ const Player = () => { }, volumeFade: { fadeIn: 200, fadeOut: 200 }, renderAudioTitle: (audioInfo, isMobile) => ( - + ), locale: locale(translate), }), - [isDesktop, playerTheme, translate] + [gainInfo, isDesktop, playerTheme, translate] ) const options = useMemo(() => { @@ -151,6 +229,12 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { + // Do this to start the context; on chrome-based browsers, the context + // will start paused since it is created prior to user interaction + if (context && context.state !== 'running') { + context.resume() + } + dispatch(currentPlaying(info)) if (startTime === null) { setStartTime(Date.now()) @@ -178,7 +262,7 @@ const Player = () => { } } }, - [dispatch, showNotifications, startTime] + [context, dispatch, showNotifications, startTime] ) const onAudioPlayTrackChange = useCallback(() => { diff --git a/ui/src/audioplayer/PlayerToolbar.js b/ui/src/audioplayer/PlayerToolbar.js index c44a93097..d7394a6bc 100644 --- a/ui/src/audioplayer/PlayerToolbar.js +++ b/ui/src/audioplayer/PlayerToolbar.js @@ -5,8 +5,13 @@ import { LoveButton, useToggleLove } from '../common' import { keyMap } from '../hotkeys' import config from '../config' -const Placeholder = () => - config.enableFavourites && +const Placeholder = () => ( + <> + {config.enableFavourites && ( + + )} + +) const Toolbar = ({ id }) => { const { data, loading } = useGetOne('song', id) @@ -15,6 +20,7 @@ const Toolbar = ({ id }) => { const handlers = { TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]), } + return ( <> diff --git a/ui/src/common/QualityInfo.js b/ui/src/common/QualityInfo.js index fade12fa5..7afe64798 100644 --- a/ui/src/common/QualityInfo.js +++ b/ui/src/common/QualityInfo.js @@ -19,7 +19,7 @@ const useStyle = makeStyles( } ) -export const QualityInfo = ({ record, size, className }) => { +export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => { const classes = useStyle() let { suffix, bitRate } = record let info = placeholder @@ -32,6 +32,12 @@ export const QualityInfo = ({ record, size, className }) => { } } + if (gainMode !== 'none') { + info += ` (${ + (gainMode === 'album' ? record.albumGain : record.trackGain) + preAmp + } dB)` + } + return ( (props.gain ? " ' db'" : ''), + }, + }, tableCell: { width: '17.5%', }, }) export const SongInfo = (props) => { - const classes = useStyles() + const classes = useStyles({ gain: config.enableReplayGain }) const translate = useTranslate() const record = useRecordContext(props) const data = { @@ -54,6 +60,15 @@ export const SongInfo = (props) => { data.playDate = } + if (config.enableReplayGain) { + data.albumGain = ( + + ) + data.trackGain = ( + + ) + } + return ( diff --git a/ui/src/config.js b/ui/src/config.js index 1514d90c3..5bf938aef 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -27,6 +27,7 @@ const defaultConfig = { listenBrainzEnabled: true, enableCoverAnimation: true, devShowArtistPage: true, + enableReplayGain: true, } let config diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 5946a5aea..6798c5367 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -363,7 +363,14 @@ "defaultView": "Default View", "desktop_notifications": "Desktop Notifications", "lastfmScrobbling": "Scrobble to Last.fm", - "listenBrainzScrobbling": "Scrobble to ListenBrainz" + "listenBrainzScrobbling": "Scrobble to ListenBrainz", + "replaygain": "ReplayGain Mode", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Disabled", + "album": "Use Album Gain", + "track": "Use Track Gain" + } } }, "albumList": "Albums", @@ -395,6 +402,7 @@ "singleLoop": "Repeat One", "shufflePlay": "Shuffle" } + }, "about": { "links": { @@ -425,4 +433,4 @@ "toggle_love": "Add this track to favourites" } } -} +} \ No newline at end of file diff --git a/ui/src/personal/Personal.js b/ui/src/personal/Personal.js index 86cea55c5..84f9b63e6 100644 --- a/ui/src/personal/Personal.js +++ b/ui/src/personal/Personal.js @@ -8,6 +8,7 @@ import { NotificationsToggle } from './NotificationsToggle' import { LastfmScrobbleToggle } from './LastfmScrobbleToggle' import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle' import config from '../config' +import { ReplayGainToggle } from './ReplayGainToggle' const useStyles = makeStyles({ root: { marginTop: '1em' }, @@ -24,6 +25,7 @@ const Personal = () => { + {config.enableReplayGain && } {config.lastFMEnabled && } {config.listenBrainzEnabled && } diff --git a/ui/src/personal/ReplayGainToggle.js b/ui/src/personal/ReplayGainToggle.js new file mode 100644 index 000000000..a0921e773 --- /dev/null +++ b/ui/src/personal/ReplayGainToggle.js @@ -0,0 +1,44 @@ +import { NumberInput, SelectInput, useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { changeGain, changePreamp } from '../actions' + +export const ReplayGainToggle = (props) => { + const translate = useTranslate() + const dispatch = useDispatch() + const gainInfo = useSelector((state) => state.replayGain) + + return ( + <> + { + dispatch(changeGain(event.target.value)) + }} + /> +
+ {gainInfo.gainMode !== 'none' && ( + { + dispatch(changePreamp(event.target.value)) + }} + /> + )} + + ) +} diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index 4e3f5dd06..b9414c864 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -4,3 +4,4 @@ export * from './playerReducer' export * from './albumView' export * from './activityReducer' export * from './settingsReducer' +export * from './replayGainReducer' diff --git a/ui/src/reducers/replayGainReducer.js b/ui/src/reducers/replayGainReducer.js new file mode 100644 index 000000000..49d9e0ba0 --- /dev/null +++ b/ui/src/reducers/replayGainReducer.js @@ -0,0 +1,45 @@ +import { CHANGE_GAIN, CHANGE_PREAMP } from '../actions' + +const getPreAmp = () => { + const storage = localStorage.getItem('preamp') + + if (storage === null) { + return 0 + } else { + const asFloat = parseFloat(storage) + return isNaN(asFloat) ? 0 : asFloat + } +} + +const initialState = { + gainMode: localStorage.getItem('gainMode') || 'none', + preAmp: getPreAmp(), +} + +export const replayGainReducer = ( + previousState = initialState, + { type, payload } +) => { + switch (type) { + case CHANGE_GAIN: { + localStorage.setItem('gainMode', payload) + return { + ...previousState, + gainMode: payload, + } + } + case CHANGE_PREAMP: { + const value = parseFloat(payload) + if (isNaN(value)) { + return previousState + } + localStorage.setItem('preAmp', payload) + return { + ...previousState, + preAmp: value, + } + } + default: + return previousState + } +}