Parse opus mappings

This commit is contained in:
Joe Turki
2025-12-11 16:21:25 +02:00
parent 0bb1745c51
commit 19e6521edb
2 changed files with 295 additions and 24 deletions

View File

@@ -7,6 +7,7 @@ package oggreader
import (
"encoding/binary"
"errors"
"fmt"
"io"
)
@@ -14,20 +15,20 @@ const (
pageHeaderTypeBeginningOfStream = 0x02
pageHeaderSignature = "OggS"
idPageSignature = "OpusHead"
pageHeaderLen = 27
idPagePayloadLength = 19
idPageSignature = "OpusHead"
idPageBasePayloadLength = 19
pageHeaderLen = 27
)
var (
errNilStream = errors.New("stream is nil")
errBadIDPageSignature = errors.New("bad header signature")
errBadIDPageType = errors.New("wrong header, expected beginning of stream")
errBadIDPageLength = errors.New("payload for id page must be 19 bytes")
errBadIDPagePayloadSignature = errors.New("bad payload signature")
errShortPageHeader = errors.New("not enough data for payload header")
errChecksumMismatch = errors.New("expected and actual checksum do not match")
errNilStream = errors.New("stream is nil")
errBadIDPageSignature = errors.New("bad header signature")
errBadIDPageType = errors.New("wrong header, expected beginning of stream")
errBadIDPageLength = errors.New("payload for id page must be 19 bytes")
errBadIDPagePayloadSignature = errors.New("bad payload signature")
errShortPageHeader = errors.New("not enough data for payload header")
errChecksumMismatch = errors.New("expected and actual checksum do not match")
errUnsupportedChannelMappingFamily = errors.New("unsupported channel mapping family")
)
// OggReader is used to read Ogg files and return page payloads.
@@ -43,12 +44,17 @@ type OggReader struct {
//
// https://tools.ietf.org/html/rfc7845.html#section-3
type OggHeader struct {
ChannelMap uint8
Channels uint8
OutputGain uint16
PreSkip uint16
SampleRate uint32
Version uint8
ChannelMap uint8
Channels uint8
OutputGain uint16
PreSkip uint16
SampleRate uint32
Version uint8
StreamCount uint8
CoupledCount uint8
// ChannelMapping we store it as a string to be comparable (maps/struct equality)
// while still holding raw bytes.
ChannelMapping string
}
// OggPageHeader is the metadata for a Page
@@ -97,23 +103,40 @@ func (o *OggReader) readHeaders() (*OggHeader, error) {
return nil, err
}
header := &OggHeader{}
if err := validatePageHeader(pageHeader, payload); err != nil {
return nil, err
}
header := parseBasicHeaderFields(payload)
if err := parseChannelMapping(header, payload); err != nil {
return nil, err
}
return header, nil
}
func validatePageHeader(pageHeader *OggPageHeader, payload []byte) error {
if string(pageHeader.sig[:]) != pageHeaderSignature {
return nil, errBadIDPageSignature
return errBadIDPageSignature
}
if pageHeader.headerType != pageHeaderTypeBeginningOfStream {
return nil, errBadIDPageType
return errBadIDPageType
}
if len(payload) != idPagePayloadLength {
return nil, errBadIDPageLength
if len(payload) < idPageBasePayloadLength {
return errBadIDPageLength
}
if s := string(payload[:8]); s != idPageSignature {
return nil, errBadIDPagePayloadSignature
return errBadIDPagePayloadSignature
}
return nil
}
func parseBasicHeaderFields(payload []byte) *OggHeader {
header := &OggHeader{}
header.Version = payload[8]
header.Channels = payload[9]
header.PreSkip = binary.LittleEndian.Uint16(payload[10:12])
@@ -121,7 +144,44 @@ func (o *OggReader) readHeaders() (*OggHeader, error) {
header.OutputGain = binary.LittleEndian.Uint16(payload[16:18])
header.ChannelMap = payload[18]
return header, nil
return header
}
// parseChannelMapping parses channel mapping data based on the channel map family.
// https://datatracker.ietf.org/doc/html/rfc7845#section-5.1.1
// family mapping of 2 and 3 are defined in https://datatracker.ietf.org/doc/html/rfc8486
func parseChannelMapping(header *OggHeader, payload []byte) error {
switch header.ChannelMap {
case 0:
return validatePayloadLength(payload, idPageBasePayloadLength)
case 1, 2, 255:
return parseExtendedChannelMapping(header, payload)
case 3:
return fmt.Errorf("%w: ambisonics family type 3 is not supported", errUnsupportedChannelMappingFamily)
default:
return errUnsupportedChannelMappingFamily
}
}
func validatePayloadLength(payload []byte, expectedLen int) error {
if len(payload) != expectedLen {
return errBadIDPageLength
}
return nil
}
func parseExtendedChannelMapping(header *OggHeader, payload []byte) error {
expectedPayloadLen := 21 + int(header.Channels)
if err := validatePayloadLength(payload, expectedPayloadLen); err != nil {
return err
}
header.StreamCount = payload[19]
header.CoupledCount = payload[20]
header.ChannelMapping = string(payload[21 : 21+header.Channels])
return nil
}
// ParseNextPage reads from stream and returns Ogg page payload, header,

View File

@@ -26,6 +26,90 @@ func buildOggContainer() []byte {
}
}
// 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))}
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))}
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)
@@ -102,4 +186,131 @@ func TestOggReader_ParseErrors(t *testing.T) {
_, _, err := NewWith(bytes.NewReader(ogg))
assert.Equal(t, err, errChecksumMismatch)
})
t.Run("Invalid Multichannel ID Page Payload Length", func(t *testing.T) {
_, _, err := newWith(bytes.NewReader(buildSurroundOggContainerShort()), false)
assert.Equal(t, err, errBadIDPageLength)
})
t.Run("Unsupported Channel Mapping Family", func(t *testing.T) {
_, _, err := newWith(bytes.NewReader(buildUnknownMappingFamilyContainer(4, 2)), false)
assert.Equal(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 {
tc := tc
t.Run(tc.name, func(t *testing.T) {
reader, header, err := newWith(bytes.NewReader(
buildChannelMappingFamilyContainer(1, tc.channels, tc.streams, tc.coupled, tc.channelMap),
), false)
assert.NoError(t, err)
assert.NotNil(t, reader)
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 {
tc := tc
t.Run(tc.name, func(t *testing.T) {
reader, header, err := newWith(bytes.NewReader(
buildChannelMappingFamilyContainer(tc.mappingFamily, tc.channels, tc.streams, tc.coupled, tc.channelMap),
), false)
assert.NoError(t, err)
assert.NotNil(t, reader)
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 {
tc := tc
t.Run(tc.name, func(t *testing.T) {
reader, header, err := newWith(bytes.NewReader(
buildChannelMappingFamilyContainer(tc.mappingFamily, tc.channels, tc.streams, tc.coupled, tc.channelMap),
), false)
assert.NoError(t, err)
assert.NotNil(t, reader)
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)
})
}
}