You're building a voice chat. Or an audio messaging feature. Or a live streaming app where the audio quality matters and the bandwidth is tight. You need a codec — something that compresses raw PCM audio into small packets for transmission and decompresses them on the other end.
Opus is that codec. It's the IETF standard for real-time audio (RFC 6716), used by Discord, WhatsApp, Zoom, WebRTC, and essentially every modern voice application. It handles speech and music, adapts to bandwidth from 6 kbps to 510 kbps, and has latency as low as 5ms. Nothing else comes close to its combination of quality, compression, and latency.
Opus is written in C. No pure Dart implementation exists (and writing one would be a multi-year project). FFI is the only path.
Getting libopus
Pre-built binaries
libopus is small (~300KB per ABI) and builds easily. But you can also find pre-built binaries:
Android: Available through the NDK's system libraries on many devices, but not guaranteed. Safer to bundle:
# Build from source
git clone https://gitlab.xiph.org/xiph/opus.git
cd opus
./autogen.sh
./configure --host=aarch64-linux-android --prefix=$PWD/build-arm64
make && make installOr use CMake (cleaner for Android integration):
# In your CMakeLists.txt
add_subdirectory(opus) # If you have the opus source tree
target_link_libraries(your_lib opus)iOS: Build with Xcode or use CocoaPods:
pod 'libopus', '~> 1.4'The C wrapper
Opus's C API is straightforward, but we'll wrap it for cleaner FFI bindings:
// native/src/opus_bridge.c
#include <opus/opus.h>
#include <stdlib.h>
#include <string.h>
// Encoder
OpusEncoder* opus_bridge_encoder_create(
int32_t sample_rate,
int32_t channels,
int32_t application // OPUS_APPLICATION_VOIP, _AUDIO, or _RESTRICTED_LOWDELAY
) {
int error;
OpusEncoder* enc = opus_encoder_create(sample_rate, channels, application, &error);
if (error != OPUS_OK) return NULL;
return enc;
}
int32_t opus_bridge_encode(
OpusEncoder* encoder,
const int16_t* pcm, // Input: raw PCM samples
int32_t frame_size, // Number of samples per channel (e.g., 960 for 20ms at 48kHz)
uint8_t* output, // Output: compressed packet
int32_t max_output_bytes
) {
return opus_encode(encoder, pcm, frame_size, output, max_output_bytes);
}
void opus_bridge_encoder_destroy(OpusEncoder* encoder) {
opus_encoder_destroy(encoder);
}
// Decoder
OpusDecoder* opus_bridge_decoder_create(int32_t sample_rate, int32_t channels) {
int error;
OpusDecoder* dec = opus_decoder_create(sample_rate, channels, &error);
if (error != OPUS_OK) return NULL;
return dec;
}
int32_t opus_bridge_decode(
OpusDecoder* decoder,
const uint8_t* data, // Input: compressed packet
int32_t data_length,
int16_t* pcm, // Output: decoded PCM samples
int32_t frame_size,
int32_t decode_fec // 0 = normal, 1 = forward error correction
) {
return opus_decode(decoder, data, data_length, pcm, frame_size, decode_fec);
}
void opus_bridge_decoder_destroy(OpusDecoder* decoder) {
opus_decoder_destroy(decoder);
}
// Set encoder bitrate
int32_t opus_bridge_set_bitrate(OpusEncoder* encoder, int32_t bitrate) {
return opus_encoder_ctl(encoder, OPUS_SET_BITRATE(bitrate));
}
// Set encoder complexity (0-10, higher = better quality, more CPU)
int32_t opus_bridge_set_complexity(OpusEncoder* encoder, int32_t complexity) {
return opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(complexity));
}Dart FFI bindings
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
final DynamicLibrary _lib = Platform.isAndroid
? DynamicLibrary.open('libopus_bridge.so')
: DynamicLibrary.process();
// Encoder
final _encoderCreate = _lib.lookupFunction<
Pointer<Void> Function(Int32, Int32, Int32),
Pointer<Void> Function(int, int, int)
>('opus_bridge_encoder_create');
final _encode = _lib.lookupFunction<
Int32 Function(Pointer<Void>, Pointer<Int16>, Int32, Pointer<Uint8>, Int32),
int Function(Pointer<Void>, Pointer<Int16>, int, Pointer<Uint8>, int)
>('opus_bridge_encode');
final _encoderDestroy = _lib.lookupFunction<
Void Function(Pointer<Void>),
void Function(Pointer<Void>)
>('opus_bridge_encoder_destroy');
// Decoder
final _decoderCreate = _lib.lookupFunction<
Pointer<Void> Function(Int32, Int32),
Pointer<Void> Function(int, int)
>('opus_bridge_decoder_create');
final _decode = _lib.lookupFunction<
Int32 Function(Pointer<Void>, Pointer<Uint8>, Int32, Pointer<Int16>, Int32, Int32),
int Function(Pointer<Void>, Pointer<Uint8>, int, Pointer<Int16>, int, int)
>('opus_bridge_decode');
final _decoderDestroy = _lib.lookupFunction<
Void Function(Pointer<Void>),
void Function(Pointer<Void>)
>('opus_bridge_decoder_destroy');
final _setBitrate = _lib.lookupFunction<
Int32 Function(Pointer<Void>, Int32),
int Function(Pointer<Void>, int)
>('opus_bridge_set_bitrate');Clean Dart wrapper
/// Opus application modes
enum OpusApplication {
voip(2048), // OPUS_APPLICATION_VOIP
audio(2049), // OPUS_APPLICATION_AUDIO
lowDelay(2051); // OPUS_APPLICATION_RESTRICTED_LOWDELAY
final int value;
const OpusApplication(this.value);
}
class OpusEncoder {
final Pointer<Void> _encoder;
final int sampleRate;
final int channels;
final int frameSize; // Samples per channel per frame
// Pre-allocated native buffers for encoding
late final Pointer<Int16> _pcmBuffer;
late final Pointer<Uint8> _outputBuffer;
static const _maxPacketSize = 4000; // Opus max packet is ~4000 bytes
OpusEncoder._({
required Pointer<Void> encoder,
required this.sampleRate,
required this.channels,
required this.frameSize,
}) : _encoder = encoder {
_pcmBuffer = calloc<Int16>(frameSize * channels);
_outputBuffer = calloc<Uint8>(_maxPacketSize);
}
/// Create an Opus encoder.
/// [sampleRate]: 8000, 12000, 16000, 24000, or 48000
/// [channels]: 1 (mono) or 2 (stereo)
/// [frameDurationMs]: 2.5, 5, 10, 20, 40, or 60
static OpusEncoder create({
int sampleRate = 48000,
int channels = 1,
int frameDurationMs = 20,
OpusApplication application = OpusApplication.voip,
int bitrate = 32000,
}) {
final encoder = _encoderCreate(sampleRate, channels, application.value);
if (encoder == nullptr) {
throw Exception('Failed to create Opus encoder');
}
final frameSize = sampleRate * frameDurationMs ~/ 1000;
final instance = OpusEncoder._(
encoder: encoder,
sampleRate: sampleRate,
channels: channels,
frameSize: frameSize,
);
_setBitrate(encoder, bitrate);
return instance;
}
/// Encode a frame of PCM audio.
/// [pcm]: Int16 samples, length must be frameSize * channels.
/// Returns the compressed Opus packet.
Uint8List encode(Int16List pcm) {
if (pcm.length != frameSize * channels) {
throw ArgumentError(
'Expected ${frameSize * channels} samples, got ${pcm.length}',
);
}
_pcmBuffer.asTypedList(pcm.length).setAll(0, pcm);
final encodedBytes = _encode(
_encoder, _pcmBuffer, frameSize, _outputBuffer, _maxPacketSize,
);
if (encodedBytes < 0) {
throw Exception('Opus encode error: $encodedBytes');
}
return Uint8List.fromList(_outputBuffer.asTypedList(encodedBytes));
}
void dispose() {
_encoderDestroy(_encoder);
calloc.free(_pcmBuffer);
calloc.free(_outputBuffer);
}
}
class OpusDecoder {
final Pointer<Void> _decoder;
final int sampleRate;
final int channels;
final int frameSize;
late final Pointer<Int16> _pcmBuffer;
OpusDecoder._({
required Pointer<Void> decoder,
required this.sampleRate,
required this.channels,
required this.frameSize,
}) : _decoder = decoder {
_pcmBuffer = calloc<Int16>(frameSize * channels);
}
static OpusDecoder create({
int sampleRate = 48000,
int channels = 1,
int frameDurationMs = 20,
}) {
final decoder = _decoderCreate(sampleRate, channels);
if (decoder == nullptr) {
throw Exception('Failed to create Opus decoder');
}
final frameSize = sampleRate * frameDurationMs ~/ 1000;
return OpusDecoder._(
decoder: decoder,
sampleRate: sampleRate,
channels: channels,
frameSize: frameSize,
);
}
/// Decode an Opus packet to PCM samples.
Int16List decode(Uint8List packet) {
final dataPtr = calloc<Uint8>(packet.length);
try {
dataPtr.asTypedList(packet.length).setAll(0, packet);
final decodedSamples = _decode(
_decoder, dataPtr, packet.length, _pcmBuffer, frameSize, 0,
);
if (decodedSamples < 0) {
throw Exception('Opus decode error: $decodedSamples');
}
return Int16List.fromList(
_pcmBuffer.asTypedList(decodedSamples * channels),
);
} finally {
calloc.free(dataPtr);
}
}
void dispose() {
_decoderDestroy(_decoder);
calloc.free(_pcmBuffer);
}
}Putting it together: voice chat
class VoiceChat {
late final OpusEncoder _encoder;
late final OpusDecoder _decoder;
late final WebSocketChannel _ws;
Future<void> start(String serverUrl) async {
_encoder = OpusEncoder.create(
sampleRate: 48000,
channels: 1,
frameDurationMs: 20,
bitrate: 32000,
application: OpusApplication.voip,
);
_decoder = OpusDecoder.create(sampleRate: 48000, channels: 1);
_ws = WebSocketChannel.connect(Uri.parse(serverUrl));
// Listen for incoming audio packets
_ws.stream.listen((data) {
if (data is Uint8List) {
final pcm = _decoder.decode(data);
_playAudio(pcm); // Send to speaker via audio output plugin
}
});
// Start capturing microphone audio
_startRecording();
}
void _onAudioFrame(Int16List pcm) {
// Called by the audio recorder for each 20ms frame
final packet = _encoder.encode(pcm);
_ws.sink.add(packet);
}
void dispose() {
_encoder.dispose();
_decoder.dispose();
_ws.sink.close();
}
}The key numbers for VoIP:
- 48000 Hz, mono, 20ms frames = 960 samples per frame
- 32 kbps bitrate = each 20ms frame compresses to ~80 bytes
- Raw PCM for the same frame: 960 * 2 bytes = 1,920 bytes
- Compression ratio: ~24:1
At 32 kbps, voice quality is excellent. You can go as low as 8 kbps for narrowband speech (phone quality) or up to 128 kbps for high-fidelity music.
Common errors
Encoder returns negative values
Cause: Opus error codes are negative integers. Common ones:
-1(OPUS_BAD_ARG): Wrong frame size, sample rate, or channel count-2(OPUS_BUFFER_TOO_SMALL): Output buffer is too small for the encoded packet-3(OPUS_INTERNAL_ERROR): Something went wrong inside Opus-4(OPUS_INVALID_PACKET): Corrupt packet during decoding
Fix: Check that frame size matches sampleRate * frameDurationMs / 1000. Valid frame durations are 2.5, 5, 10, 20, 40, or 60ms. Valid sample rates are 8000, 12000, 16000, 24000, or 48000.
Audio sounds robotic or garbled
Cause: Encoder and decoder have mismatched parameters (different sample rate, channel count, or frame size). Or packets are arriving out of order and being decoded sequentially.
Fix: Ensure encoder and decoder use identical parameters. If using WebSocket transport, packets can arrive out of order in rare cases — add sequence numbers and a small jitter buffer.
Crackling or popping audio
Cause: Buffer underrun. The audio output is requesting frames faster than they're being decoded, so it plays silence or repeats frames. Common on slow networks.
Fix: Implement a jitter buffer — a small queue (40-100ms worth of frames) that absorbs timing variations. Decode packets into the buffer; the audio output reads from the buffer at a steady rate.
Memory leak from encoder/decoder not being disposed
Cause: OpusEncoder and OpusDecoder hold native memory (calloc allocations + the Opus internal state). Not calling dispose() leaks all of it.
Fix: Dispose in the widget's dispose() or the service's cleanup method. The encoder's internal state is typically ~20KB; the pre-allocated buffers add another ~8KB each. Small per-instance, but it adds up if you create encoders in a loop.
High CPU usage during encoding
Cause: Opus complexity is set too high. The default complexity is 10 (maximum quality), which uses more CPU.
Fix: For mobile VoIP, complexity 5-7 is a good balance. The quality difference between 7 and 10 is hard to hear in speech; the CPU difference is significant:
opus_bridge_set_complexity(encoder, 7);This is Post 17 of the FFI series. Next: Fast Compression With zstd.*