Optimizing my Opus to CAF Converter in Go
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:
Read an Opus file
Parse its structure
Extract the audio data
Create a new CAF file with appropriate headers
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 bufferMinor Code Improvements
While optimizing, I also improved the code structure:
Pre-allocated slices with reasonable capacities:
// Before
packetSizes := []uint64{}
// After
packetSizes := make([]uint64, 0, 1024) // Pre-allocate for ~1MB of audioResults
The performance improvements were significant:
Original version: 25 seconds for a 4MB file
Optimized version: 15 seconds for the same file
Memory allocations reduced by approximately 30%
CPU usage reduced by approximately 40%
Key Takeaways
Buffer Reuse Matters: In Go, allocating and garbage collecting many small buffers can be expensive. Reusing buffers can significantly improve performance.
Right-Size Your Buffers: Default buffer sizes aren't always optimal. Understanding your data patterns helps in choosing appropriate buffer sizes.
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:
Parallel processing of audio chunks
SIMD optimizations for audio processing
Memory pooling for very large files
The full code is available on GitHub, and I welcome contributions and suggestions for further improvements.
Happy coding! 🚀