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  }