mirror of
https://github.com/navidrome/navidrome.git
synced 2025-06-12 13:23:05 +03:00
ReplayGain support + audio normalization (web player) (#1988)
* ReplayGain support - extract ReplayGain tags from files, expose via native api - use metadata to normalize audio in web player * make pre-push happy * remove unnecessary prints * remove another unnecessary print * add tooltips, see metadata * address comments, use settings instead * remove console.log * use better language for gain modes
This commit is contained in:
parent
9ae156dd82
commit
1324a16fc5
@ -63,6 +63,7 @@ type configOptions struct {
|
|||||||
ReverseProxyUserHeader string
|
ReverseProxyUserHeader string
|
||||||
ReverseProxyWhitelist string
|
ReverseProxyWhitelist string
|
||||||
Prometheus prometheusOptions
|
Prometheus prometheusOptions
|
||||||
|
EnableReplayGain bool
|
||||||
|
|
||||||
Scanner scannerOptions
|
Scanner scannerOptions
|
||||||
|
|
||||||
@ -265,6 +266,8 @@ func init() {
|
|||||||
viper.SetDefault("prometheus.enabled", false)
|
viper.SetDefault("prometheus.enabled", false)
|
||||||
viper.SetDefault("prometheus.metricspath", "/metrics")
|
viper.SetDefault("prometheus.metricspath", "/metrics")
|
||||||
|
|
||||||
|
viper.SetDefault("enablereplaygain", false)
|
||||||
|
|
||||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||||
viper.SetDefault("scanner.genreseparators", ";/,")
|
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||||
|
|
||||||
|
34
db/migration/20230117155559_add_replaygain_metadata.go
Normal file
34
db/migration/20230117155559_add_replaygain_metadata.go
Normal file
@ -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
|
||||||
|
}
|
@ -18,50 +18,55 @@ type MediaFile struct {
|
|||||||
Annotations `structs:"-"`
|
Annotations `structs:"-"`
|
||||||
Bookmarkable `structs:"-"`
|
Bookmarkable `structs:"-"`
|
||||||
|
|
||||||
ID string `structs:"id" json:"id" orm:"pk;column(id)"`
|
ID string `structs:"id" json:"id" orm:"pk;column(id)"`
|
||||||
Path string `structs:"path" json:"path"`
|
Path string `structs:"path" json:"path"`
|
||||||
Title string `structs:"title" json:"title"`
|
Title string `structs:"title" json:"title"`
|
||||||
Album string `structs:"album" json:"album"`
|
Album string `structs:"album" json:"album"`
|
||||||
ArtistID string `structs:"artist_id" json:"artistId" orm:"pk;column(artist_id)"`
|
ArtistID string `structs:"artist_id" json:"artistId" orm:"pk;column(artist_id)"`
|
||||||
Artist string `structs:"artist" json:"artist"`
|
Artist string `structs:"artist" json:"artist"`
|
||||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"pk;column(album_artist_id)"`
|
||||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||||
AlbumID string `structs:"album_id" json:"albumId" orm:"pk;column(album_id)"`
|
AlbumID string `structs:"album_id" json:"albumId" orm:"pk;column(album_id)"`
|
||||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||||
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
||||||
DiscNumber int `structs:"disc_number" json:"discNumber"`
|
DiscNumber int `structs:"disc_number" json:"discNumber"`
|
||||||
DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"`
|
DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"`
|
||||||
Year int `structs:"year" json:"year"`
|
Year int `structs:"year" json:"year"`
|
||||||
Size int64 `structs:"size" json:"size"`
|
Size int64 `structs:"size" json:"size"`
|
||||||
Suffix string `structs:"suffix" json:"suffix"`
|
Suffix string `structs:"suffix" json:"suffix"`
|
||||||
Duration float32 `structs:"duration" json:"duration"`
|
Duration float32 `structs:"duration" json:"duration"`
|
||||||
BitRate int `structs:"bit_rate" json:"bitRate"`
|
BitRate int `structs:"bit_rate" json:"bitRate"`
|
||||||
Channels int `structs:"channels" json:"channels"`
|
Channels int `structs:"channels" json:"channels"`
|
||||||
Genre string `structs:"genre" json:"genre"`
|
Genre string `structs:"genre" json:"genre"`
|
||||||
Genres Genres `structs:"-" json:"genres"`
|
Genres Genres `structs:"-" json:"genres"`
|
||||||
FullText string `structs:"full_text" json:"fullText"`
|
FullText string `structs:"full_text" json:"fullText"`
|
||||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||||
OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
|
OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
|
||||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||||
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
|
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
|
||||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||||
Compilation bool `structs:"compilation" json:"compilation"`
|
Compilation bool `structs:"compilation" json:"compilation"`
|
||||||
Comment string `structs:"comment" json:"comment,omitempty"`
|
Comment string `structs:"comment" json:"comment,omitempty"`
|
||||||
Lyrics string `structs:"lyrics" json:"lyrics,omitempty"`
|
Lyrics string `structs:"lyrics" json:"lyrics,omitempty"`
|
||||||
Bpm int `structs:"bpm" json:"bpm,omitempty"`
|
Bpm int `structs:"bpm" json:"bpm,omitempty"`
|
||||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||||
MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"`
|
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)"`
|
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)"`
|
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)"`
|
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)"`
|
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"`
|
MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
|
||||||
MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,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
|
RGAlbumGain float64 `structs:"rg_album_gain" json:"rgAlbumGain" orm:"column(rg_album_gain)"`
|
||||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime)
|
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 {
|
func (mf MediaFile) ContentType() string {
|
||||||
|
@ -75,6 +75,13 @@ func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile {
|
|||||||
mf.CreatedAt = time.Now()
|
mf.CreatedAt = time.Now()
|
||||||
mf.UpdatedAt = md.ModificationTime()
|
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
|
return *mf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
md, _ := e.extractMetadata("tests/fixtures/test.ogg", output)
|
||||||
Expect(md).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
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"}))
|
||||||
|
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -143,6 +143,39 @@ func (t Tags) Size() int64 { return t.fileInfo.Size() }
|
|||||||
func (t Tags) FilePath() string { return t.filePath }
|
func (t Tags) FilePath() string { return t.filePath }
|
||||||
func (t Tags) Suffix() string { return strings.ToLower(strings.TrimPrefix(path.Ext(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 {
|
func (t Tags) getTags(tagNames ...string) []string {
|
||||||
for _, tag := range tagNames {
|
for _, tag := range tagNames {
|
||||||
if v, ok := t.tags[tag]; ok {
|
if v, ok := t.tags[tag]; ok {
|
||||||
|
@ -41,6 +41,10 @@ var _ = Describe("Tags", func() {
|
|||||||
Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3"))
|
Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3"))
|
||||||
Expect(m.Suffix()).To(Equal("mp3"))
|
Expect(m.Suffix()).To(Equal("mp3"))
|
||||||
Expect(m.Size()).To(Equal(int64(51876)))
|
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"]
|
m = mds["tests/fixtures/test.ogg"]
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
|
@ -52,6 +52,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
|||||||
"lastFMApiKey": conf.Server.LastFM.ApiKey,
|
"lastFMApiKey": conf.Server.LastFM.ApiKey,
|
||||||
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
||||||
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
|
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
|
||||||
|
"enableReplayGain": conf.Server.EnableReplayGain,
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||||
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BaseURL, conf.Server.UILoginBackgroundURL)
|
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BaseURL, conf.Server.UILoginBackgroundURL)
|
||||||
|
@ -289,6 +289,17 @@ var _ = Describe("serveIndex", func() {
|
|||||||
Expect(config).To(HaveKeyWithValue("listenBrainzEnabled", true))
|
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() {
|
Describe("loginBackgroundURL", func() {
|
||||||
Context("empty BaseURL", func() {
|
Context("empty BaseURL", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
BIN
tests/fixtures/test.mp3
vendored
BIN
tests/fixtures/test.mp3
vendored
Binary file not shown.
@ -26,6 +26,7 @@ import {
|
|||||||
albumViewReducer,
|
albumViewReducer,
|
||||||
activityReducer,
|
activityReducer,
|
||||||
settingsReducer,
|
settingsReducer,
|
||||||
|
replayGainReducer,
|
||||||
downloadMenuDialogReducer,
|
downloadMenuDialogReducer,
|
||||||
} from './reducers'
|
} from './reducers'
|
||||||
import createAdminStore from './store/createAdminStore'
|
import createAdminStore from './store/createAdminStore'
|
||||||
@ -59,6 +60,7 @@ const adminStore = createAdminStore({
|
|||||||
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
||||||
activity: activityReducer,
|
activity: activityReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
|
replayGain: replayGainReducer,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -2,5 +2,6 @@ export * from './player'
|
|||||||
export * from './themes'
|
export * from './themes'
|
||||||
export * from './albumView'
|
export * from './albumView'
|
||||||
export * from './dialogs'
|
export * from './dialogs'
|
||||||
|
export * from './replayGain'
|
||||||
export * from './serverEvents'
|
export * from './serverEvents'
|
||||||
export * from './settings'
|
export * from './settings'
|
||||||
|
12
ui/src/actions/replayGain.js
Normal file
12
ui/src/actions/replayGain.js
Normal file
@ -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,
|
||||||
|
})
|
@ -5,7 +5,7 @@ import clsx from 'clsx'
|
|||||||
import { QualityInfo } from '../common'
|
import { QualityInfo } from '../common'
|
||||||
import useStyle from './styles'
|
import useStyle from './styles'
|
||||||
|
|
||||||
const AudioTitle = React.memo(({ audioInfo, isMobile }) => {
|
const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
|
||||||
const classes = useStyle()
|
const classes = useStyle()
|
||||||
const className = classes.audioTitle
|
const className = classes.audioTitle
|
||||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||||
@ -15,7 +15,12 @@ const AudioTitle = React.memo(({ audioInfo, isMobile }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const song = audioInfo.song
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -31,7 +36,11 @@ const AudioTitle = React.memo(({ audioInfo, isMobile }) => {
|
|||||||
{song.title}
|
{song.title}
|
||||||
</span>
|
</span>
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<QualityInfo record={qi} className={classes.qualityInfo} />
|
<QualityInfo
|
||||||
|
record={qi}
|
||||||
|
className={classes.qualityInfo}
|
||||||
|
{...gainInfo}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
|
@ -24,6 +24,16 @@ import locale from './locale'
|
|||||||
import { keyMap } from '../hotkeys'
|
import { keyMap } from '../hotkeys'
|
||||||
import keyHandlers from './keyHandlers'
|
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 Player = () => {
|
||||||
const theme = useCurrentTheme()
|
const theme = useCurrentTheme()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
@ -50,6 +60,70 @@ const Player = () => {
|
|||||||
const showNotifications = useSelector(
|
const showNotifications = useSelector(
|
||||||
(state) => state.settings.notifications || false
|
(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(
|
const defaultOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -75,11 +149,15 @@ const Player = () => {
|
|||||||
},
|
},
|
||||||
volumeFade: { fadeIn: 200, fadeOut: 200 },
|
volumeFade: { fadeIn: 200, fadeOut: 200 },
|
||||||
renderAudioTitle: (audioInfo, isMobile) => (
|
renderAudioTitle: (audioInfo, isMobile) => (
|
||||||
<AudioTitle audioInfo={audioInfo} isMobile={isMobile} />
|
<AudioTitle
|
||||||
|
audioInfo={audioInfo}
|
||||||
|
gainInfo={gainInfo}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
locale: locale(translate),
|
locale: locale(translate),
|
||||||
}),
|
}),
|
||||||
[isDesktop, playerTheme, translate]
|
[gainInfo, isDesktop, playerTheme, translate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
@ -151,6 +229,12 @@ const Player = () => {
|
|||||||
|
|
||||||
const onAudioPlay = useCallback(
|
const onAudioPlay = useCallback(
|
||||||
(info) => {
|
(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))
|
dispatch(currentPlaying(info))
|
||||||
if (startTime === null) {
|
if (startTime === null) {
|
||||||
setStartTime(Date.now())
|
setStartTime(Date.now())
|
||||||
@ -178,7 +262,7 @@ const Player = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, showNotifications, startTime]
|
[context, dispatch, showNotifications, startTime]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAudioPlayTrackChange = useCallback(() => {
|
const onAudioPlayTrackChange = useCallback(() => {
|
||||||
|
@ -5,8 +5,13 @@ import { LoveButton, useToggleLove } from '../common'
|
|||||||
import { keyMap } from '../hotkeys'
|
import { keyMap } from '../hotkeys'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
|
||||||
const Placeholder = () =>
|
const Placeholder = () => (
|
||||||
config.enableFavourites && <LoveButton disabled={true} resource={'song'} />
|
<>
|
||||||
|
{config.enableFavourites && (
|
||||||
|
<LoveButton disabled={true} resource={'song'} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
const Toolbar = ({ id }) => {
|
const Toolbar = ({ id }) => {
|
||||||
const { data, loading } = useGetOne('song', id)
|
const { data, loading } = useGetOne('song', id)
|
||||||
@ -15,6 +20,7 @@ const Toolbar = ({ id }) => {
|
|||||||
const handlers = {
|
const handlers = {
|
||||||
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
|
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||||
|
@ -19,7 +19,7 @@ const useStyle = makeStyles(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const QualityInfo = ({ record, size, className }) => {
|
export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => {
|
||||||
const classes = useStyle()
|
const classes = useStyle()
|
||||||
let { suffix, bitRate } = record
|
let { suffix, bitRate } = record
|
||||||
let info = placeholder
|
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 (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
className={clsx(classes.chip, className)}
|
className={clsx(classes.chip, className)}
|
||||||
@ -46,9 +52,11 @@ QualityInfo.propTypes = {
|
|||||||
record: PropTypes.object.isRequired,
|
record: PropTypes.object.isRequired,
|
||||||
size: PropTypes.string,
|
size: PropTypes.string,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
|
gainMode: PropTypes.string,
|
||||||
}
|
}
|
||||||
|
|
||||||
QualityInfo.defaultProps = {
|
QualityInfo.defaultProps = {
|
||||||
record: {},
|
record: {},
|
||||||
size: 'small',
|
size: 'small',
|
||||||
|
gainMode: 'none',
|
||||||
}
|
}
|
||||||
|
@ -17,15 +17,21 @@ import inflection from 'inflection'
|
|||||||
import { BitrateField, SizeField } from './index'
|
import { BitrateField, SizeField } from './index'
|
||||||
import { MultiLineTextField } from './MultiLineTextField'
|
import { MultiLineTextField } from './MultiLineTextField'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import config from '../config'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
|
gain: {
|
||||||
|
'&:after': {
|
||||||
|
content: (props) => (props.gain ? " ' db'" : ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
tableCell: {
|
tableCell: {
|
||||||
width: '17.5%',
|
width: '17.5%',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const SongInfo = (props) => {
|
export const SongInfo = (props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles({ gain: config.enableReplayGain })
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const record = useRecordContext(props)
|
const record = useRecordContext(props)
|
||||||
const data = {
|
const data = {
|
||||||
@ -54,6 +60,15 @@ export const SongInfo = (props) => {
|
|||||||
data.playDate = <DateField record={record} source="playDate" showTime />
|
data.playDate = <DateField record={record} source="playDate" showTime />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.enableReplayGain) {
|
||||||
|
data.albumGain = (
|
||||||
|
<NumberField source="rgAlbumGain" className={classes.gain} />
|
||||||
|
)
|
||||||
|
data.trackGain = (
|
||||||
|
<NumberField source="rgTrackGain" className={classes.gain} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table aria-label="song details" size="small">
|
<Table aria-label="song details" size="small">
|
||||||
|
@ -27,6 +27,7 @@ const defaultConfig = {
|
|||||||
listenBrainzEnabled: true,
|
listenBrainzEnabled: true,
|
||||||
enableCoverAnimation: true,
|
enableCoverAnimation: true,
|
||||||
devShowArtistPage: true,
|
devShowArtistPage: true,
|
||||||
|
enableReplayGain: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
let config
|
let config
|
||||||
|
@ -363,7 +363,14 @@
|
|||||||
"defaultView": "Default View",
|
"defaultView": "Default View",
|
||||||
"desktop_notifications": "Desktop Notifications",
|
"desktop_notifications": "Desktop Notifications",
|
||||||
"lastfmScrobbling": "Scrobble to Last.fm",
|
"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",
|
"albumList": "Albums",
|
||||||
@ -395,6 +402,7 @@
|
|||||||
"singleLoop": "Repeat One",
|
"singleLoop": "Repeat One",
|
||||||
"shufflePlay": "Shuffle"
|
"shufflePlay": "Shuffle"
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"links": {
|
"links": {
|
||||||
|
@ -8,6 +8,7 @@ import { NotificationsToggle } from './NotificationsToggle'
|
|||||||
import { LastfmScrobbleToggle } from './LastfmScrobbleToggle'
|
import { LastfmScrobbleToggle } from './LastfmScrobbleToggle'
|
||||||
import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle'
|
import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
import { ReplayGainToggle } from './ReplayGainToggle'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: { marginTop: '1em' },
|
root: { marginTop: '1em' },
|
||||||
@ -24,6 +25,7 @@ const Personal = () => {
|
|||||||
<SelectTheme />
|
<SelectTheme />
|
||||||
<SelectLanguage />
|
<SelectLanguage />
|
||||||
<SelectDefaultView />
|
<SelectDefaultView />
|
||||||
|
{config.enableReplayGain && <ReplayGainToggle />}
|
||||||
<NotificationsToggle />
|
<NotificationsToggle />
|
||||||
{config.lastFMEnabled && <LastfmScrobbleToggle />}
|
{config.lastFMEnabled && <LastfmScrobbleToggle />}
|
||||||
{config.listenBrainzEnabled && <ListenBrainzScrobbleToggle />}
|
{config.listenBrainzEnabled && <ListenBrainzScrobbleToggle />}
|
||||||
|
44
ui/src/personal/ReplayGainToggle.js
Normal file
44
ui/src/personal/ReplayGainToggle.js
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<SelectInput
|
||||||
|
{...props}
|
||||||
|
fullWidth
|
||||||
|
source="replayGain"
|
||||||
|
label={translate('menu.personal.options.replaygain')}
|
||||||
|
choices={[
|
||||||
|
{ id: 'none', name: 'menu.personal.options.gain.none' },
|
||||||
|
{ id: 'track', name: 'menu.personal.options.gain.album' },
|
||||||
|
{ id: 'album', name: 'menu.personal.options.gain.track' },
|
||||||
|
]}
|
||||||
|
defaultValue={gainInfo.gainMode}
|
||||||
|
onChange={(event) => {
|
||||||
|
dispatch(changeGain(event.target.value))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
{gainInfo.gainMode !== 'none' && (
|
||||||
|
<NumberInput
|
||||||
|
{...props}
|
||||||
|
source="preAmp"
|
||||||
|
label={translate('menu.personal.options.preAmp')}
|
||||||
|
defaultValue={gainInfo.preAmp}
|
||||||
|
step={0.5}
|
||||||
|
min={-15}
|
||||||
|
max={15}
|
||||||
|
onChange={(event) => {
|
||||||
|
dispatch(changePreamp(event.target.value))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -4,3 +4,4 @@ export * from './playerReducer'
|
|||||||
export * from './albumView'
|
export * from './albumView'
|
||||||
export * from './activityReducer'
|
export * from './activityReducer'
|
||||||
export * from './settingsReducer'
|
export * from './settingsReducer'
|
||||||
|
export * from './replayGainReducer'
|
||||||
|
45
ui/src/reducers/replayGainReducer.js
Normal file
45
ui/src/reducers/replayGainReducer.js
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user