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 }