github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/client/files/gains.go (about) 1 // Copyright 2022 Daniel Erat. 2 // All rights reserved. 3 4 package files 5 6 import ( 7 "encoding/json" 8 "fmt" 9 "io" 10 "log" 11 "os" 12 "path/filepath" 13 "runtime" 14 15 "github.com/derat/nup/cmd/nup/client" 16 "github.com/derat/nup/cmd/nup/mp3gain" 17 "github.com/derat/nup/server/db" 18 ) 19 20 // GainsCache is passed to ReadSong to compute gain adjustments for MP3 files. 21 // 22 // Gain adjustments need to be computed across entire albums, so adjustments are cached 23 // so they won't need to be computed multiple times. 24 type GainsCache struct { 25 cfg *client.Config 26 dumped map[string]mp3gain.Info // read from dumped db.Songs 27 cache *client.TaskCache // computes and stores gain adjustments 28 } 29 30 // NewGainsCache returns a new GainsCache. 31 // 32 // If dumpPath is non-empty, db.Song objects are JSON-unmarshaled from it to initialize the cache 33 // with previously-computed gain adjustments. 34 func NewGainsCache(cfg *client.Config, dumpPath string) (*GainsCache, error) { 35 // mp3gain doesn't seem to take advantage of multiple cores, so run multiple copies in parallel: 36 // https://hydrogenaud.io/index.php?topic=72197.0 37 // https://sound.stackexchange.com/questions/33069/multi-core-batch-volume-gain 38 gc := GainsCache{ 39 cfg: cfg, 40 cache: client.NewTaskCache(runtime.NumCPU()), 41 } 42 43 if dumpPath != "" { 44 gc.dumped = make(map[string]mp3gain.Info) 45 46 f, err := os.Open(dumpPath) 47 if err != nil { 48 return nil, err 49 } 50 defer f.Close() 51 52 d := json.NewDecoder(f) 53 for { 54 var s db.Song 55 if err := d.Decode(&s); err == io.EOF { 56 break 57 } else if err != nil { 58 return nil, err 59 } 60 if s.TrackGain == 0 && s.AlbumGain == 0 && s.PeakAmp == 0 { 61 return nil, fmt.Errorf("missing gain info for %q", s.Filename) 62 } 63 gc.dumped[filepath.Join(cfg.MusicDir, s.Filename)] = mp3gain.Info{ 64 TrackGain: s.TrackGain, 65 AlbumGain: s.AlbumGain, 66 PeakAmp: s.PeakAmp, 67 } 68 } 69 } 70 71 return &gc, nil 72 } 73 74 // get returns gain adjustments for the file at p, computing them if needed. 75 // 76 // album and albumID correspond to p and are used to process additional 77 // songs from the same album in the directory. 78 func (gc *GainsCache) get(p, album, albumID string) (mp3gain.Info, error) { 79 // If we already loaded this file's adjustments from a dump, use them. 80 if info, ok := gc.dumped[p]; ok { 81 return info, nil 82 } 83 84 // If the requested song was part of an album, we also need to process all of the other 85 // songs in the album in order to compute gain adjustments relative to the entire album. 86 // The task key here is arbitrary but needs to be the same for all files in the album. 87 dir := filepath.Dir(p) 88 hasAlbum := (albumID != "" || album != "") && album != NonAlbumTracksValue 89 var key string 90 if hasAlbum { 91 key = fmt.Sprintf("%q %q %q", dir, album, albumID) 92 } else { 93 key = fmt.Sprintf("%q", p) 94 } 95 96 // Request the adjustments from the TaskCache. The supplied task will only be run 97 // if the adjustments aren't already available and there isn't already another task 98 // with the same key. 99 info, err := gc.cache.Get(p, key, func() (map[string]interface{}, error) { 100 var paths []string 101 if hasAlbum { 102 entries, err := os.ReadDir(dir) 103 if err != nil { 104 return nil, err 105 } 106 for _, entry := range entries { 107 p := filepath.Join(dir, entry.Name()) 108 if !IsMusicPath(p) || !entry.Type().IsRegular() { 109 continue 110 } 111 // TODO: Consider caching tags somewhere since we're also reading them in the 112 // original readSong call. In practice, computing gains is so incredibly slow (at 113 // least on my computer) that reading tags twice probably doesn't matter in the big 114 // scheme of things. 115 // I'm ignoring errors here since it's weird if we fail to add a new song because 116 // some other song in the same directory is broken. 117 s, err := ReadSong(gc.cfg, p, nil, SkipAudioData, nil) 118 if err == nil && s.Album == album && s.AlbumID == albumID { 119 paths = append(paths, p) 120 } 121 } 122 } else { 123 paths = []string{p} 124 } 125 if len(paths) == 1 { 126 log.Printf("Computing gain adjustments for %v", paths[0]) 127 } else { 128 log.Printf("Computing gain adjustments for %d songs in %v", len(paths), dir) 129 } 130 131 infos, err := mp3gain.ComputeAlbum(paths) 132 if err != nil { 133 return nil, err 134 } 135 res := make(map[string]interface{}, len(infos)) 136 for p, info := range infos { 137 res[p] = info 138 } 139 return res, nil 140 }) 141 142 if err != nil { 143 return mp3gain.Info{}, err 144 } 145 return info.(mp3gain.Info), nil 146 }