github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/mp3gain/gain.go (about)

     1  // Copyright 2021 Daniel Erat.
     2  // All rights reserved.
     3  
     4  // Package mp3gain uses the mp3gain program to analyze MP3s and compute gain adjustments.
     5  package mp3gain
     6  
     7  import (
     8  	"fmt"
     9  	"math"
    10  	"os/exec"
    11  	"strconv"
    12  	"strings"
    13  )
    14  
    15  // Info contains information about gain adjustments for a song.
    16  type Info struct {
    17  	// TrackGain is the track's dB gain adjustment independent of its album.
    18  	TrackGain float64
    19  	// AlbumGain is the album's dB gain adjustment.
    20  	AlbumGain float64
    21  	// PeakAmp is the peak amplitude of the song, with 1.0 being the maximum
    22  	// amplitude playable without clipping.
    23  	PeakAmp float64
    24  }
    25  
    26  // ComputeAlbum uses mp3gain to compute gain adjustments for the
    27  // specified MP3 files, all of which should be from the same album.
    28  // Keys in the returned map are the supplied paths.
    29  func ComputeAlbum(paths []string) (map[string]Info, error) {
    30  	// Return hardcoded data for tests if instructed.
    31  	if infoForTest != nil {
    32  		m := make(map[string]Info)
    33  		for _, p := range paths {
    34  			m[p] = *infoForTest
    35  		}
    36  		return m, nil
    37  	}
    38  
    39  	out, err := exec.Command("mp3gain", append([]string{
    40  		"-o",      // "output is a database-friendly tab-delimited list"
    41  		"-q",      // "quiet mode: no status messages"
    42  		"-s", "s", // "skip (ignore) stored tag info (do not read or write tags)"
    43  	}, paths...)...).Output()
    44  	if err != nil {
    45  		return nil, fmt.Errorf("mp3gain failed: %v", err)
    46  	}
    47  
    48  	m, err := parseMP3GainOutput(string(out))
    49  	if err != nil {
    50  		return nil, fmt.Errorf("bad mp3gain output: %v", err)
    51  	}
    52  	return m, nil
    53  }
    54  
    55  // parseMP3GainOutput parses output from the mp3gain command for computeGains.
    56  func parseMP3GainOutput(out string) (map[string]Info, error) {
    57  	lns := strings.Split(strings.TrimSpace(out), "\n")
    58  	if len(lns) < 3 {
    59  		return nil, fmt.Errorf("output %q not at least 3 lines", out)
    60  	}
    61  
    62  	// The last line contains the album summary.
    63  	p, albumGain, _, err := parseMP3GainLine(lns[len(lns)-1])
    64  	if err != nil {
    65  		return nil, fmt.Errorf("failed parsing %q", lns[len(lns)-1])
    66  	}
    67  	if p != `"Album"` {
    68  		return nil, fmt.Errorf(`expected "Album" for summary %q`, lns[len(lns)-1])
    69  	}
    70  
    71  	// Skip the header and the album summary.
    72  	m := make(map[string]Info)
    73  	for _, ln := range lns[1 : len(lns)-1] {
    74  		p, gain, peakAmp, err := parseMP3GainLine(ln)
    75  		if err != nil {
    76  			return nil, fmt.Errorf("failed parsing %q", ln)
    77  		}
    78  		m[p] = Info{TrackGain: gain, AlbumGain: albumGain, PeakAmp: peakAmp}
    79  	}
    80  	return m, nil
    81  }
    82  
    83  // parseMP3GainLine parses an individual line of output for parseMP3GainOutput.
    84  func parseMP3GainLine(ln string) (path string, gain, peakAmp float64, err error) {
    85  	fields := strings.Split(ln, "\t")
    86  	if len(fields) != 6 {
    87  		return "", 0, 0, fmt.Errorf("got %d field(s); want 6", len(fields))
    88  	}
    89  	// Fields are path, MP3 gain, dB gain, max amplitude, max global_gain, min global_gain.
    90  	if gain, err = strconv.ParseFloat(fields[2], 64); err != nil {
    91  		return "", 0, 0, err
    92  	}
    93  	if peakAmp, err = strconv.ParseFloat(fields[3], 64); err != nil {
    94  		return "", 0, 0, err
    95  	}
    96  	peakAmp /= 32767 // output seems to be based on 16-bit samples
    97  	peakAmp = math.Round(peakAmp*100000) / 100000
    98  
    99  	return fields[0], gain, peakAmp, nil
   100  }
   101  
   102  // infoForTest contains hardcoded gain information to return.
   103  var infoForTest *Info
   104  
   105  // SetInfoForTest sets a hardcoded Info object to use instead of
   106  // actually running the mp3gain program.
   107  func SetInfoForTest(info *Info) {
   108  	infoForTest = info
   109  }