Skip to content

Commit

Permalink
ReplayGain support + audio normalization (web player) (#1988)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kgarner7 authored and deluan committed Jan 17, 2023
1 parent 9ae156d commit 1324a16
Show file tree
Hide file tree
Showing 24 changed files with 411 additions and 56 deletions.
3 changes: 3 additions & 0 deletions conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type configOptions struct {
ReverseProxyUserHeader string
ReverseProxyWhitelist string
Prometheus prometheusOptions
EnableReplayGain bool

Scanner scannerOptions

Expand Down Expand Up @@ -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", ";/,")

Expand Down
34 changes: 34 additions & 0 deletions db/migration/20230117155559_add_replaygain_metadata.go
Original file line number Diff line number Diff line change
@@ -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
}
93 changes: 49 additions & 44 deletions model/mediafile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions scanner/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
19 changes: 19 additions & 0 deletions scanner/metadata/ffmpeg/ffmpeg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}))

})
})
33 changes: 33 additions & 0 deletions scanner/metadata/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions scanner/metadata/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions server/serve_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions server/serve_index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Binary file modified tests/fixtures/test.mp3
Binary file not shown.
2 changes: 2 additions & 0 deletions ui/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
albumViewReducer,
activityReducer,
settingsReducer,
replayGainReducer,
downloadMenuDialogReducer,
} from './reducers'
import createAdminStore from './store/createAdminStore'
Expand Down Expand Up @@ -59,6 +60,7 @@ const adminStore = createAdminStore({
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
activity: activityReducer,
settings: settingsReducer,
replayGain: replayGainReducer,
},
})

Expand Down
1 change: 1 addition & 0 deletions ui/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 12 additions & 0 deletions ui/src/actions/replayGain.js
Original file line number Diff line number Diff line change
@@ -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,
})
15 changes: 12 additions & 3 deletions ui/src/audioplayer/AudioTitle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)')
Expand All @@ -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 (
<Link
Expand All @@ -31,7 +36,11 @@ const AudioTitle = React.memo(({ audioInfo, isMobile }) => {
{song.title}
</span>
{isDesktop && (
<QualityInfo record={qi} className={classes.qualityInfo} />
<QualityInfo
record={qi}
className={classes.qualityInfo}
{...gainInfo}
/>
)}
</span>
{isMobile ? (
Expand Down

0 comments on commit 1324a16

Please sign in to comment.