Files
SpotiFLAC-Mobile/go_backend/metadata_m4a.go
T

1182 lines
33 KiB
Go

package gobackend
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
"os"
"regexp"
"strconv"
"strings"
)
func ReadM4ATags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
ilst, err := findM4AIlstAtom(f, fi.Size())
if err != nil {
return nil, err
}
return readM4ATagsFromIlst(f, fi.Size(), ilst)
}
func readM4ATagsFromIlst(f *os.File, fileSize int64, ilst atomHeader) (*AudioMetadata, error) {
metadata := &AudioMetadata{}
start := ilst.offset + ilst.headerSize
end := ilst.offset + ilst.size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return nil, err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "\xa9nam":
metadata.Title, _ = readM4ATextValue(f, header, fileSize)
case "\xa9ART":
metadata.Artist, _ = readM4ATextValue(f, header, fileSize)
case "\xa9alb":
metadata.Album, _ = readM4ATextValue(f, header, fileSize)
case "aART":
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fileSize)
case "\xa9day":
metadata.Date, _ = readM4ATextValue(f, header, fileSize)
metadata.Year = metadata.Date
case "\xa9gen":
metadata.Genre, _ = readM4ATextValue(f, header, fileSize)
case "\xa9wrt":
metadata.Composer, _ = readM4ATextValue(f, header, fileSize)
case "\xa9cmt":
metadata.Comment, _ = readM4ATextValue(f, header, fileSize)
case "cprt":
metadata.Copyright, _ = readM4ATextValue(f, header, fileSize)
case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fileSize)
case "trkn":
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fileSize)
case "disk":
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fileSize)
case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fileSize)
if freeformErr == nil {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "ISRC":
metadata.ISRC = value
case "LABEL", "ORGANIZATION":
metadata.Label = value
case "COMMENT":
if metadata.Comment == "" {
metadata.Comment = value
}
case "COMPOSER":
if metadata.Composer == "" {
metadata.Composer = value
}
case "COPYRIGHT":
if metadata.Copyright == "" {
metadata.Copyright = value
}
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "REPLAYGAIN_TRACK_GAIN":
metadata.ReplayGainTrackGain = value
case "REPLAYGAIN_TRACK_PEAK":
metadata.ReplayGainTrackPeak = value
case "REPLAYGAIN_ALBUM_GAIN":
metadata.ReplayGainAlbumGain = value
case "REPLAYGAIN_ALBUM_PEAK":
metadata.ReplayGainAlbumPeak = value
}
}
}
pos += header.size
}
if metadata.Title == "" &&
metadata.Artist == "" &&
metadata.Album == "" &&
metadata.AlbumArtist == "" &&
metadata.Lyrics == "" &&
metadata.TrackNumber == 0 &&
metadata.DiscNumber == 0 {
return nil, fmt.Errorf("no M4A tags found")
}
return metadata, nil
}
func extractLyricsFromM4A(filePath string) (string, error) {
metadata, err := ReadM4ATags(filePath)
if err != nil {
return "", err
}
if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
return "", fmt.Errorf("no lyrics found in file")
}
return metadata.Lyrics, nil
}
func extractCoverFromM4A(filePath string) ([]byte, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
fileSize := fi.Size()
ilst, err := findM4AIlstAtom(f, fileSize)
if err != nil {
return nil, err
}
return extractCoverFromM4AIlst(f, fileSize, ilst)
}
func extractCoverFromM4AIlst(f *os.File, fileSize int64, ilst atomHeader) ([]byte, error) {
bodyStart := ilst.offset + ilst.headerSize
bodySize := ilst.size - ilst.headerSize
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("cover atom not found")
}
dataStart := covr.offset + covr.headerSize
dataSize := covr.size - covr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in cover")
}
// data atom: header + 4 bytes type indicator + 4 bytes locale
imgStart := dataAtom.offset + dataAtom.headerSize + 8
imgLen := dataAtom.size - dataAtom.headerSize - 8
if imgLen <= 0 {
return nil, fmt.Errorf("empty cover data")
}
buf := make([]byte, imgLen)
if _, err := f.ReadAt(buf, imgStart); err != nil {
return nil, err
}
return buf, nil
}
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
// It tries two common layouts:
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return atomHeader{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
// Path 1: moov > udta > meta > ilst
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
return ilst, nil
}
}
}
// Path 2: moov > meta > ilst (no udta wrapper)
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return ilst, nil
}
}
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
// findIlstInMeta locates the ilst atom inside a meta atom, handling both
// layouts: ISO-BMFF (4-byte version/flags before the child atoms, written by
// FFmpeg's mp4 muxer) and QuickTime (no version/flags, written by the mov muxer
// used for AC-4 passthrough).
func findIlstInMeta(f *os.File, meta atomHeader, fileSize int64) (atomHeader, bool) {
// ISO-BMFF: skip the 4-byte version/flags that precede the child atoms.
isoStart := meta.offset + meta.headerSize + 4
isoSize := meta.size - meta.headerSize - 4
if ilst, ok, _ := findAtomInRange(f, isoStart, isoSize, "ilst", fileSize); ok {
return ilst, true
}
// QuickTime: child atoms begin immediately after the meta header.
qtStart := meta.offset + meta.headerSize
qtSize := meta.size - meta.headerSize
if ilst, ok, _ := findAtomInRange(f, qtStart, qtSize, "ilst", fileSize); ok {
return ilst, true
}
return atomHeader{}, false
}
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
if payloadLen <= 0 {
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
}
buf := make([]byte, payloadLen)
if _, err := f.ReadAt(buf, payloadStart); err != nil {
return nil, err
}
return buf, nil
}
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
dataStart := parent.offset + parent.headerSize
dataSize := parent.size - parent.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
}
return readM4ADataAtomPayload(f, dataAtom)
}
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return "", err
}
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
}
func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return 0, 0, err
}
if len(payload) < 6 {
return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ)
}
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
}
func parsePositiveInt(value string) int {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
n, _ := strconv.Atoi(value)
return n
}
func formatIndexValue(number, total int) string {
if number <= 0 {
return ""
}
if total > 0 {
return fmt.Sprintf("%d/%d", number, total)
}
return strconv.Itoa(number)
}
func hasMapKey(fields map[string]string, key string) bool {
_, ok := fields[key]
return ok
}
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
start := parent.offset + parent.headerSize
end := parent.offset + parent.size
var nameValue string
var dataValue string
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return "", "", err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "mean":
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
case "name":
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
// It does NOT contain a nested "data" atom, so read the payload directly.
payloadStart := header.offset + header.headerSize + 4
payloadLen := header.size - header.headerSize - 4
if payloadLen > 0 {
buf := make([]byte, payloadLen)
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
}
}
case "data":
payload, payloadErr := readM4ADataAtomPayload(f, header)
if payloadErr == nil {
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
}
}
pos += header.size
}
if nameValue == "" || dataValue == "" {
return "", "", fmt.Errorf("freeform M4A tag incomplete")
}
return nameValue, dataValue, nil
}
type m4aMetadataPath struct {
moov atomHeader
udta *atomHeader
meta atomHeader
ilst atomHeader
}
func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return m4aMetadataPath{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
udta: &udtaCopy,
meta: meta,
ilst: ilst,
}, nil
}
}
}
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
ilst: ilst,
}, nil
}
}
return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func buildM4AAtom(typ string, payload []byte) []byte {
size := int64(8 + len(payload))
buf := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(buf[0:4], uint32(size))
copy(buf[4:8], []byte(typ))
copy(buf[8:], payload)
return buf
}
func buildM4AFreeformAtom(name, value string) []byte {
meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...)
namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...)
dataPayload := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text
copy(dataPayload[8:], []byte(value))
payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...)
payload = append(payload, buildM4AAtom("name", namePayload)...)
payload = append(payload, buildM4AAtom("data", dataPayload)...)
return buildM4AAtom("----", payload)
}
func buildITunNORMTag(trackGain, trackPeak string) string {
gainDb, ok := parseReplayGainDb(trackGain)
if !ok {
return ""
}
peakLinear, ok := parseReplayGainPeak(trackPeak)
if !ok {
return ""
}
clamp := func(v int64) int64 {
if v < 0 {
return 0
}
if v > 65534 {
return 65534
}
return v
}
g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0)))
g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0)))
peak := clamp(int64(math.Round(peakLinear * 32768.0)))
values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value)))
}
return strings.Join(parts, " ")
}
var replayGainNumberPattern = regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`)
func parseReplayGainDb(value string) (float64, bool) {
match := replayGainNumberPattern.FindStringSubmatch(strings.TrimSpace(value))
if len(match) < 2 {
return 0, false
}
parsed, err := strconv.ParseFloat(match[1], 64)
if err != nil {
return 0, false
}
return parsed, true
}
func parseReplayGainPeak(value string) (float64, bool) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || parsed <= 0 {
return 0, false
}
return parsed, true
}
func collectM4AReplayGainFields(fields map[string]string) map[string]string {
result := map[string]string{}
if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" {
result["replaygain_track_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" {
result["replaygain_track_peak"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" {
result["replaygain_album_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" {
result["replaygain_album_peak"] = value
}
if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" {
result["iTunNORM"] = norm
}
return result
}
func writeAtomSize(buf []byte, header atomHeader, newSize int64) error {
if newSize <= 0 {
return fmt.Errorf("invalid size for %s", header.typ)
}
if header.headerSize == 16 {
if int(header.offset)+16 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1)
binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize))
return nil
}
if newSize > math.MaxUint32 {
return fmt.Errorf("atom %s too large for 32-bit header", header.typ)
}
if int(header.offset)+8 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize))
return nil
}
func EditM4AReplayGain(filePath string, fields map[string]string) error {
replayGainFields := collectM4AReplayGainFields(fields)
if len(replayGainFields) == 0 {
return nil
}
remove := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
tags := make([]m4aFreeformTag, 0, len(order))
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
tags = append(tags, m4aFreeformTag{name: name, value: value})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
type m4aFreeformTag struct {
name string
value string
}
// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing
// freeform ("----") atom whose uppercased name is in `remove`, then appends the
// supplied tags (empty values are skipped, which effectively clears the field).
// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain.
//
// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so
// fields like ISRC and LABEL are silently dropped when written via -metadata.
// Writing them as iTunes freeform atoms natively is the only way they persist.
func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
// MOV-style containers (e.g. AC-4 passthrough) store tags as QuickTime
// atoms under udta with no iTunes meta>ilst structure. There is nowhere
// to write freeform tags, so skip gracefully instead of failing.
if strings.Contains(err.Error(), "ilst not found") {
GoLog("[Metadata] No iTunes ilst container; skipping freeform tags")
return nil
}
return err
}
// Only the moov box is buffered; the audio bulk is streamed on write.
base := path.moov.offset
moovBuf := make([]byte, path.moov.size)
if _, err := f.ReadAt(moovBuf, base); err != nil {
return err
}
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + path.ilst.size
newBody := make([]byte, 0, int(path.ilst.size))
for pos := bodyStart; pos+8 <= bodyEnd; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
if readErr != nil {
return readErr
}
if header.size == 0 {
header.size = bodyEnd - pos
}
if header.size < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
keep := true
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
}
if keep {
newBody = append(newBody, moovBuf[pos-base:pos-base+header.size]...)
}
pos += header.size
}
for _, tag := range tags {
if strings.TrimSpace(tag.value) == "" {
continue
}
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
ilstRel := path.ilst.offset - base
updated := append([]byte{}, moovBuf[:ilstRel]...)
updated = append(updated, newIlst...)
updated = append(updated, moovBuf[ilstRel+path.ilst.size:]...)
// The path headers carry absolute file offsets; rebase them onto the
// moov-rooted buffer before patching sizes.
rel := func(h atomHeader) atomHeader {
h.offset -= base
return h
}
delta := int64(len(newIlst)) - path.ilst.size
if err := writeAtomSize(updated, rel(path.ilst), path.ilst.size+delta); err != nil {
return err
}
if err := writeAtomSize(updated, rel(path.meta), path.meta.size+delta); err != nil {
return err
}
if path.udta != nil {
if err := writeAtomSize(updated, rel(*path.udta), path.udta.size+delta); err != nil {
return err
}
}
if err := writeAtomSize(updated, rel(path.moov), path.moov.size+delta); err != nil {
return err
}
// Keep sample pointers valid when moov precedes mdat: every stco/co64
// entry at or beyond the resized ilst must shift with it. Entries hold
// absolute file offsets, so compare against the absolute ilst position.
if delta != 0 {
if moov, ok := findChildMP4(updated, 0, int64(len(updated)), "moov"); ok {
shiftChunkOffsets(updated, moov, path.ilst.offset, delta)
}
}
// Release the read handle before replacing the file (required on Windows).
f.Close()
return replaceFileSectionsStreaming(filePath, []fileSection{
{start: base, end: base + path.moov.size, data: updated},
})
}
// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes
// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so
// they must be written natively for the values to actually persist. An empty
// value clears the corresponding tag. Other (recognized) tags are left intact.
func EditM4AFreeformText(filePath string, fields map[string]string) error {
_, hasISRC := fields["isrc"]
_, hasLabel := fields["label"]
if !hasISRC && !hasLabel {
return nil
}
remove := map[string]struct{}{}
tags := make([]m4aFreeformTag, 0, 2)
if hasISRC {
remove["ISRC"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
}
if hasLabel {
remove["LABEL"] = struct{}{}
remove["ORGANIZATION"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
func GetM4AQuality(filePath string) (AudioQuality, error) {
f, err := os.Open(filePath)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
}
fileSize := info.Size()
return m4aQualityFromFile(f, fileSize)
}
func m4aQualityFromFile(f *os.File, fileSize int64) (AudioQuality, error) {
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil {
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
}
if !moovFound {
return AudioQuality{}, fmt.Errorf("moov atom not found")
}
moovStart := moovHeader.offset
moovEnd := moovHeader.offset + moovHeader.size
duration := readM4ADurationSeconds(f, moovHeader, fileSize)
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
if err != nil {
return AudioQuality{}, err
}
buf := make([]byte, 32)
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
}
// AudioSampleEntry layout from the box type field:
// [0:4] type ("mp4a"/"alac")
// [4:10] SampleEntry.reserved
// [10:12] data_reference_index
// [12:20] reserved[8]
// [20:22] channelcount
// [22:24] samplesize (bit depth)
// [24:26] pre_defined
// [26:28] reserved
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := 0
codec := normalizeM4AAudioCodec(atomType)
switch atomType {
case "alac":
bitDepth = int(buf[22])<<8 | int(buf[23])
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
if alacBitDepth > 0 {
bitDepth = alacBitDepth
}
if alacSampleRate > 0 {
sampleRate = alacSampleRate
}
}
case "fLaC":
bitDepth = int(buf[22])<<8 | int(buf[23])
if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok {
if flacBitDepth > 0 {
bitDepth = flacBitDepth
}
if flacSampleRate > 0 {
sampleRate = flacSampleRate
}
if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 {
duration = int(flacTotalSamples / int64(sampleRate))
}
}
}
bitrate := estimateAudioBitrateKbps(fileSize, duration)
if bitrate > 0 && bitrate < 16 {
bitrate = 0
}
return AudioQuality{
BitDepth: bitDepth,
SampleRate: sampleRate,
Duration: duration,
Bitrate: bitrate,
Codec: codec,
}, nil
}
func normalizeM4AAudioCodec(atomType string) string {
switch atomType {
case "mp4a":
return "aac"
case "alac":
return "alac"
case "fLaC":
return "flac"
case "ec-3":
return "eac3"
case "ac-3":
return "ac3"
case "ac-4":
return "ac4"
default:
return strings.TrimSpace(atomType)
}
}
func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int {
if fileSize <= 0 || durationSeconds <= 0 {
return 0
}
return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0))
}
func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize
mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize)
if err == nil && found {
if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 {
return duration
}
}
return readM4ATrackDurationSeconds(f, moovHeader, fileSize)
}
func readMP4DurationAtomSeconds(f *os.File, header atomHeader, _ int64) int {
payloadOffset := header.offset + header.headerSize
versionBuf := make([]byte, 1)
if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil {
return 0
}
if versionBuf[0] == 1 {
buf := make([]byte, 32)
if _, err := f.ReadAt(buf, payloadOffset); err != nil {
return 0
}
timescale := binary.BigEndian.Uint32(buf[20:24])
duration := binary.BigEndian.Uint64(buf[24:32])
if timescale == 0 || duration == 0 {
return 0
}
return int(math.Round(float64(duration) / float64(timescale)))
}
buf := make([]byte, 20)
if _, err := f.ReadAt(buf, payloadOffset); err != nil {
return 0
}
timescale := binary.BigEndian.Uint32(buf[12:16])
duration := binary.BigEndian.Uint32(buf[16:20])
if timescale == 0 || duration == 0 {
return 0
}
return int(math.Round(float64(duration) / float64(timescale)))
}
func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize
bestDuration := 0
_ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool {
if header.typ == "mdhd" {
if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration {
bestDuration = duration
}
return false
}
return header.typ == "trak" || header.typ == "mdia"
})
return bestDuration
}
func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error {
if size <= 0 {
return nil
}
end := start + size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if visit(header) {
childStart := header.offset + header.headerSize
childSize := header.size - header.headerSize
if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil {
return err
}
}
pos += atomSize
}
return nil
}
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 {
return 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize)
if err != nil || !found {
return 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, false
}
return parseALACSpecificConfig(payload)
}
func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) {
if sampleOffset < 4 {
return 0, 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize)
if err != nil || !found {
return 0, 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, 0, false
}
return parseMP4FLACSpecificConfig(payload)
}
func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) {
if len(payload) >= 4 && string(payload[:4]) == "fLaC" {
payload = payload[4:]
} else if len(payload) >= 4 {
// FLACSpecificBox starts with a full-box version/flags field.
payload = payload[4:]
}
for len(payload) >= 4 {
blockType := payload[0] & 0x7F
blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
if blockLen < 0 || len(payload) < 4+blockLen {
return 0, 0, 0, false
}
block := payload[4 : 4+blockLen]
if blockType == 0 && len(block) >= 34 {
bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34])
return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0
}
payload = payload[4+blockLen:]
}
return 0, 0, 0, false
}
func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) {
if len(streamInfo) < 18 {
return 0, 0, 0
}
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
int64(streamInfo[16])<<8 |
int64(streamInfo[17])
return bitsPerSample, sampleRate, totalSamples
}
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
if len(payload) < 24 {
return 0, 0, false
}
bitDepth := int(payload[5])
sampleRate := int(binary.BigEndian.Uint32(payload[20:24]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
// Some encoders prepend 4 bytes before the ALACSpecificConfig payload.
if len(payload) >= 28 {
bitDepth = int(payload[9])
sampleRate = int(binary.BigEndian.Uint32(payload[24:28]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
}
return 0, 0, false
}
type atomHeader struct {
offset int64
size int64
headerSize int64
typ string
}
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
if offset+8 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
headerBuf := make([]byte, 8)
if _, err := f.ReadAt(headerBuf, offset); err != nil {
return atomHeader{}, err
}
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
typ := string(headerBuf[4:8])
if size32 == 1 {
if offset+16 > fileSize {
return atomHeader{}, io.ErrUnexpectedEOF
}
extBuf := make([]byte, 8)
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
return atomHeader{}, err
}
size64 := binary.BigEndian.Uint64(extBuf)
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
}
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
}
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
if size <= 0 {
return atomHeader{}, false, nil
}
end := start + size
pos := start
for pos+8 <= end {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return atomHeader{}, false, err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if header.typ == target {
return header, true, nil
}
pos += atomSize
}
return atomHeader{}, false, nil
}
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024
patterns := [][]byte{
[]byte("mp4a"),
[]byte("alac"),
[]byte("fLaC"),
[]byte("ec-3"),
[]byte("ac-3"),
[]byte("ac-4"),
}
var tail []byte
readPos := start
for readPos < end {
toRead := end - readPos
if toRead > chunkSize {
toRead = chunkSize
}
buf := make([]byte, toRead)
n, err := f.ReadAt(buf, readPos)
if err != nil && err != io.EOF {
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
}
if n == 0 {
break
}
data := append(tail, buf[:n]...)
bestIdx := -1
bestType := ""
for _, pattern := range patterns {
idx := bytes.Index(data, pattern)
if idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
bestIdx = idx
bestType = string(pattern)
}
}
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+32 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
}
if len(data) >= 3 {
tail = append([]byte{}, data[len(data)-3:]...)
} else {
tail = append([]byte{}, data...)
}
readPos += int64(n)
}
return 0, "", fmt.Errorf("audio info not found in M4A file")
}