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)