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

     1  // Copyright 2022 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package debug
     5  
     6  import (
     7  	"bytes"
     8  	"errors"
     9  	"fmt"
    10  	"os"
    11  	"time"
    12  
    13  	"github.com/derat/mpeg"
    14  	"github.com/derat/taglib-go/taglib"
    15  )
    16  
    17  // mpegInfo contains debugging info about an MP3 file.
    18  type mpegInfo struct {
    19  	size            int64         // entire file
    20  	header          int64         // ID3v2 header size
    21  	footer          int64         // ID3v1 footer size
    22  	sha1            string        // SHA1 of data between header and footer
    23  	vbr             bool          // bitrate is variable (as opposed to constant)
    24  	avgKbitRate     float64       // averaged across audio frames
    25  	sampleRate      int           // from first audio frame
    26  	samplesPerFrame int           // from first audio frame
    27  	xingFrames      int           // number of frames from Xing header
    28  	xingBytes       int64         // audio data size from Xing header
    29  	xingDur         time.Duration // audio duration from Xing header (or guessed as CBR)
    30  	xingEnc         string        // human-readable encoder version and settings
    31  	actualFrames    int           // actual frame count
    32  	actualBytes     int64         // actual audio data size
    33  	actualDur       time.Duration // actual duration
    34  	emptyFrame      int           // first empty frame at end of file
    35  	emptyOffset     int64         // offset of emptyFrame from start of file
    36  	emptyTime       time.Duration // time of emptyFrame
    37  	skipped         []skipInfo    // skipped regions
    38  }
    39  
    40  // skipInfo contains details about an invalid region of an MP3 file that was skipped.
    41  type skipInfo struct {
    42  	offset, size int64 // in bytes
    43  	err          error // first error
    44  }
    45  
    46  // getMPEGInfo returns debug information about the MP3 file at p.
    47  func getMPEGInfo(p string) (*mpegInfo, error) {
    48  	f, err := os.Open(p)
    49  	if err != nil {
    50  		return nil, err
    51  	}
    52  	defer f.Close()
    53  
    54  	fi, err := f.Stat()
    55  	if err != nil {
    56  		return nil, err
    57  	}
    58  
    59  	info := mpegInfo{size: fi.Size(), emptyFrame: -1}
    60  	if tag, err := mpeg.ReadID3v1Footer(f, fi); err == nil && tag != nil {
    61  		info.footer = mpeg.ID3v1Length
    62  	}
    63  	if tag, err := taglib.Decode(f, fi.Size()); err == nil {
    64  		info.header = int64(tag.TagSize())
    65  	}
    66  	if info.sha1, err = mpeg.ComputeAudioSHA1(f, fi, info.header, info.footer); err != nil {
    67  		return &info, fmt.Errorf("failed computing SHA1: %v", err)
    68  	}
    69  
    70  	// Read the Xing header.
    71  	var vbrInfo *mpeg.VBRInfo
    72  	if info.xingDur, vbrInfo, err = mpeg.ComputeAudioDuration(f, fi, info.header, info.footer); err != nil {
    73  		return &info, fmt.Errorf("failed computing duration: %v", err)
    74  	}
    75  	if vbrInfo != nil {
    76  		// An "Xing" header ID (as opposed to "Info") typically indicates a VBR stream.
    77  		// Make this assumption so that we won't report streams that were encoded with VBR
    78  		// settings as CBR if all of the frames happen to have the same bitrate.
    79  		info.vbr = vbrInfo.ID == mpeg.XingID && (vbrInfo.Method != mpeg.CBR && vbrInfo.Method != mpeg.CBR2Pass)
    80  		info.xingFrames = int(vbrInfo.Frames)
    81  		info.xingBytes = int64(vbrInfo.Bytes)
    82  		if vbrInfo.Encoder != "" {
    83  			info.xingEnc = fmt.Sprintf("%s %s", vbrInfo.Encoder, vbrInfo.Method)
    84  		}
    85  	}
    86  
    87  	// Read all of the frames in the file.
    88  	off := info.header
    89  	var skipped, kbitRateSum int64
    90  	var skipErr error
    91  	var lastKbitRate int
    92  
    93  	addSkipped := func() {
    94  		if skipped == 0 {
    95  			return
    96  		}
    97  
    98  		start := off - skipped
    99  
   100  		// Diagnose common errors.
   101  		b := make([]byte, skipped)
   102  		if _, err := f.ReadAt(b, start); err == nil {
   103  			if bytes.HasPrefix(b, []byte("LYRICSBEGIN")) && bytes.HasSuffix(b, []byte("LYRICSEND")) {
   104  				skipErr = errors.New("Lyrics3v1 tag")
   105  			} else if bytes.HasPrefix(b, []byte("LYRICSBEGIN")) && bytes.HasSuffix(b, []byte("LYRICS200")) {
   106  				skipErr = errors.New("Lyrics3v2 tag")
   107  			} else if bytes.HasPrefix(b, []byte("TAG")) {
   108  				skipErr = errors.New("extra ID3v1 tag")
   109  			} else if bytes.Count(b, []byte{0}) == len(b) {
   110  				skipErr = errors.New("empty")
   111  			}
   112  		}
   113  		info.skipped = append(info.skipped, skipInfo{
   114  			offset: start,
   115  			size:   skipped,
   116  			err:    skipErr,
   117  		})
   118  		skipped = 0
   119  		skipErr = nil
   120  	}
   121  
   122  	for off < info.size-info.footer {
   123  		finfo, err := mpeg.ReadFrameInfo(f, off)
   124  		if err != nil {
   125  			off++
   126  			skipped++
   127  			if skipErr == nil {
   128  				skipErr = err
   129  			}
   130  			continue
   131  		}
   132  
   133  		addSkipped()
   134  
   135  		if isXing := info.xingBytes != 0 && info.actualFrames == 0; isXing {
   136  		} else {
   137  			// Skip the Xing frame for bitrate calculations since its bitrate sometimes
   138  			// differs from the audio frames in CBR files. See e.g.
   139  			// https://github.com/JamesHeinrich/getID3/issues/287#issuecomment-786357902.
   140  			if lastKbitRate > 0 && finfo.KbitRate != lastKbitRate {
   141  				info.vbr = true
   142  			}
   143  			lastKbitRate = finfo.KbitRate
   144  			kbitRateSum += int64(finfo.KbitRate)
   145  
   146  			// Get the sample rate from the first non-Xing frame we find. Getting it from the Xing
   147  			// frame seemed to work as well, but it feels a bit safer to skip it since I've seen
   148  			// different bitrates there.
   149  			if info.sampleRate == 0 {
   150  				info.sampleRate = finfo.SampleRate
   151  				info.samplesPerFrame = finfo.SamplesPerFrame
   152  			}
   153  		}
   154  
   155  		// Check for empty frames at the end of the file.
   156  		if finfo.Empty() {
   157  			if info.emptyFrame < 0 {
   158  				info.emptyFrame = info.actualFrames
   159  				info.emptyOffset = off
   160  			}
   161  		} else {
   162  			info.emptyFrame = -1
   163  		}
   164  		info.actualFrames++
   165  		info.actualBytes += finfo.Size()
   166  		off += finfo.Size()
   167  	}
   168  	addSkipped()
   169  
   170  	// The Xing header apparently doesn't include itself in the frame count
   171  	// (but confusingly *does* include itself in the bytes count):
   172  	// https://www.mail-archive.com/mp3encoder@minnie.tuhs.org/msg02868.html
   173  	// https://hydrogenaud.io/index.php?topic=85690.0
   174  	// If it was present, adjust fields so they'll be comparable to xingFrames.
   175  	if info.xingFrames != 0 {
   176  		info.actualFrames--
   177  		if info.emptyFrame > 0 {
   178  			info.emptyFrame--
   179  		}
   180  	}
   181  
   182  	// Do this after the above decrement since we excluded the Xing frame from kbitRateSum.
   183  	if info.actualFrames > 0 {
   184  		info.avgKbitRate = float64(kbitRateSum) / float64(info.actualFrames)
   185  	}
   186  
   187  	// Compute durations. The sample rate is fixed and there's a constant number
   188  	// of samples per frame, so we just need the number of frames.
   189  	computeDur := func(frames int) time.Duration {
   190  		if info.sampleRate == 0 {
   191  			return 0
   192  		}
   193  		return time.Duration(info.samplesPerFrame*frames) * time.Second /
   194  			time.Duration(info.sampleRate)
   195  	}
   196  	info.actualDur = computeDur(info.actualFrames)
   197  	if info.emptyFrame >= 0 {
   198  		info.emptyTime = computeDur(info.emptyFrame)
   199  	}
   200  
   201  	return &info, nil
   202  }