Files

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
}