Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

package opus

import (
"encoding/binary"
"fmt"

"github.com/pion/opus/internal/celt"
)

const (
defaultBitrate = 24000
minBitrate = 6000
maxBitrate = 510000
frame20msNS = 20000000
)

// celtOnlyFullband20msMonoConfig is the TOC config number for CELT-only,
// fullband, 20 ms frames per RFC 6716 Table 2.
const celtOnlyFullband20msMonoConfig = 31
Comment thread
thomas-vilte marked this conversation as resolved.
Outdated

// Encoder encodes PCM into Opus packets.
type Encoder struct {
celtEncoder celt.Encoder
sampleRate int
channels int
bitrate int
complexity int
}

// NewEncoder creates a new Opus encoder.
//
// This initial encoder wrapper supports only 48 kHz, mono, 20 ms CELT-only
// packets. It wraps the internal CELT encoder and emits a complete Opus packet
// with TOC byte and one frame payload.
func NewEncoder(sampleRate, channels int) (Encoder, error) {
Comment thread
thomas-vilte marked this conversation as resolved.
Outdated
encoder := Encoder{
celtEncoder: celt.NewEncoder(),
bitrate: defaultBitrate,
complexity: 0,
}

if err := encoder.Init(sampleRate, channels); err != nil {
return Encoder{}, err
}

return encoder, nil
}

// Init initializes a pre-allocated Opus encoder.
//
// This first public encoder slice supports only 48 kHz mono 20 ms CELT-only
// packets.
func (e *Encoder) Init(sampleRate, channels int) error {
Comment thread
thomas-vilte marked this conversation as resolved.
Outdated
if sampleRate != celtSampleRate {
return errInvalidSampleRate
}

if channels != 1 {
return errInvalidChannelCount
}

e.sampleRate = sampleRate
e.channels = channels
e.bitrate = defaultBitrate
e.complexity = 0
e.celtEncoder = celt.NewEncoder()

return nil
}

// SetBitrate sets the target bitrate in bits per second.
func (e *Encoder) SetBitrate(bps int) error {
if bps < minBitrate || bps > maxBitrate {
return fmt.Errorf("%w: %d", errBitrateOutOfRange, bps)
}

e.bitrate = bps

return nil
}

// SetComplexity stores the requested encoder complexity.
//
// The initial CELT encoder slice does not yet vary behavior by complexity,
// but the public API accepts the standard Opus 0..10 range for future
// expansion.
func (e *Encoder) SetComplexity(complexity int) error {
if complexity < 0 || complexity > 10 {
return fmt.Errorf("%w: %d", errInvalidComplexity, complexity)
}

e.complexity = complexity

return nil
}

// Encode encodes S16LE PCM into a single Opus packet.
//
// The input must contain exactly one 20 ms mono 48 kHz frame.
func (e *Encoder) Encode(in []byte, out []byte) (int, error) {
if len(in)%2 != 0 {
return 0, fmt.Errorf("%w: s16le length %d not a multiple of 2", errInvalidInputLength, len(in))
}

pcm := make([]float32, len(in)/2)
for i := range pcm {
sample := int16(binary.LittleEndian.Uint16(in[i*2:])) //nolint:gosec // G115: little-endian s16 round-trip.
pcm[i] = float32(sample) / 32768
}

return e.EncodeFloat32(pcm, out)
}
Comment thread
thomas-vilte marked this conversation as resolved.

// EncodeFloat32 encodes float PCM into a single Opus packet.
//
// The input must contain exactly one 20 ms mono 48 kHz frame.
func (e *Encoder) EncodeFloat32(in []float32, out []byte) (int, error) {
if e.sampleRate != celtSampleRate {
return 0, errInvalidSampleRate
}

if e.channels != 1 {
return 0, errInvalidChannelCount
}
frameSamples := e.frameSampleCount()
if len(in) != frameSamples*e.channels {
return 0, fmt.Errorf("%w: got %d samples, want %d", errInvalidFrameSize, len(in), frameSamples*e.channels)
}

frameBytes := e.frameBytes()
if frameBytes <= 0 || frameBytes > maxOpusFrameSize {
return 0, fmt.Errorf("%w: %d", errInvalidFrameByteBudget, frameBytes)
}
if len(out) < frameBytes+1 {
return 0, errOutBufferTooSmall
}

payload, err := e.celtEncoder.EncodeFrame(in, frameBytes, 0, e.celtEncoder.Mode().BandCount())
if err != nil {
return 0, err
}
if len(payload) > maxOpusFrameSize {
return 0, errMalformedPacket
}
Comment thread
thomas-vilte marked this conversation as resolved.
if len(out) < len(payload)+1 {
return 0, errOutBufferTooSmall
}

out[0] = byte(e.tocHeader())
copy(out[1:], payload)

return 1 + len(payload), nil
}

func (e *Encoder) tocHeader() tableOfContentsHeader {
header := byte(celtOnlyFullband20msMonoConfig << 3)
header |= byte(frameCodeOneFrame)

return tableOfContentsHeader(header)
}

func (e *Encoder) frameBytes() int {
return int(int64(e.bitrate) * frame20msNS / 1000000000 / 8)
}

func (e *Encoder) frameSampleCount() int {
return int(int64(celtSampleRate) * frame20msNS / 1000000000)
}
153 changes: 153 additions & 0 deletions encoder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package opus

import (
"encoding/binary"
"math"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const encoderTestFrameSampleCount = 960

func TestNewEncoder(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

assert.Equal(t, 48000, encoder.sampleRate)
assert.Equal(t, 1, encoder.channels)
assert.Equal(t, defaultBitrate, encoder.bitrate)

_, err = NewEncoder(16000, 1)
assert.ErrorIs(t, err, errInvalidSampleRate)

_, err = NewEncoder(48000, 2)
assert.ErrorIs(t, err, errInvalidChannelCount)
}

func TestEncodeFloat32RoundTrip(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

decoder, err := NewDecoderWithOutput(48000, 1)
require.NoError(t, err)

pcm := testEncoderSineFloat32()
packet := make([]byte, 256)

n, err := encoder.EncodeFloat32(pcm, packet)
require.NoError(t, err)
require.Positive(t, n)

assert.Equal(t, byte(celtOnlyFullband20msMonoConfig<<3)|byte(frameCodeOneFrame), packet[0])

out := make([]float32, encoderTestFrameSampleCount)
bandwidth, isStereo, err := decoder.DecodeFloat32(packet[:n], out)
require.NoError(t, err)

assert.Equal(t, BandwidthFullband, bandwidth)
assert.False(t, isStereo)
assert.Greater(t, vectorEnergyFloat32(out), 1e-6)
}

func TestEncodeS16LERoundTrip(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

decoder, err := NewDecoderWithOutput(48000, 1)
require.NoError(t, err)

pcm := testEncoderSineS16LE()
packet := make([]byte, 256)

n, err := encoder.Encode(pcm, packet)
require.NoError(t, err)
require.Positive(t, n)

out := make([]float32, encoderTestFrameSampleCount)
_, _, err = decoder.DecodeFloat32(packet[:n], out)
require.NoError(t, err)

assert.Greater(t, vectorEnergyFloat32(out), 1e-6)
}

func TestEncodeRejectsInvalidS16LEInputLength(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

_, err = encoder.Encode(make([]byte, 3), make([]byte, 64))
assert.ErrorIs(t, err, errInvalidInputLength)
}

func TestEncodeFloat32RejectsInvalidFrameSize(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

_, err = encoder.EncodeFloat32(make([]float32, encoderTestFrameSampleCount-1), make([]byte, 64))
assert.ErrorIs(t, err, errInvalidFrameSize)
}

func TestEncodeRejectsSmallOutputBuffer(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

pcm := testEncoderSineFloat32()
packet := make([]byte, 8)

_, err = encoder.EncodeFloat32(pcm, packet)
assert.ErrorIs(t, err, errOutBufferTooSmall)
}

func TestSetBitrate(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

require.NoError(t, encoder.SetBitrate(32000))
assert.Equal(t, 32000, encoder.bitrate)

assert.Error(t, encoder.SetBitrate(1000))
assert.Error(t, encoder.SetBitrate(999999))
}

func TestSetComplexity(t *testing.T) {
encoder, err := NewEncoder(48000, 1)
require.NoError(t, err)

require.NoError(t, encoder.SetComplexity(10))
assert.Equal(t, 10, encoder.complexity)

assert.Error(t, encoder.SetComplexity(-1))
assert.Error(t, encoder.SetComplexity(11))
}
Comment thread
thomas-vilte marked this conversation as resolved.

func testEncoderSineFloat32() []float32 {
pcm := make([]float32, encoderTestFrameSampleCount)
for i := range pcm {
pcm[i] = float32(math.Sin(2 * math.Pi * 440 * float64(i) / 48000))
}

return pcm
}

func testEncoderSineS16LE() []byte {
pcm := make([]byte, encoderTestFrameSampleCount*2)
for i := range encoderTestFrameSampleCount {
amplitude := 16000.0
sample := int16(amplitude * math.Sin(2*math.Pi*440*float64(i)/48000))
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(sample)) //nolint:gosec // G115: little-endian s16 round-trip.
}

return pcm
}

func vectorEnergyFloat32(x []float32) float64 {
var e float64
for _, v := range x {
e += float64(v * v)
}

return math.Sqrt(e)
}
10 changes: 10 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,14 @@ var (
errOutBufferTooSmall = errors.New("out isn't large enough")

errMalformedPacket = errors.New("malformed packet")

errBitrateOutOfRange = errors.New("bitrate out of range")

errInvalidComplexity = errors.New("invalid complexity")

errInvalidInputLength = errors.New("invalid input length")

errInvalidFrameSize = errors.New("invalid frame size")

errInvalidFrameByteBudget = errors.New("invalid frame byte budget")
)
Loading