HomeDocumentationAdvanced Flutter and C++ in Dart FFI
Advanced Flutter and C++ in Dart FFI
15

Real-Time Audio With Opus in Flutter

Flutter Opus FFI — Encode and Decode Audio for VoIP and Streaming

April 22, 2026

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:

bash
# 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 install

Or use CMake (cleaner for Android integration):

cmake
# 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:

ruby
pod 'libopus', '~> 1.4'

The C wrapper

Opus's C API is straightforward, but we'll wrap it for cleaner FFI bindings:

c
// 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

dart
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

dart
/// 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

dart
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:

c
opus_bridge_set_complexity(encoder, 7);

This is Post 17 of the FFI series. Next: Fast Compression With zstd.*

Related Topics

flutter opus codecflutter audio encoding ffiflutter voip audioopus dart ffiflutter audio streaminglibopus flutterflutter audio codecflutter real-time audio processing

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.