403 lines
9.7 KiB
Go
403 lines
9.7 KiB
Go
package metadata
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
)
|
|
|
|
// errNoSyncWord is returned when no valid MP3 frame sync word
|
|
// is found within the search window.
|
|
var errNoSyncWord = errors.New("could not find MP3 sync word")
|
|
|
|
// maxSyncSearchBytes limits how far we scan for the first sync word
|
|
// after skipping any ID3v2 tags. 512 KB accommodates files with
|
|
// large embedded artwork or multiple prepended ID3v2 tags.
|
|
const maxSyncSearchBytes = 512 * 1024
|
|
|
|
// maxID3v2Tags limits how many consecutive ID3v2 tags we skip.
|
|
// Some files contain multiple prepended tags from different tagging
|
|
// tools.
|
|
const maxID3v2Tags = 5
|
|
|
|
// MPEG version constants.
|
|
const (
|
|
mpegVersion1 = 3 // 0b11
|
|
mpegVersion2 = 2 // 0b10
|
|
mpegVersion2_5 = 0 // 0b00 (unofficial extension)
|
|
)
|
|
|
|
// bitrateTable maps [versionIndex][bitrateIndex] to kbps.
|
|
// versionIndex 0 = MPEG1, 1 = MPEG2/2.5.
|
|
// bitrateIndex 0 and 15 are invalid.
|
|
//
|
|
//nolint:mnd // lookup table values are from the MPEG spec.
|
|
var bitrateTable = [2][16]int{
|
|
// MPEG1 Layer 3
|
|
{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
|
|
// MPEG2/2.5 Layer 3
|
|
{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
|
|
}
|
|
|
|
// sampleRateTable maps [versionIndex][sampleRateIndex] to Hz.
|
|
// versionIndex: 0 = MPEG1, 1 = MPEG2, 2 = MPEG2.5.
|
|
//
|
|
//nolint:mnd // lookup table values are from the MPEG spec.
|
|
var sampleRateTable = [3][4]int{
|
|
{44100, 48000, 32000, 0}, // MPEG1
|
|
{22050, 24000, 16000, 0}, // MPEG2
|
|
{11025, 12000, 8000, 0}, // MPEG2.5
|
|
}
|
|
|
|
// samplesPerFrame returns the number of PCM samples per MP3 frame
|
|
// for the given MPEG version (Layer 3 only).
|
|
//
|
|
//nolint:mnd // constants from the MPEG spec.
|
|
func samplesPerFrame(version int) int {
|
|
if version == mpegVersion1 {
|
|
return 1152
|
|
}
|
|
|
|
return 576 // MPEG2 / MPEG2.5
|
|
}
|
|
|
|
// mp3BitDepth is the effective bit depth for decoded MP3 audio.
|
|
// The MPEG standard decodes to 16-bit PCM.
|
|
const mp3BitDepth = 16
|
|
|
|
// getMP3Duration computes the duration of an MP3 file in
|
|
// milliseconds by reading only the first frame's header and any
|
|
// Xing/VBRI VBR header it contains. For CBR files (no VBR header)
|
|
// it falls back to fileSize / bitrate. It also returns audio
|
|
// properties extracted from the frame header.
|
|
//
|
|
// The file position is undefined after this call.
|
|
func getMP3Duration(
|
|
f *os.File,
|
|
) (int64, *AudioProperties, error) {
|
|
// 1. Skip all leading ID3v2 tags. Some files have multiple
|
|
// consecutive tags from different tagging tools.
|
|
audioStart, err := skipID3v2(f)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf(
|
|
"skipping ID3v2: %w", err,
|
|
)
|
|
}
|
|
|
|
audioStart, err = skipAdditionalID3v2(f, audioStart)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf(
|
|
"skipping additional ID3v2 tags: %w", err,
|
|
)
|
|
}
|
|
|
|
// 2. Find and parse the first MP3 frame header.
|
|
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
// Build audio properties from the frame header.
|
|
channels := 2
|
|
if hdr.channelMode == 3 { //nolint:mnd // 3 = mono
|
|
channels = 1
|
|
}
|
|
|
|
props := &AudioProperties{
|
|
SampleRate: hdr.sampleRate,
|
|
BitDepth: mp3BitDepth,
|
|
Channels: channels,
|
|
Bitrate: hdr.bitrateKbps,
|
|
}
|
|
|
|
// 3. Attempt to read a VBR header (Xing/Info or VBRI) from
|
|
// inside the first frame.
|
|
vbrFrames, found, err := readVBRHeader(
|
|
f, hdr, frameOffset,
|
|
)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
if found && vbrFrames > 0 {
|
|
spf := samplesPerFrame(hdr.version)
|
|
durationMS := int64(vbrFrames) *
|
|
int64(spf) * 1000 / int64(hdr.sampleRate)
|
|
|
|
return durationMS, props, nil
|
|
}
|
|
|
|
// 4. CBR fallback: duration = audioBytes * 8 / bitrate.
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf(
|
|
"stat file for CBR duration: %w", err,
|
|
)
|
|
}
|
|
|
|
audioBytes := fi.Size() - audioStart
|
|
durationMS := audioBytes * 8 * 1000 /
|
|
(int64(hdr.bitrateKbps) * 1000)
|
|
|
|
return durationMS, props, nil
|
|
}
|
|
|
|
// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio
|
|
// frame header.
|
|
type mpegFrameHeader struct {
|
|
version int // mpegVersion1, mpegVersion2, mpegVersion2_5
|
|
bitrateKbps int
|
|
sampleRate int
|
|
channelMode int // 0-3; 3 = mono
|
|
padding int // 0 or 1
|
|
}
|
|
|
|
// skipID3v2 checks for an ID3v2 tag at the start of f and returns
|
|
// the byte offset where audio data begins.
|
|
//
|
|
//nolint:mnd // byte offsets from the ID3v2 spec.
|
|
func skipID3v2(f *os.File) (int64, error) {
|
|
var buf [10]byte
|
|
|
|
if _, err := f.ReadAt(buf[:], 0); err != nil {
|
|
return 0, fmt.Errorf("reading ID3v2 header: %w", err)
|
|
}
|
|
|
|
if string(buf[:3]) != "ID3" {
|
|
return 0, nil // no ID3v2 tag
|
|
}
|
|
|
|
// Syncsafe integer: 4 bytes, each using 7 bits.
|
|
size := int64(buf[6])<<21 |
|
|
int64(buf[7])<<14 |
|
|
int64(buf[8])<<7 |
|
|
int64(buf[9])
|
|
|
|
return 10 + size, nil
|
|
}
|
|
|
|
// skipAdditionalID3v2 looks for further ID3v2 tags starting at
|
|
// offset and advances past each one found. This handles files
|
|
// where multiple tagging tools have each prepended their own ID3v2
|
|
// header.
|
|
//
|
|
//nolint:mnd // byte offsets from the ID3v2 spec.
|
|
func skipAdditionalID3v2(
|
|
f *os.File,
|
|
offset int64,
|
|
) (int64, error) {
|
|
var buf [10]byte
|
|
|
|
for range maxID3v2Tags {
|
|
if _, err := f.ReadAt(buf[:], offset); err != nil {
|
|
// EOF or short read means no more tags.
|
|
return offset, nil //nolint:nilerr
|
|
}
|
|
|
|
if string(buf[:3]) != "ID3" {
|
|
return offset, nil
|
|
}
|
|
|
|
size := int64(buf[6])<<21 |
|
|
int64(buf[7])<<14 |
|
|
int64(buf[8])<<7 |
|
|
int64(buf[9])
|
|
|
|
offset += 10 + size
|
|
}
|
|
|
|
return offset, nil
|
|
}
|
|
|
|
// findFrameHeader scans from startOffset for the first valid MP3
|
|
// sync word and returns the parsed header plus the file offset
|
|
// where the frame begins.
|
|
//
|
|
//nolint:mnd,cyclop // bit manipulation from the MPEG spec.
|
|
func findFrameHeader(
|
|
f *os.File,
|
|
startOffset int64,
|
|
) (mpegFrameHeader, int64, error) {
|
|
if _, err := f.Seek(startOffset, io.SeekStart); err != nil {
|
|
return mpegFrameHeader{}, 0, fmt.Errorf(
|
|
"seeking to audio start: %w", err,
|
|
)
|
|
}
|
|
|
|
// Read a chunk large enough to contain the first frame.
|
|
buf := make([]byte, maxSyncSearchBytes)
|
|
|
|
n, err := io.ReadAtLeast(f, buf, 4)
|
|
if err != nil {
|
|
return mpegFrameHeader{}, 0, fmt.Errorf(
|
|
"reading audio data: %w", err,
|
|
)
|
|
}
|
|
|
|
buf = buf[:n]
|
|
|
|
for i := 0; i <= len(buf)-4; i++ {
|
|
// Sync word: 11 set bits (0xFF followed by 0xE0 mask).
|
|
if buf[i] != 0xFF || buf[i+1]&0xE0 != 0xE0 {
|
|
continue
|
|
}
|
|
|
|
hdr, ok := parseFrameHeader(buf[i : i+4])
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
return hdr, startOffset + int64(i), nil
|
|
}
|
|
|
|
return mpegFrameHeader{}, 0, errNoSyncWord
|
|
}
|
|
|
|
// parseFrameHeader decodes a 4-byte MPEG audio frame header.
|
|
// Returns false if the header contains invalid field combinations.
|
|
//
|
|
//nolint:mnd,cyclop // bit manipulation from the MPEG spec.
|
|
func parseFrameHeader(b []byte) (mpegFrameHeader, bool) {
|
|
version := int((b[1] >> 3) & 0x03)
|
|
layer := int((b[1] >> 1) & 0x03)
|
|
|
|
// We only handle Layer 3.
|
|
if layer != 1 { // Layer encoding: 1 = Layer 3
|
|
return mpegFrameHeader{}, false
|
|
}
|
|
|
|
// Determine version index for the bitrate table.
|
|
var bitrateIdx int
|
|
|
|
switch version {
|
|
case mpegVersion1:
|
|
bitrateIdx = 0
|
|
case mpegVersion2, mpegVersion2_5:
|
|
bitrateIdx = 1
|
|
default:
|
|
return mpegFrameHeader{}, false // reserved
|
|
}
|
|
|
|
brIndex := int((b[2] >> 4) & 0x0F)
|
|
bitrate := bitrateTable[bitrateIdx][brIndex]
|
|
|
|
if bitrate == 0 {
|
|
return mpegFrameHeader{}, false
|
|
}
|
|
|
|
// Sample rate.
|
|
var srVersionIdx int
|
|
|
|
switch version {
|
|
case mpegVersion1:
|
|
srVersionIdx = 0
|
|
case mpegVersion2:
|
|
srVersionIdx = 1
|
|
case mpegVersion2_5:
|
|
srVersionIdx = 2
|
|
}
|
|
|
|
srIndex := int((b[2] >> 2) & 0x03)
|
|
sampleRate := sampleRateTable[srVersionIdx][srIndex]
|
|
|
|
if sampleRate == 0 {
|
|
return mpegFrameHeader{}, false
|
|
}
|
|
|
|
padding := int((b[2] >> 1) & 0x01)
|
|
channelMode := int((b[3] >> 6) & 0x03)
|
|
|
|
return mpegFrameHeader{
|
|
version: version,
|
|
bitrateKbps: bitrate,
|
|
sampleRate: sampleRate,
|
|
channelMode: channelMode,
|
|
padding: padding,
|
|
}, true
|
|
}
|
|
|
|
// readVBRHeader tries to read a Xing/Info or VBRI header from the
|
|
// first frame at frameOffset. Returns the total frame count and
|
|
// whether a VBR header was found.
|
|
//
|
|
//nolint:mnd // byte offsets from Xing/VBRI specs.
|
|
func readVBRHeader(
|
|
f *os.File,
|
|
hdr mpegFrameHeader,
|
|
frameOffset int64,
|
|
) (uint32, bool, error) {
|
|
// Xing/Info header offset depends on version and channel mode.
|
|
var sideInfoSize int
|
|
|
|
switch {
|
|
case hdr.version == mpegVersion1 && hdr.channelMode != 3:
|
|
sideInfoSize = 32
|
|
case hdr.version == mpegVersion1 && hdr.channelMode == 3:
|
|
sideInfoSize = 17
|
|
case hdr.channelMode != 3:
|
|
sideInfoSize = 17
|
|
default:
|
|
sideInfoSize = 9
|
|
}
|
|
|
|
// The Xing header sits right after the 4-byte frame header +
|
|
// side information.
|
|
xingOffset := frameOffset + 4 + int64(sideInfoSize)
|
|
|
|
// Read enough bytes for Xing header (magic + flags + frames).
|
|
var xingBuf [12]byte
|
|
|
|
if _, err := f.ReadAt(xingBuf[:], xingOffset); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return 0, false, nil
|
|
}
|
|
|
|
return 0, false, fmt.Errorf(
|
|
"reading Xing header: %w", err,
|
|
)
|
|
}
|
|
|
|
magic := string(xingBuf[:4])
|
|
if magic == "Xing" || magic == "Info" {
|
|
flags := binary.BigEndian.Uint32(xingBuf[4:8])
|
|
|
|
// Bit 0 of flags indicates the frames field is present.
|
|
if flags&0x01 != 0 {
|
|
frames := binary.BigEndian.Uint32(xingBuf[8:12])
|
|
|
|
return frames, true, nil
|
|
}
|
|
|
|
// Xing header present but no frame count — fall through
|
|
// to CBR fallback.
|
|
return 0, true, nil
|
|
}
|
|
|
|
// VBRI header is always at a fixed offset of 36 bytes from
|
|
// the frame start (regardless of version/channel mode).
|
|
vbriOffset := frameOffset + 36
|
|
|
|
var vbriBuf [26]byte
|
|
|
|
if _, err := f.ReadAt(vbriBuf[:], vbriOffset); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return 0, false, nil
|
|
}
|
|
|
|
return 0, false, fmt.Errorf(
|
|
"reading VBRI header: %w", err,
|
|
)
|
|
}
|
|
|
|
if string(vbriBuf[:4]) == "VBRI" {
|
|
// Total frames at offset 14 from VBRI magic.
|
|
frames := binary.BigEndian.Uint32(vbriBuf[14:18])
|
|
|
|
return frames, true, nil
|
|
}
|
|
|
|
return 0, false, nil
|
|
}
|