Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
923 lines
30 KiB
Go
923 lines
30 KiB
Go
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package oggreader
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// buildOggFile generates a valid oggfile that can
|
|
// be used for tests.
|
|
func buildOggContainer() []byte {
|
|
return []byte{
|
|
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00, 0x8e, 0x9b, 0x20, 0xaa, 0x00, 0x00,
|
|
0x00, 0x00, 0x61, 0xee, 0x61, 0x17, 0x01, 0x13, 0x4f, 0x70,
|
|
0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x02, 0x00, 0x0f,
|
|
0x80, 0xbb, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4f, 0x67, 0x67,
|
|
0x53, 0x00, 0x00, 0xda, 0x93, 0xc2, 0xd9, 0x00, 0x00, 0x00,
|
|
0x00, 0x8e, 0x9b, 0x20, 0xaa, 0x02, 0x00, 0x00, 0x00, 0x49,
|
|
0x97, 0x03, 0x37, 0x01, 0x05, 0x98, 0x36, 0xbe, 0x88, 0x9e,
|
|
}
|
|
}
|
|
|
|
// buildSurroundOggContainerShort has mapping family 1 but omits the mapping table (invalid length).
|
|
func buildSurroundOggContainerShort() []byte {
|
|
return []byte{
|
|
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00, 0x58, 0x49, 0xac, 0xe2, 0x00, 0x00,
|
|
0x00, 0x00, 0xc1, 0xa8, 0x7d, 0x4e, 0x01, 0x13, 0x4f, 0x70,
|
|
0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x06, 0x38, 0x01,
|
|
0x80, 0xbb, 0x00, 0x00, 0x00, 0x00, 0x01,
|
|
}
|
|
}
|
|
|
|
// buildUnknownMappingFamilyContainer creates an ID page with an unrecognized channel mapping family.
|
|
func buildUnknownMappingFamilyContainer(mappingFamily, channels uint8) []byte {
|
|
payload := []byte{
|
|
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
|
0x01, // version
|
|
channels, // channel count
|
|
0x38, 0x01, // preskip (0x0138)
|
|
0x80, 0xbb, 0x00, 0x00, // sample rate (48000)
|
|
0x00, 0x00, // output gain
|
|
mappingFamily,
|
|
}
|
|
|
|
segmentTable := []byte{byte(len(payload))} //nolint:gosec // G115: test-only, sized by construction.
|
|
|
|
header := []byte{
|
|
0x4f, 0x67, 0x67, 0x53, // "OggS"
|
|
0x00, // version
|
|
0x02, // beginning of stream
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // granule position
|
|
0x00, 0x00, 0x00, 0x00, // bitstream serial number
|
|
0x00, 0x00, 0x00, 0x00, // page sequence number
|
|
0x00, 0x00, 0x00, 0x00, // checksum (ignored with checksum disabled)
|
|
0x01, // page segments
|
|
}
|
|
|
|
packet := make([]byte, 0, len(header)+len(segmentTable)+len(payload))
|
|
packet = append(packet, header...)
|
|
packet = append(packet, segmentTable...)
|
|
packet = append(packet, payload...)
|
|
|
|
return packet
|
|
}
|
|
|
|
// buildChannelMappingFamilyContainer builds an Opus ID page for mapping families that
|
|
// follow the Figure 3 layout (families 1, 2, 3, 255).
|
|
func buildChannelMappingFamilyContainer(
|
|
mappingFamily, channels, streamCount, coupledCount uint8,
|
|
mapping []byte,
|
|
) []byte {
|
|
payload := []byte{
|
|
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
|
0x01, // version
|
|
channels, // channel count
|
|
0x38, 0x01, // preskip (0x0138)
|
|
0x80, 0xbb, 0x00, 0x00, // sample rate (48000)
|
|
0x00, 0x00, // output gain
|
|
mappingFamily,
|
|
streamCount,
|
|
coupledCount,
|
|
}
|
|
payload = append(payload, mapping...)
|
|
|
|
segmentTable := []byte{byte(len(payload))} //nolint:gosec // G115: test-only, sized by construction.
|
|
|
|
header := []byte{
|
|
0x4f, 0x67, 0x67, 0x53, // "OggS"
|
|
0x00, // version
|
|
0x02, // header type (beginning of stream)
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // granule position
|
|
0x00, 0x00, 0x00, 0x00, // bitstream serial number
|
|
0x00, 0x00, 0x00, 0x00, // page sequence number
|
|
0x00, 0x00, 0x00, 0x00, // checksum (ignored when checksum disabled)
|
|
0x01, // page segments
|
|
}
|
|
|
|
packet := make([]byte, 0, len(header)+len(segmentTable)+len(payload))
|
|
packet = append(packet, header...)
|
|
packet = append(packet, segmentTable...)
|
|
packet = append(packet, payload...)
|
|
|
|
return packet
|
|
}
|
|
|
|
func TestOggReader_ParseValidHeader(t *testing.T) {
|
|
reader, header, err := NewWith(bytes.NewReader(buildOggContainer()))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
assert.NotNil(t, header)
|
|
|
|
assert.EqualValues(t, header.ChannelMap, 0)
|
|
assert.EqualValues(t, header.Channels, 2)
|
|
assert.EqualValues(t, header.OutputGain, 0)
|
|
assert.EqualValues(t, header.PreSkip, 0xf00)
|
|
assert.EqualValues(t, header.SampleRate, 48000)
|
|
assert.EqualValues(t, header.Version, 1)
|
|
}
|
|
|
|
func TestOggReader_ParseNextPage(t *testing.T) {
|
|
ogg := bytes.NewReader(buildOggContainer())
|
|
|
|
reader, _, err := NewWith(ogg)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
assert.Equal(t, int64(47), reader.bytesReadSuccesfully)
|
|
|
|
payload, _, err := reader.ParseNextPage()
|
|
assert.Equal(t, []byte{0x98, 0x36, 0xbe, 0x88, 0x9e}, payload)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, int64(80), reader.bytesReadSuccesfully)
|
|
|
|
_, _, err = reader.ParseNextPage()
|
|
assert.Equal(t, err, io.EOF)
|
|
}
|
|
|
|
func TestOggReader_ParseErrors(t *testing.T) {
|
|
t.Run("Assert that Reader isn't nil", func(t *testing.T) {
|
|
_, _, err := NewWith(nil)
|
|
assert.Equal(t, err, errNilStream)
|
|
})
|
|
|
|
t.Run("Invalid ID Page Header Signature", func(t *testing.T) {
|
|
ogg := buildOggContainer()
|
|
ogg[0] = 0
|
|
|
|
_, _, err := newWith(bytes.NewReader(ogg), false)
|
|
assert.ErrorIs(t, err, errBadIDPageSignature)
|
|
})
|
|
|
|
t.Run("Invalid ID Page Header Type", func(t *testing.T) {
|
|
ogg := buildOggContainer()
|
|
ogg[5] = 0
|
|
|
|
_, _, err := newWith(bytes.NewReader(ogg), false)
|
|
assert.ErrorIs(t, err, errBadIDPageType)
|
|
})
|
|
|
|
t.Run("Invalid ID Page Payload Length", func(t *testing.T) {
|
|
ogg := buildOggContainer()
|
|
ogg[27] = 0
|
|
|
|
_, _, err := newWith(bytes.NewReader(ogg), false)
|
|
assert.ErrorIs(t, err, errBadIDPageLength)
|
|
})
|
|
|
|
t.Run("Invalid ID Page Payload Length", func(t *testing.T) {
|
|
ogg := buildOggContainer()
|
|
ogg[35] = 0
|
|
|
|
_, _, err := newWith(bytes.NewReader(ogg), false)
|
|
assert.ErrorIs(t, err, errBadIDPagePayloadSignature)
|
|
})
|
|
|
|
t.Run("Invalid Page Checksum", func(t *testing.T) {
|
|
ogg := buildOggContainer()
|
|
ogg[22] = 0
|
|
|
|
_, _, err := NewWith(bytes.NewReader(ogg))
|
|
assert.ErrorIs(t, err, errChecksumMismatch)
|
|
})
|
|
|
|
t.Run("Invalid Multichannel ID Page Payload Length", func(t *testing.T) {
|
|
_, _, err := newWith(bytes.NewReader(buildSurroundOggContainerShort()), false)
|
|
assert.ErrorIs(t, err, errBadIDPageLength)
|
|
})
|
|
|
|
t.Run("Unsupported Channel Mapping Family", func(t *testing.T) {
|
|
_, _, err := newWith(bytes.NewReader(buildUnknownMappingFamilyContainer(4, 2)), false)
|
|
assert.ErrorIs(t, err, errUnsupportedChannelMappingFamily)
|
|
})
|
|
}
|
|
|
|
func TestOggReader_ChannelMappingFamily1(t *testing.T) {
|
|
type testCase struct {
|
|
name string
|
|
channels uint8
|
|
streams uint8
|
|
coupled uint8
|
|
channelMap []byte
|
|
}
|
|
|
|
cases := []testCase{
|
|
{name: "1-mono", channels: 1, streams: 1, coupled: 0, channelMap: []byte{0}},
|
|
{name: "2-stereo", channels: 2, streams: 1, coupled: 1, channelMap: []byte{0, 1}},
|
|
{name: "3-linear-surround", channels: 3, streams: 2, coupled: 1, channelMap: []byte{0, 2, 1}},
|
|
{name: "4-quad", channels: 4, streams: 2, coupled: 2, channelMap: []byte{0, 1, 2, 3}},
|
|
{name: "5-5.0", channels: 5, streams: 3, coupled: 2, channelMap: []byte{0, 1, 2, 3, 4}},
|
|
{name: "6-5.1", channels: 6, streams: 4, coupled: 2, channelMap: []byte{0, 4, 1, 2, 3, 5}},
|
|
{name: "7-6.1", channels: 7, streams: 4, coupled: 3, channelMap: []byte{0, 1, 2, 3, 4, 5, 6}},
|
|
{name: "8-7.1", channels: 8, streams: 5, coupled: 3, channelMap: []byte{0, 1, 2, 3, 4, 5, 6, 7}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
reader, err := NewWithOptions(
|
|
bytes.NewReader(buildChannelMappingFamilyContainer(1, tc.channels, tc.streams, tc.coupled, tc.channelMap)),
|
|
WithDoChecksum(false),
|
|
)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
|
|
payload, pageHeader, err := reader.ParseNextPage()
|
|
assert.NoError(t, err)
|
|
sig, ok := pageHeader.HeaderType(payload)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, HeaderOpusID, sig)
|
|
|
|
header, err := ParseOpusHead(payload)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, header)
|
|
|
|
assert.EqualValues(t, 1, header.Version)
|
|
assert.EqualValues(t, tc.channels, header.Channels)
|
|
assert.EqualValues(t, 0x138, header.PreSkip)
|
|
assert.EqualValues(t, 48e3, header.SampleRate)
|
|
assert.EqualValues(t, 0, header.OutputGain)
|
|
assert.EqualValues(t, 1, header.ChannelMap)
|
|
assert.EqualValues(t, tc.streams, header.StreamCount)
|
|
assert.EqualValues(t, tc.coupled, header.CoupledCount)
|
|
assert.Equal(t, string(tc.channelMap), header.ChannelMapping)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOggReader_KnownChannelMappingFamilies(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mappingFamily uint8
|
|
channels uint8
|
|
streams uint8
|
|
coupled uint8
|
|
channelMap []byte
|
|
}{
|
|
{name: "family-2", mappingFamily: 2, channels: 2, streams: 1, coupled: 1, channelMap: []byte{0, 1}},
|
|
{name: "family-255", mappingFamily: 255, channels: 2, streams: 1, coupled: 1, channelMap: []byte{0, 1}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
container := buildChannelMappingFamilyContainer(
|
|
tc.mappingFamily, tc.channels, tc.streams, tc.coupled, tc.channelMap,
|
|
)
|
|
reader, err := NewWithOptions(bytes.NewReader(container), WithDoChecksum(false))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
|
|
payload, pageHeader, err := reader.ParseNextPage()
|
|
assert.NoError(t, err)
|
|
sig, ok := pageHeader.HeaderType(payload)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, HeaderOpusID, sig)
|
|
|
|
header, err := ParseOpusHead(payload)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, header)
|
|
|
|
assert.EqualValues(t, tc.mappingFamily, header.ChannelMap)
|
|
assert.EqualValues(t, tc.channels, header.Channels)
|
|
assert.EqualValues(t, 0x138, header.PreSkip)
|
|
assert.EqualValues(t, 48e3, header.SampleRate)
|
|
assert.EqualValues(t, 0, header.OutputGain)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOggReader_ParseExtraFieldsForNonZeroMappingFamily(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mappingFamily uint8
|
|
channels uint8
|
|
streams uint8
|
|
coupled uint8
|
|
channelMap []byte
|
|
}{
|
|
{name: "family-1-stereo", mappingFamily: 1, channels: 2, streams: 1, coupled: 1, channelMap: []byte{0, 1}},
|
|
{name: "family-1-5.1", mappingFamily: 1, channels: 6, streams: 4, coupled: 2, channelMap: []byte{0, 4, 1, 2, 3, 5}},
|
|
{
|
|
name: "family-1-7.1",
|
|
mappingFamily: 1,
|
|
channels: 8,
|
|
streams: 5,
|
|
coupled: 3,
|
|
channelMap: []byte{0, 1, 2, 3, 4, 5, 6, 7},
|
|
},
|
|
{name: "family-2", mappingFamily: 2, channels: 4, streams: 2, coupled: 2, channelMap: []byte{0, 1, 2, 3}},
|
|
{name: "family-255", mappingFamily: 255, channels: 5, streams: 3, coupled: 2, channelMap: []byte{0, 1, 2, 3, 4}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
container := buildChannelMappingFamilyContainer(
|
|
tc.mappingFamily, tc.channels, tc.streams, tc.coupled, tc.channelMap,
|
|
)
|
|
reader, err := NewWithOptions(bytes.NewReader(container), WithDoChecksum(false))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
|
|
payload, pageHeader, err := reader.ParseNextPage()
|
|
assert.NoError(t, err)
|
|
sig, ok := pageHeader.HeaderType(payload)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, HeaderOpusID, sig)
|
|
|
|
header, err := ParseOpusHead(payload)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, header)
|
|
|
|
assert.EqualValues(t, tc.mappingFamily, header.ChannelMap)
|
|
assert.EqualValues(t, tc.channels, header.Channels)
|
|
assert.EqualValues(t, tc.streams, header.StreamCount)
|
|
assert.EqualValues(t, tc.coupled, header.CoupledCount)
|
|
assert.Equal(t, string(tc.channelMap), header.ChannelMapping)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOggReader_NewWithOptions(t *testing.T) {
|
|
t.Run("With checksum enabled (default)", func(t *testing.T) {
|
|
reader, err := NewWithOptions(bytes.NewReader(buildOggContainer()))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
assert.True(t, reader.doChecksum)
|
|
|
|
payload, pageHeader, err := reader.ParseNextPage()
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, payload)
|
|
assert.NotNil(t, pageHeader)
|
|
assert.Equal(t, string(HeaderOpusID), string(payload[:8]))
|
|
})
|
|
|
|
t.Run("With checksum enabled explicitly", func(t *testing.T) {
|
|
reader, err := NewWithOptions(bytes.NewReader(buildOggContainer()), WithDoChecksum(true))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
assert.True(t, reader.doChecksum)
|
|
|
|
ogg := buildOggContainer()
|
|
ogg[22] = 0
|
|
reader2, err := NewWithOptions(bytes.NewReader(ogg), WithDoChecksum(true))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader2)
|
|
|
|
_, _, err = reader2.ParseNextPage()
|
|
assert.Equal(t, errChecksumMismatch, err)
|
|
})
|
|
|
|
t.Run("With checksum disabled", func(t *testing.T) {
|
|
reader, err := NewWithOptions(bytes.NewReader(buildOggContainer()), WithDoChecksum(false))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
assert.False(t, reader.doChecksum)
|
|
|
|
ogg := buildOggContainer()
|
|
ogg[22] = 0
|
|
reader2, err := NewWithOptions(bytes.NewReader(ogg), WithDoChecksum(false))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader2)
|
|
|
|
payload, pageHeader, err := reader2.ParseNextPage()
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, payload)
|
|
assert.NotNil(t, pageHeader)
|
|
})
|
|
}
|
|
|
|
// buildMultiTrackOggContainer generates a minimal two-track Ogg file
|
|
// with two Opus ID header pages (one for each track).
|
|
func buildMultiTrackOggContainer(
|
|
firstSerial, secondSerial uint32,
|
|
channels uint8,
|
|
sampleRate uint32,
|
|
preskip uint16,
|
|
version uint8,
|
|
channelMap uint8,
|
|
outputGain uint16,
|
|
) []byte {
|
|
firstSerialBytes := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(firstSerialBytes, firstSerial)
|
|
secondSerialBytes := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(secondSerialBytes, secondSerial)
|
|
|
|
preskipBytes := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(preskipBytes, preskip)
|
|
|
|
sampleRateBytes := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(sampleRateBytes, sampleRate)
|
|
|
|
outputGainBytes := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(outputGainBytes, outputGain)
|
|
|
|
firstPageHeader := []byte{
|
|
0x4f, 0x67, 0x67, 0x53, // "OggS"
|
|
0x00, // version
|
|
0x02, // header type (beginning of stream)
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // granule position
|
|
firstSerialBytes[0], firstSerialBytes[1], firstSerialBytes[2], firstSerialBytes[3], // serial number
|
|
0x00, 0x00, 0x00, 0x00, // page sequence number
|
|
0xd7, 0xb7, 0x51, 0x4a, // checksum
|
|
0x01, // page segments
|
|
0x13, // segment size (19 bytes)
|
|
}
|
|
|
|
firstPayload := []byte{
|
|
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
|
version, // version
|
|
channels, // channels
|
|
preskipBytes[0], preskipBytes[1], // preskip
|
|
sampleRateBytes[0], sampleRateBytes[1], sampleRateBytes[2], sampleRateBytes[3], // sample rate
|
|
outputGainBytes[0], outputGainBytes[1], // output gain
|
|
channelMap, // channel mapping family
|
|
}
|
|
|
|
// Second track: Opus ID page
|
|
// Ogg page header (27 bytes)
|
|
secondPageHeader := []byte{
|
|
0x4f, 0x67, 0x67, 0x53, // "OggS"
|
|
0x00, // version
|
|
0x02, // header type (beginning of stream)
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // granule position
|
|
secondSerialBytes[0], secondSerialBytes[1], secondSerialBytes[2], secondSerialBytes[3], // serial number
|
|
0x00, 0x00, 0x00, 0x00, // page sequence number
|
|
0xaf, 0xaa, 0x01, 0x8b, // checksum
|
|
0x01, // page segments
|
|
0x13, // segment size (19 bytes)
|
|
}
|
|
|
|
// Second track: OpusHead payload (19 bytes)
|
|
secondPayload := []byte{
|
|
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
|
version, // version
|
|
channels, // channels
|
|
preskipBytes[0], preskipBytes[1], // preskip
|
|
sampleRateBytes[0], sampleRateBytes[1], sampleRateBytes[2], sampleRateBytes[3], // sample rate
|
|
outputGainBytes[0], outputGainBytes[1], // output gain
|
|
channelMap, // channel mapping family
|
|
}
|
|
|
|
container := make([]byte, 0, len(firstPageHeader)+len(firstPayload)+len(secondPageHeader)+len(secondPayload))
|
|
container = append(container, firstPageHeader...)
|
|
container = append(container, firstPayload...)
|
|
container = append(container, secondPageHeader...)
|
|
container = append(container, secondPayload...)
|
|
|
|
return container
|
|
}
|
|
|
|
func TestOggReader_MultiTrackFile(t *testing.T) {
|
|
firstSerial := uint32(0xd03ed35d)
|
|
secondSerial := uint32(0xfa6e13f0)
|
|
channels := uint8(1)
|
|
sampleRate := uint32(48000)
|
|
preskip := uint16(0x0138)
|
|
version := uint8(1)
|
|
channelMap := uint8(0)
|
|
outputGain := uint16(0)
|
|
|
|
data := buildMultiTrackOggContainer(
|
|
firstSerial, secondSerial,
|
|
channels, sampleRate, preskip,
|
|
version, channelMap, outputGain,
|
|
)
|
|
|
|
reader, err := NewWithOptions(bytes.NewReader(data), WithDoChecksum(false))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
|
|
var headers []*OggHeader
|
|
var pageHeaders []*OggPageHeader
|
|
|
|
for {
|
|
payload, pageHeader, err := reader.ParseNextPage()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
assert.NoError(t, err, "Error reading page")
|
|
|
|
break
|
|
}
|
|
|
|
sig, ok := pageHeader.HeaderType(payload)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, HeaderOpusID, sig)
|
|
|
|
header, err2 := ParseOpusHead(payload)
|
|
assert.NoError(t, err2)
|
|
assert.NotNil(t, header)
|
|
headers = append(headers, header)
|
|
pageHeaders = append(pageHeaders, pageHeader)
|
|
|
|
t.Logf("Found header %d: Channels=%d, SampleRate=%d, Serial=%d",
|
|
len(headers), header.Channels, header.SampleRate, pageHeader.Serial)
|
|
}
|
|
|
|
assert.Equal(t, 2, len(headers), "Should find exactly 2 headers")
|
|
assert.Equal(t, channels, headers[0].Channels, "First track should be mono")
|
|
assert.Equal(t, channels, headers[1].Channels, "Second track should be mono")
|
|
assert.Equal(t, sampleRate, headers[0].SampleRate, "First track should be 48kHz")
|
|
assert.Equal(t, sampleRate, headers[1].SampleRate, "Second track should be 48kHz")
|
|
|
|
assert.Equal(t, firstSerial, pageHeaders[0].Serial, "First track serial should match")
|
|
assert.Equal(t, secondSerial, pageHeaders[1].Serial, "Second track serial should match")
|
|
assert.NotEqual(t, pageHeaders[0].Serial, pageHeaders[1].Serial, "Serial numbers should be different")
|
|
|
|
t.Logf("Multi-track file: found %d headers", len(headers))
|
|
}
|
|
|
|
// buildOpusTagsPayload builds an OpusTags payload.
|
|
func buildOpusTagsPayload(vendor string, comments []UserComment) []byte {
|
|
payload := []byte("OpusTags")
|
|
|
|
vendorBytes := []byte(vendor)
|
|
vendorLen := make([]byte, 4)
|
|
//nolint:gosec // G115: test-only, sized by construction
|
|
binary.LittleEndian.PutUint32(vendorLen, uint32(len(vendorBytes)))
|
|
payload = append(payload, vendorLen...)
|
|
payload = append(payload, vendorBytes...)
|
|
|
|
commentCount := make([]byte, 4)
|
|
//nolint:gosec // G115: test-only, sized by construction
|
|
binary.LittleEndian.PutUint32(commentCount, uint32(len(comments)))
|
|
payload = append(payload, commentCount...)
|
|
|
|
for _, c := range comments {
|
|
comment := c.Comment + "=" + c.Value
|
|
commentBytes := []byte(comment)
|
|
commentLen := make([]byte, 4)
|
|
//nolint:gosec // G115: test-only, sized by construction
|
|
binary.LittleEndian.PutUint32(commentLen, uint32(len(commentBytes)))
|
|
payload = append(payload, commentLen...)
|
|
payload = append(payload, commentBytes...)
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
// buildOggPage builds a complete Ogg page with header, segment table, and payload.
|
|
func buildOggPage(serial uint32, pageIndex uint32, headerType uint8, payload []byte) []byte {
|
|
serialBytes := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(serialBytes, serial)
|
|
|
|
indexBytes := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(indexBytes, pageIndex)
|
|
|
|
// Build segment table (single segment containing entire payload)
|
|
segmentTable := []byte{byte(len(payload))} //nolint:gosec // G115: test-only, sized by construction.
|
|
|
|
// Build page header (27 bytes)
|
|
header := []byte{
|
|
0x4f, 0x67, 0x67, 0x53, // "OggS"
|
|
0x00, // version
|
|
headerType, // header type
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // granule position
|
|
serialBytes[0], serialBytes[1], serialBytes[2], serialBytes[3], // serial number
|
|
indexBytes[0], indexBytes[1], indexBytes[2], indexBytes[3], // page sequence number
|
|
0x00, 0x00, 0x00, 0x00, // checksum (will be zero, checksum disabled in test)
|
|
0x01, // page segments count
|
|
}
|
|
|
|
page := make([]byte, 0, len(header)+len(segmentTable)+len(payload))
|
|
page = append(page, header...)
|
|
page = append(page, segmentTable...)
|
|
page = append(page, payload...)
|
|
|
|
return page
|
|
}
|
|
|
|
// buildOpusHeadPayload builds an OpusHead payload.
|
|
func buildOpusHeadPayload(
|
|
version, channels uint8,
|
|
preskip uint16,
|
|
sampleRate uint32,
|
|
outputGain uint16,
|
|
channelMap uint8,
|
|
) []byte {
|
|
payload := []byte("OpusHead")
|
|
payload = append(payload, version)
|
|
payload = append(payload, channels)
|
|
|
|
preskipBytes := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(preskipBytes, preskip)
|
|
payload = append(payload, preskipBytes...)
|
|
|
|
sampleRateBytes := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(sampleRateBytes, sampleRate)
|
|
payload = append(payload, sampleRateBytes...)
|
|
|
|
outputGainBytes := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(outputGainBytes, outputGain)
|
|
payload = append(payload, outputGainBytes...)
|
|
payload = append(payload, channelMap)
|
|
|
|
return payload
|
|
}
|
|
|
|
// buildTwoTrackOggContainer builds a complete two-track Ogg container.
|
|
// Track 1: OpusHead (index 0) + OpusTags (index 1).
|
|
// Track 2: OpusHead (index 0) + OpusTags (index 1).
|
|
func buildTwoTrackOggContainer(
|
|
serial1, serial2 uint32,
|
|
track1Comments, track2Comments []UserComment,
|
|
) []byte {
|
|
opusHeadPayload := buildOpusHeadPayload(1, 2, 0x0138, 48000, 0, 0)
|
|
|
|
vendor := "TestVendor"
|
|
track1TagsPayload := buildOpusTagsPayload(vendor, track1Comments)
|
|
track2TagsPayload := buildOpusTagsPayload(vendor, track2Comments)
|
|
|
|
track1OpusHeadPage := buildOggPage(serial1, 0, pageHeaderTypeBeginningOfStream, opusHeadPayload)
|
|
track1OpusTagsPage := buildOggPage(serial1, 1, 0, track1TagsPayload)
|
|
track2OpusHeadPage := buildOggPage(serial2, 0, pageHeaderTypeBeginningOfStream, opusHeadPayload)
|
|
track2OpusTagsPage := buildOggPage(serial2, 1, 0, track2TagsPayload)
|
|
|
|
totalLen := len(track1OpusHeadPage) + len(track1OpusTagsPage) +
|
|
len(track2OpusHeadPage) + len(track2OpusTagsPage)
|
|
container := make([]byte, 0, totalLen)
|
|
container = append(container, track1OpusHeadPage...)
|
|
container = append(container, track1OpusTagsPage...)
|
|
container = append(container, track2OpusHeadPage...)
|
|
container = append(container, track2OpusTagsPage...)
|
|
|
|
return container
|
|
}
|
|
|
|
func processPages(reader *OggReader) ([]HeaderType, []*OpusTags, error) {
|
|
var headersFound []HeaderType
|
|
var opusTagsFound []*OpusTags
|
|
|
|
for {
|
|
payload, pageHeader, err := reader.ParseNextPage()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
|
|
return nil, nil, err
|
|
}
|
|
|
|
sig, ok := pageHeader.HeaderType(payload)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
headersFound = append(headersFound, sig)
|
|
if sig == HeaderOpusTags {
|
|
tags, err := ParseOpusTags(payload)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if tags != nil {
|
|
opusTagsFound = append(opusTagsFound, tags)
|
|
}
|
|
}
|
|
}
|
|
|
|
return headersFound, opusTagsFound, nil
|
|
}
|
|
|
|
func countHeaderTypes(headersFound []HeaderType) (int, int) {
|
|
opusIDCount := 0
|
|
opusTagsCount := 0
|
|
for _, h := range headersFound {
|
|
switch h {
|
|
case HeaderOpusID:
|
|
opusIDCount++
|
|
case HeaderOpusTags:
|
|
opusTagsCount++
|
|
default:
|
|
}
|
|
}
|
|
|
|
return opusIDCount, opusTagsCount
|
|
}
|
|
|
|
func userCommentsToMap(comments []UserComment) map[string]string {
|
|
out := make(map[string]string, len(comments))
|
|
for _, c := range comments {
|
|
out[c.Comment] = c.Value
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func TestOggReader_DetectHeadersAndTags(t *testing.T) {
|
|
serial1 := uint32(0xd03ed35d)
|
|
serial2 := uint32(0xfa6e13f0)
|
|
|
|
track1Title := hex.EncodeToString([]byte{
|
|
0x6e, 0x65, 0x76, 0x65, 0x72, 0x20, 0x67, 0x6f, 0x6e, 0x6e, 0x61, 0x20,
|
|
0x67, 0x69, 0x76, 0x65, 0x20, 0x79, 0x6f, 0x75, 0x20, 0x75, 0x70,
|
|
})
|
|
|
|
track1Comments := []UserComment{
|
|
{Comment: "title", Value: track1Title},
|
|
{Comment: "encoder", Value: "test-encoder-v1.0"},
|
|
}
|
|
track2Comments := []UserComment{
|
|
{Comment: "title", Value: "Noise Track 2"},
|
|
{Comment: "encoder", Value: "test-encoder-v1.0"},
|
|
}
|
|
data := buildTwoTrackOggContainer(serial1, serial2, track1Comments, track2Comments)
|
|
|
|
reader, err := NewWithOptions(bytes.NewReader(data), WithDoChecksum(false))
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, reader)
|
|
|
|
headersFound, opusTagsFound, err := processPages(reader)
|
|
assert.NoError(t, err)
|
|
|
|
assert.Greater(t, len(headersFound), 0, "Should find at least one header or tag")
|
|
|
|
opusIDCount, opusTagsCount := countHeaderTypes(headersFound)
|
|
|
|
assert.Equal(t, 2, opusIDCount, "Should find exactly 2 OpusHead pages")
|
|
assert.Equal(t, 2, opusTagsCount, "Should find exactly 2 OpusTags pages")
|
|
|
|
assert.Equal(t, 2, len(opusTagsFound), "Should parse 2 OpusTags")
|
|
|
|
assert.Equal(t, "TestVendor", opusTagsFound[0].Vendor)
|
|
assert.Equal(t, "TestVendor", opusTagsFound[1].Vendor)
|
|
|
|
track1 := userCommentsToMap(opusTagsFound[0].UserComments)
|
|
track2 := userCommentsToMap(opusTagsFound[1].UserComments)
|
|
|
|
assert.Equal(t, track1Title, track1["title"])
|
|
assert.Equal(t, "test-encoder-v1.0", track1["encoder"])
|
|
assert.Equal(t, "Noise Track 2", track2["title"])
|
|
assert.Equal(t, "test-encoder-v1.0", track2["encoder"])
|
|
}
|
|
|
|
func TestParseOpusTagsErrors(t *testing.T) {
|
|
makeHeader := func(length int) []byte {
|
|
payload := make([]byte, length)
|
|
copy(payload, []byte(HeaderOpusTags))
|
|
|
|
return payload
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
payload []byte
|
|
errMessage string
|
|
}{
|
|
{
|
|
name: "payload too short",
|
|
payload: []byte("short"),
|
|
errMessage: "payload too short",
|
|
},
|
|
{
|
|
name: "bad signature",
|
|
payload: append([]byte("OpusHead"), make([]byte, 8)...), // length 16, wrong magic
|
|
errMessage: "expected \"OpusTags\"",
|
|
},
|
|
{
|
|
name: "vendor length longer than payload",
|
|
payload: func() []byte {
|
|
payload := makeHeader(20)
|
|
binary.LittleEndian.PutUint32(payload[8:], 10) // vendor length larger than remaining bytes
|
|
|
|
return payload
|
|
}(),
|
|
errMessage: "vendor string",
|
|
},
|
|
{
|
|
name: "unreasonable comment count",
|
|
payload: func() []byte {
|
|
payload := makeHeader(17) // 8 (magic) + 4 (vendor len) + 1 (vendor) + 4 (comment count)
|
|
binary.LittleEndian.PutUint32(payload[8:], 1)
|
|
payload[12] = 'v'
|
|
binary.LittleEndian.PutUint32(payload[13:], 3) // comment count too large for remaining payload
|
|
|
|
return payload
|
|
}(),
|
|
errMessage: "unreasonable comment count",
|
|
},
|
|
{
|
|
name: "payload too short for first comment length",
|
|
payload: func() []byte {
|
|
payload := makeHeader(16) // exactly header + vendor len + comment count, but no room for comment len
|
|
binary.LittleEndian.PutUint32(payload[8:], 0)
|
|
binary.LittleEndian.PutUint32(payload[12:], 1)
|
|
|
|
return payload
|
|
}(),
|
|
errMessage: "comment len 0",
|
|
},
|
|
{
|
|
name: "payload too short for comment data",
|
|
payload: func() []byte {
|
|
payload := makeHeader(20) // room for comment len, but not the comment itself
|
|
binary.LittleEndian.PutUint32(payload[8:], 0)
|
|
binary.LittleEndian.PutUint32(payload[12:], 1)
|
|
binary.LittleEndian.PutUint32(payload[16:], 10) // comment claims 10 bytes, none available
|
|
|
|
return payload
|
|
}(),
|
|
errMessage: "comment 0",
|
|
},
|
|
{
|
|
name: "invalid comment format",
|
|
payload: func() []byte {
|
|
comment := []byte("novalue")
|
|
payload := makeHeader(20 + len(comment)) // 8 magic + 4 vendor len + 4 comment count + 4 comment len + comment
|
|
|
|
binary.LittleEndian.PutUint32(payload[8:], 0) // vendor length
|
|
binary.LittleEndian.PutUint32(payload[12:], 1) // one comment
|
|
binary.LittleEndian.PutUint32(payload[16:], uint32(len(comment))) //nolint:gosec
|
|
copy(payload[20:], comment) // missing '=' separator
|
|
|
|
return payload
|
|
}(),
|
|
errMessage: "invalid comment 0",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tags, err := ParseOpusTags(tc.payload)
|
|
assert.Nil(t, tags)
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, errBadOpusTagsSignature)
|
|
assert.ErrorContains(t, err, tc.errMessage)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseVendorStringMissingCommentCount(t *testing.T) {
|
|
const (
|
|
headerMagicLen = 8
|
|
u32Size = 4
|
|
)
|
|
|
|
// Build payload with just enough room for magic, vendor length, and vendor string
|
|
// but not enough for the comment count field to trigger the vendor error path.
|
|
payload := make([]byte, headerMagicLen+u32Size+1) // 13 bytes total
|
|
copy(payload, []byte(HeaderOpusTags))
|
|
binary.LittleEndian.PutUint32(payload[headerMagicLen:], 1) // vendor length
|
|
payload[headerMagicLen+u32Size] = 'v' // single vendor byte
|
|
|
|
vendor, end, err := parseVendorString(payload, headerMagicLen, u32Size, headerMagicLen+u32Size)
|
|
assert.Empty(t, vendor)
|
|
assert.Zero(t, end)
|
|
assert.ErrorIs(t, err, errBadOpusTagsSignature)
|
|
assert.ErrorContains(t, err, "vendor+comment count")
|
|
}
|
|
|
|
func TestParseOpusHead_EmptyPayload_NoPanic(t *testing.T) {
|
|
_, err := ParseOpusHead([]byte{})
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestParseOpusHead_ChannelMappingSliceOverflow_NoPanic(t *testing.T) {
|
|
const channels uint8 = 235
|
|
|
|
payload := makeOpusHeadWithChannelMapping(channels, 1)
|
|
|
|
h, err := ParseOpusHead(payload)
|
|
assert.NoError(t, err)
|
|
|
|
assert.Equal(t, len(h.ChannelMapping), int(channels))
|
|
}
|
|
|
|
func makeOpusHeadWithChannelMapping(channels uint8, mappingFamily uint8) []byte {
|
|
baseLen := 19
|
|
totalLen := baseLen
|
|
if mappingFamily != 0 {
|
|
totalLen = 21 + int(channels)
|
|
}
|
|
|
|
pack := make([]byte, totalLen)
|
|
copy(pack[0:8], []byte("OpusHead"))
|
|
|
|
pack[8] = 1
|
|
pack[9] = channels
|
|
|
|
binary.LittleEndian.PutUint16(pack[10:12], 0)
|
|
binary.LittleEndian.PutUint32(pack[12:16], 48000)
|
|
binary.LittleEndian.PutUint16(pack[16:18], 0)
|
|
pack[18] = mappingFamily
|
|
|
|
if mappingFamily != 0 {
|
|
pack[19] = channels
|
|
pack[20] = 0
|
|
|
|
for i := 0; i < int(channels); i++ {
|
|
pack[21+i] = uint8(i) //nolint:gosec // G115: test-only, uint8(i) is in range
|
|
}
|
|
}
|
|
|
|
return pack
|
|
}
|