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
+ }
+}