Nabil Mosharraf HossainWriting3D & Games

Optimizing my Opus to CAF Converter in Go

10 February 2025 · 3 min read
Originally published on Substack in February 2025.

Ever wonder why Discord switched to Opus audio? It offers better quality than MP3 at half the size - meaning superior audio with less bandwidth! But one specific challange is that any Apple OS does not support Opus natively. I actually had to create a golang library to convert Opus files to Apple's Core Audio Format (CAF) without FFMPEG dependencies. While the initial implementation worked, it was taking about 25 seconds to process a 4MB file. That felt slow. Here's how I improved the performance by 40%, bringing the processing time down to 15 seconds.

The Initial Problem

The converter needed to:

  1. Read an Opus file

  2. Parse its structure

  3. Extract the audio data

  4. Create a new CAF file with appropriate headers

  5. Write the converted data

The bottleneck wasn't immediately obvious, so I started by examining the most intensive operations: reading the Opus file and writing the CAF file.

Investigation: Memory Allocations

Looking at the original code, I noticed we were allocating new buffers for every page read:

func (o *OggReader) ParseNextPage() ([][]byte, *OggPageHeader, error) {
    h := make([]byte, pageHeaderLen)
    n, err := io.ReadFull(o.stream, h)
    // ...
    sizeBuffer := make([]byte, pageHeader.segmentsCount)
    // ...
    currentSegment = make([]byte, segmentSize)
}

Each call to ParseNextPage() was allocating multiple new slices. For a large audio file with thousands of pages, this meant thousands of allocations.

First Optimization: Buffer Reuse

The first optimization was to reuse a single large buffer for the entire page reading process. Instead of allocating new buffers for each page, we now allocate one buffer when creating the OggReader:

type OggReader struct {
    stream     io.Reader
    pageBuffer []byte  // New reusable buffer
}

func NewWith(in io.Reader) (*OggReader, *OggHeader, error) {
    reader := &OggReader{
        stream:     in,
        pageBuffer: make([]byte, maxPageSize),  // One-time allocation
    }
    // ...
}

This change alone eliminated thousands of allocations during the conversion process.

Second Optimization: Buffered I/O

The second optimization came from looking at our I/O patterns. We were using the default buffer sizes for bufio.Reader and bufio.Writer:

// Before
bufferedReader := bufio.NewReader(inFile)
bufferedWriter := bufio.NewWriter(outFile)

Audio files tend to have larger chunks of data, so increasing these buffer sizes made sense:

// After
bufferedReader := bufio.NewReaderSize(inFile, 32*1024)  // 32KB buffer
bufferedWriter := bufio.NewWriterSize(outFile, 32*1024) // 32KB buffer

Minor Code Improvements

While optimizing, I also improved the code structure:

  1. Pre-allocated slices with reasonable capacities:

// Before
packetSizes := []uint64{}
// After
packetSizes := make([]uint64, 0, 1024)  // Pre-allocate for ~1MB of audio

Results

The performance improvements were significant:

Key Takeaways

  1. Buffer Reuse Matters: In Go, allocating and garbage collecting many small buffers can be expensive. Reusing buffers can significantly improve performance.

  2. Right-Size Your Buffers: Default buffer sizes aren't always optimal. Understanding your data patterns helps in choosing appropriate buffer sizes.

  3. Profile First: While these optimizations worked well, they were based on actual profiling data. Always measure before optimizing.

What's Next?

While a 40% performance improvement is significant, there's always room for more optimization. Future improvements might include:

The full code is available on GitHub, and I welcome contributions and suggestions for further improvements.

Happy coding! 🚀