From 1ddbf98d2cb31c4af517e10d1bb89d93dcebb5ea Mon Sep 17 00:00:00 2001 From: Nishant Chitkara Date: Wed, 5 Aug 2026 04:37:53 -0700 Subject: [PATCH] feat(mp4): decode QuickTime audio sample entries .mp3, lpcm, twos, and sowt These QuickTime sample description names use the standard sound sample description layout, but fell through to UnknownBox, so their channel count, sample size, and sample rate were unreachable without hand parsing. Register them with the audio sample entry decoder and expose them as StsdBox.Mp3 and StsdBox.QtPcm. Since these names decoded as UnknownBox before, a body that does not parse as a sound sample description falls back to UnknownBox instead of failing the file, and the StsdBox.AddChild type assertions for the new names tolerate that fallback instead of panicking on it. --- CHANGELOG.md | 9 +++++ mp4/audiosampleentry_test.go | 68 ++++++++++++++++++++++++++++++++++++ mp4/audiosamplentry.go | 32 +++++++++++++++++ mp4/box.go | 4 +++ mp4/boxsr.go | 4 +++ mp4/stsd.go | 16 +++++++++ mp4/stsd_test.go | 45 ++++++++++++++++++++++++ 7 files changed, 178 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6486fa5c..3eaafef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DecodeAudioSampleEntry` now errors on bodies too short for the fixed fields, and encoding errors on inconsistent `QuickTimeVersion`/`QuickTimeV1`/`QuickTimeV2` combinations +- The QuickTime audio sample entries `.mp3`, `lpcm`, `twos`, and `sowt` + decode as `AudioSampleEntryBox` (reachable as `StsdBox.Mp3` and + `StsdBox.QtPcm`) instead of falling through to `UnknownBox`. A body that + does not parse as a sound sample description still becomes an + `UnknownBox`, so previously decodable files keep decoding; a body that + does parse now re-encodes with the same fidelity as `mp4a` (zeroed + packet-size bytes, integer sample rate) instead of byte-verbatim. Note + that a `.mp3` entry carries no esds; its codec facts live in the sound + description fields themselves ### Fixed diff --git a/mp4/audiosampleentry_test.go b/mp4/audiosampleentry_test.go index a91a3eda..ae4f0b99 100644 --- a/mp4/audiosampleentry_test.go +++ b/mp4/audiosampleentry_test.go @@ -222,6 +222,21 @@ func TestQuickTimeEncodeValidation(t *testing.T) { } } +func TestLegacyQuickTimeNamesFallBackToUnknown(t *testing.T) { + // Truncated legacy entries decoded as UnknownBox before the names were + // registered; they must keep doing so instead of failing the file. + data := quickTimeSoundDescriptionBytes(".mp3", 0, nil, nil)[:20] + binary.BigEndian.PutUint32(data[:4], uint32(len(data))) + cmpAfterDecodeEncodeBox(t, data) + box, err := mp4.DecodeBox(0, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + if _, isUnknown := box.(*mp4.UnknownBox); !isUnknown { + t.Errorf("truncated .mp3 entry decoded as %T, wanted UnknownBox", box) + } +} + // TestDirtyReservedBytesKeepDecoding pins the fallback: entries whose // reserved bytes carry an unknown version, or claim a QuickTime version the // layout does not match, decode with the plain ISO layout like before, and @@ -307,3 +322,56 @@ func TestQuickTimeLpcmFormatFlags(t *testing.T) { }) } } + +func TestQuickTimeAudioSampleEntryNames(t *testing.T) { + for _, name := range []string{".mp3", "lpcm", "twos", "sowt"} { + t.Run(name, func(t *testing.T) { + data := quickTimeSoundDescriptionBytes(name, 0, nil, nil) + cmpAfterDecodeEncodeBox(t, data) + box, err := mp4.DecodeBox(0, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + entry, ok := box.(*mp4.AudioSampleEntryBox) + if !ok { + t.Fatalf("%s decoded as %T, wanted AudioSampleEntryBox", name, box) + } + if entry.ChannelCount != 2 || entry.SampleSize != 16 || entry.SampleRate != 48000 { + t.Errorf("fixed fields not decoded: %+v", entry) + } + stsd := mp4.NewStsdBox() + stsd.AddChild(entry) + switch name { + case ".mp3": + if stsd.Mp3 != entry { + t.Error("stsd.Mp3 not set") + } + default: + if stsd.QtPcm != entry { + t.Error("stsd.QtPcm not set") + } + } + }) + } + + // lpcm entries carry a version 2 sound description in practice. + t.Run("lpcm version 2", func(t *testing.T) { + v2Extension := make([]byte, 0, 36) + v2Extension = binary.BigEndian.AppendUint32(v2Extension, 72) + v2Extension = binary.BigEndian.AppendUint64(v2Extension, math.Float64bits(44100)) + for _, val := range []uint32{2, mp4.QuickTimeV2Marker, 16, 0, 4, 1} { + v2Extension = binary.BigEndian.AppendUint32(v2Extension, val) + } + data := quickTimeSoundDescriptionBytes("lpcm", 2, v2Extension, nil) + cmpAfterDecodeEncodeBox(t, data) + box, err := mp4.DecodeBox(0, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + entry := box.(*mp4.AudioSampleEntryBox) + q := entry.QuickTimeV2 + if q == nil || q.AudioSampleRate != 44100 || q.NumAudioChannels != 2 || q.ConstBitsPerChannel != 16 { + t.Errorf("quickTimeV2 fields not decoded: %+v", q) + } + }) +} diff --git a/mp4/audiosamplentry.go b/mp4/audiosamplentry.go index bf8891d2..eb3d64e3 100644 --- a/mp4/audiosamplentry.go +++ b/mp4/audiosamplentry.go @@ -286,6 +286,38 @@ func DecodeAudioSampleEntrySR(hdr BoxHeader, startPos uint64, sr bits.SliceReade return decodeAudioSampleEntryFromData(hdr, startPos, data) } +// DecodeQuickTimeAudioSampleEntry - decode a legacy QuickTime audio sample +// entry (.mp3, lpcm, twos, sowt). These names decoded as UnknownBox before +// they were registered, so a body that does not parse as a sound sample +// description falls back to UnknownBox instead of failing the file. +func DecodeQuickTimeAudioSampleEntry(hdr BoxHeader, startPos uint64, r io.Reader) (Box, error) { + data, err := readBoxBody(r, hdr) + if err != nil { + return nil, err + } + box, err := decodeAudioSampleEntryFromData(hdr, startPos, data) + if err != nil { + return CreateUnknownBox(hdr.Name, hdr.Size, data), nil + } + return box, nil +} + +// DecodeQuickTimeAudioSampleEntrySR - decode a legacy QuickTime audio sample +// entry (.mp3, lpcm, twos, sowt) with UnknownBox fallback. +func DecodeQuickTimeAudioSampleEntrySR(hdr BoxHeader, startPos uint64, sr bits.SliceReader) (Box, error) { + data := sr.ReadBytes(hdr.payloadLen()) + if sr.AccError() != nil { + return nil, sr.AccError() + } + box, err := decodeAudioSampleEntryFromData(hdr, startPos, data) + if err != nil { + payload := make([]byte, len(data)) + copy(payload, data) + return CreateUnknownBox(hdr.Name, hdr.Size, payload), nil + } + return box, nil +} + // Type - return box type func (a *AudioSampleEntryBox) Type() string { return a.name diff --git a/mp4/box.go b/mp4/box.go index 7b9509b4..598ba51f 100644 --- a/mp4/box.go +++ b/mp4/box.go @@ -23,6 +23,7 @@ func init() { "\xa9nam": DecodeGenericContainerBox, "\xa9too": DecodeGenericContainerBox, "\xa9cpy": DecodeGenericContainerBox, + ".mp3": DecodeQuickTimeAudioSampleEntry, "ac-3": DecodeAudioSampleEntry, "ac-4": DecodeAudioSampleEntry, "alou": DecodeLoudnessBaseBox, @@ -101,6 +102,7 @@ func init() { "jpgC": DecodeJpgC, "kind": DecodeKind, "leva": DecodeLeva, + "lpcm": DecodeQuickTimeAudioSampleEntry, "ludt": DecodeLudt, "mdat": DecodeMdat, "mdcv": DecodeMdcv, @@ -148,6 +150,7 @@ func init() { "skip": DecodeFree, "SmDm": DecodeSmDm, "smhd": DecodeSmhd, + "sowt": DecodeQuickTimeAudioSampleEntry, "ssix": DecodeSsix, "stbl": DecodeStbl, "stco": DecodeStco, @@ -178,6 +181,7 @@ func init() { "trex": DecodeTrex, "trgr": DecodeTrgr, "trun": DecodeTrun, + "twos": DecodeQuickTimeAudioSampleEntry, "udta": DecodeUdta, "url ": DecodeURLBox, "uuid": DecodeUUIDBox, diff --git a/mp4/boxsr.go b/mp4/boxsr.go index eee97b6d..2f2f14ed 100644 --- a/mp4/boxsr.go +++ b/mp4/boxsr.go @@ -14,6 +14,7 @@ func init() { "\xa9cpy": DecodeGenericContainerBoxSR, "\xa9nam": DecodeGenericContainerBoxSR, "\xa9too": DecodeGenericContainerBoxSR, + ".mp3": DecodeQuickTimeAudioSampleEntrySR, "ac-3": DecodeAudioSampleEntrySR, "ac-4": DecodeAudioSampleEntrySR, "alou": DecodeLoudnessBaseBoxSR, @@ -92,6 +93,7 @@ func init() { "jpgC": DecodeJpgCSR, "kind": DecodeKindSR, "leva": DecodeLevaSR, + "lpcm": DecodeQuickTimeAudioSampleEntrySR, "ludt": DecodeLudtSR, "mdat": DecodeMdatSR, "mdcv": DecodeMdcvSR, @@ -139,6 +141,7 @@ func init() { "skip": DecodeFreeSR, "SmDm": DecodeSmDmSR, "smhd": DecodeSmhdSR, + "sowt": DecodeQuickTimeAudioSampleEntrySR, "ssix": DecodeSsixSR, "stbl": DecodeStblSR, "stco": DecodeStcoSR, @@ -169,6 +172,7 @@ func init() { "trex": DecodeTrexSR, "trgr": DecodeTrgrSR, "trun": DecodeTrunSR, + "twos": DecodeQuickTimeAudioSampleEntrySR, "udta": DecodeUdtaSR, "url ": DecodeURLBoxSR, "uuid": DecodeUUIDBoxSR, diff --git a/mp4/stsd.go b/mp4/stsd.go index 29fcc094..aade4ffc 100644 --- a/mp4/stsd.go +++ b/mp4/stsd.go @@ -40,6 +40,12 @@ type StsdBox struct { Jpeg *VisualSampleEntryBox // Mp4a is a pointer to a box with name mp4a Mp4a *AudioSampleEntryBox + // Mp3 is a pointer to a box with name .mp3 (QuickTime MP3 audio) + Mp3 *AudioSampleEntryBox + // QtPcm is a pointer to a box with name lpcm (modern, version 2 style), or + // twos/sowt (legacy big/little-endian 16-bit PCM) — QuickTime PCM audio. + // Check Type() to tell which one it is. + QtPcm *AudioSampleEntryBox // AC3 is a pointer to a box with name ac-3 AC3 *AudioSampleEntryBox // EC3 is a pointer to a box with name ec-3 @@ -93,6 +99,16 @@ func (s *StsdBox) AddChild(box Box) { s.Avs3 = box.(*VisualSampleEntryBox) case "mp4a": s.Mp4a = box.(*AudioSampleEntryBox) + case ".mp3": + // A legacy body that does not parse as a sound sample description + // falls back to UnknownBox; it then only lives in Children. + if entry, ok := box.(*AudioSampleEntryBox); ok { + s.Mp3 = entry + } + case "lpcm", "twos", "sowt": + if entry, ok := box.(*AudioSampleEntryBox); ok { + s.QtPcm = entry + } case "ac-3": s.AC3 = box.(*AudioSampleEntryBox) case "ec-3": diff --git a/mp4/stsd_test.go b/mp4/stsd_test.go index d61e3b7d..80c080b1 100644 --- a/mp4/stsd_test.go +++ b/mp4/stsd_test.go @@ -2,6 +2,7 @@ package mp4_test import ( "bytes" + "encoding/binary" "encoding/hex" "os" "testing" @@ -152,6 +153,50 @@ func TestStsdAC4(t *testing.T) { } } +// TestStsdTruncatedLegacyAudioEntryFallsBackToUnknown pins that a registered +// legacy QuickTime name whose body does not parse as a sound sample +// description (here a truncated .mp3 entry, which decodes as UnknownBox) +// still lands in Children without being typed as StsdBox.Mp3, on both +// decode paths. +func TestStsdTruncatedLegacyAudioEntryFallsBackToUnknown(t *testing.T) { + entry := quickTimeSoundDescriptionBytes(".mp3", 0, nil, nil)[:20] + binary.BigEndian.PutUint32(entry[:4], uint32(len(entry))) + data := make([]byte, 0, 16+len(entry)) + data = binary.BigEndian.AppendUint32(data, uint32(16+len(entry))) + data = append(data, []byte("stsd")...) + data = binary.BigEndian.AppendUint32(data, 0) // version + flags + data = binary.BigEndian.AppendUint32(data, 1) // entry count + data = append(data, entry...) + decodes := []struct { + name string + decode func() (mp4.Box, error) + }{ + {"DecodeBox", func() (mp4.Box, error) { return mp4.DecodeBox(0, bytes.NewReader(data)) }}, + {"DecodeBoxSR", func() (mp4.Box, error) { return mp4.DecodeBoxSR(0, bits.NewFixedSliceReader(data)) }}, + } + for _, d := range decodes { + t.Run(d.name, func(t *testing.T) { + box, err := d.decode() + if err != nil { + t.Fatal(err) + } + stsd, ok := box.(*mp4.StsdBox) + if !ok { + t.Fatalf("Expected StsdBox, got %T", box) + } + if len(stsd.Children) != 1 { + t.Fatalf("Expected one child, got %d", len(stsd.Children)) + } + if _, isUnknown := stsd.Children[0].(*mp4.UnknownBox); !isUnknown { + t.Errorf("truncated .mp3 entry decoded as %T, wanted UnknownBox", stsd.Children[0]) + } + if stsd.Mp3 != nil { + t.Error("StsdBox.Mp3 is set, wanted nil for an UnknownBox fallback child") + } + }) + } +} + func decodeStsdBox(t *testing.T, data []byte) *mp4.StsdBox { t.Helper() sr := bits.NewFixedSliceReader(data)