Mixer Output Capture

Capture the master mixer output as a stream for recording, processing, or streaming

Overview

flutter_soloud can capture the master mixer output as a stream of raw audio data. This is useful for:

  • Recording the final mixed audio to a file
  • Streaming the mixed output to another service or device
  • Processing the master output in real-time

The captured data is read from a circular buffer managed by the native engine. The stream emits chunks as they become available, and any remaining data is flushed when capture stops.

Basic Workflow

1. Start the Capture Stream

Call startMixerOutputStream() after initializing SoLoud:

dart
await SoLoud.instance.init();

final stream = SoLoud.instance.startMixerOutputStream(
  format: MixerOutputFormat.pcmF32le,
  sampleRate: -1,                 // Use the engine sample rate
  channels: -1,                   // Use the engine channel count
  bufferSizeBytes: 1024 * 1024,   // 1 MB circular buffer
  notificationThresholdBytes: 4096, // Emit a chunk every 4 KB (compressed/PCM threshold mode)
);

2. Listen to the Stream

Subscribe to the stream and write the chunks to a file, socket, or any other consumer:

dart
final subscription = stream.listen((Uint8List chunk) {
  // Append the chunk to a file or send it over the network
  fileSink.add(chunk);
});

3. Play Sounds as Usual

While the capture is running, all sounds played through the engine are mixed into the captured output:

dart
final sound = await SoLoud.instance.loadAsset('assets/music.mp3');
await SoLoud.instance.play(sound);

4. Stop the Capture

When you are done, call stopMixerOutputStream() to stop the capture and flush any remaining data:

dart
SoLoud.instance.stopMixerOutputStream();
await subscription.cancel();
await fileSink.close();

Output Format

The MixerOutputFormat enum controls the format of the captured data:

FormatDescription
MixerOutputFormat.pcmF32le32-bit floating point PCM, little-endian (default)
MixerOutputFormat.pcmS88-bit signed PCM
MixerOutputFormat.pcmS16le16-bit signed PCM, little-endian
MixerOutputFormat.pcmS32le32-bit signed PCM, little-endian
MixerOutputFormat.opusOpus encoded audio
MixerOutputFormat.vorbisVorbis encoded audio
MixerOutputFormat.flacFLAC encoded audio
MixerOutputFormat.wavWAV encoded audio (16-bit PCM in a RIFF/WAVE container). Streams incrementally, but the header size fields must be patched after capture stops.

WAV Format Caveat

When using MixerOutputFormat.wav, the stream behaves like the other formats: you receive a 44-byte header at the start of the stream, followed by PCM chunks as they are produced. However, the WAV container stores the total PCM size in the RIFF and data chunk headers, so those fields are placeholders until the capture ends.

To produce a valid WAV file, you must:

  1. Capture the stream as usual.
  2. Stop the capture with stopMixerOutputStream().
  3. Call SoLoud.instance.getMixerOutputWavHeader() to get the updated 44-byte header.
  4. Overwrite the first 44 bytes of the saved file with that header.
dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_soloud/flutter_soloud.dart';

Future<void> recordWavMixerOutput(String outputPath) async {
  await SoLoud.instance.init();

  final file = File(outputPath);
  final raf = file.openSync(mode: FileMode.writeOnly);

  final stream = SoLoud.instance.startMixerOutputStream(
    format: MixerOutputFormat.wav,
    bufferSizeBytes: 1024 * 1024,
    notificationThresholdBytes: 4096,
  );

  final subscription = stream.listen((Uint8List chunk) {
    raf.writeFromSync(chunk);
  });

  // Play a sound
  final sound = await SoLoud.instance.loadAsset('assets/music.mp3');
  await SoLoud.instance.play(sound);

  // Record for 5 seconds
  await Future<void>.delayed(const Duration(seconds: 5));

  SoLoud.instance.stopMixerOutputStream();
  await subscription.cancel();
  await raf.close();

  // Patch the WAV header with the final sizes.
  final header = SoLoud.instance.getMixerOutputWavHeader();
  if (header.length == 44) {
    final patchRaf = file.openSync(mode: FileMode.writeOnlyAppend)
      ..setPositionSync(0)
      ..writeFromSync(header);
    await patchRaf.close();
  }
}

Parameters

ParameterDescription
formatThe desired output format. PCM formats are always available; compressed formats require Xiph libraries.
sampleRateThe sample rate of the captured audio. Use -1 to match the engine sample rate.
channelsThe number of channels. Use -1 to match the engine channel count.
bufferSizeBytesTotal size of the circular capture buffer in bytes. A larger buffer allows more time between reads.
notificationThresholdBytesNumber of bytes that must be available before a chunk is emitted. Used for compressed formats and for PCM when chunkPCMFrames is -1. Smaller values give lower latency but more callbacks.
chunkPCMFramesFixed number of PCM frames per emitted chunk. PCM only; must be -1 or at least 2048. The emitted byte size is chunkPCMFrames * channels * bytesPerSample.

Check if Capture is Running

You can check whether the mixer output capture is currently active:

dart
if (SoLoud.instance.isMixerOutputStreamRunning) {
  // Capture is active
}

Important Notes

  • The captured stream is a broadcast stream. Multiple listeners can subscribe to it, but each listener receives the same chunks independently.
  • When the capture is stopped, any remaining data in the circular buffer is flushed synchronously. This ensures that compressed formats (such as Opus or FLAC) are properly finalized.
  • For PCM formats, you can request fixed-size chunks by setting chunkPCMFrames. This is useful for network streaming or any consumer that needs a stable frame size. Compressed formats always use notificationThresholdBytes because the encoder determines the packet boundaries.
  • If the stream is already running, calling startMixerOutputStream() again returns the existing stream.
  • The capture stream is automatically closed when SoLoud.instance.deinit() is called.

Fixed-size PCM Chunks

For live streaming or network audio, it is often easier to consume a fixed number of frames per chunk instead of variable-sized threshold chunks. Use chunkPCMFrames to emit chunks of exactly the requested size:

dart
final stream = SoLoud.instance.startMixerOutputStream(
  format: MixerOutputFormat.pcmS16le,
  sampleRate: 44100,
  channels: 2,
  chunkPCMFrames: 2048, // Each chunk is 2048 stereo frames
);

Each emitted chunk is exactly chunkPCMFrames * channels * bytesPerSample bytes, except for the final tail that is flushed when stopMixerOutputStream() is called. chunkPCMFrames must be -1 or at least 2048. It is only valid for PCM formats; for compressed formats, use notificationThresholdBytes instead.

Main-thread Latency and Capture Gaps

The native capture thread notifies Dart when a chunk is ready and waits for Dart to advance the read position. If the Dart thread is busy with synchronous work while a chunk is ready, the notification is delayed and the next chunk interval grows by roughly the time Dart was blocked. For example, a 500 ms main-thread stall produces a ~500 ms gap between PCM chunks.

To avoid gaps in the captured stream, keep synchronous work off the Dart thread while capture is running, or use a larger buffer if occasional bursts are acceptable. It is also possible to use an Isolate to run capture flow in a separate thread. The example/lib/mixer_capture/isolate_capture_test.dart example demonstrates how to use the capture flow inside an Isolate other than the main.

Simple Example

dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_soloud/flutter_soloud.dart';

Future<void> recordMixerOutput(String outputPath) async {
  await SoLoud.instance.init();

  final file = File(outputPath);
  final sink = file.openWrite();

  final stream = SoLoud.instance.startMixerOutputStream(
    format: MixerOutputFormat.pcmS16le,
    bufferSizeBytes: 1024 * 1024,
    notificationThresholdBytes: 4096,
  );

  final subscription = stream.listen((Uint8List chunk) {
    sink.add(chunk);
  });

  // Play a sound
  final sound = await SoLoud.instance.loadAsset('assets/music.mp3');
  await SoLoud.instance.play(sound);

  // Record for 5 seconds
  await Future<void>.delayed(const Duration(seconds: 5));

  SoLoud.instance.stopMixerOutputStream();
  await subscription.cancel();
  await sink.close();
}