flutter_soloud pull-buffer streaming
In flutter_soloud, "pull" streaming means the engine owns the data demand: you create an AudioSource with setPullBufferStream(...) declaring the total encoded size, and the engine calls your onMoreDataIsNeeded(offset) callback whenever it wants the next chunk of encoded bytes at a specific byte offset. You fetch the bytes (HTTP Range request, file read, decryptor, custom protocol) and hand them back via addPullBufferDataStream. Decoded audio lives in a fixed-size circular buffer, so memory stays bounded regardless of source size — a 10 GB file can play with a 5 MB buffer. Seeking works: after SoLoud.instance.seek(handle, pos), the engine re-issues onMoreDataIsNeeded at the byte offset for the new position. One pull stream = one playback voice.
Minimal example
import 'package:flutter/foundation.dart';
import 'package:flutter_soloud/flutter_soloud.dart';
Future<void> playHugeFile(
Uint8List Function(int offset, int length) fetchRange,
int totalBytes,
) async {
await SoLoud.instance.init(); // must complete before any other call
// Declare first: the callback closure must reference the source, and a
// local can't be referenced inside its own initializer.
late final AudioSource source;
source = SoLoud.instance.setPullBufferStream(
audioSizeBytes: totalBytes, // REQUIRED, non-zero, known upfront
bufferSizeBytes: 5 * 1024 * 1024, // decoded circular buffer, ~14 s stereo f32
bufferTriggerPosition: 0.8, // default; ask for more when 20% ahead remains
format: BufferType.auto, // default; detects MP3/OGG Opus/OGG Vorbis/FLAC/WAV
onAudioDuration: (seconds) {/* total duration is now known */},
onMetadata: (metadata) {/* detected format, sample rate, channels */},
onMoreDataIsNeeded: (offset) {
const chunkSize = 64 * 1024;
final end = (offset + chunkSize).clamp(0, totalBytes);
if (offset < 0 || offset >= totalBytes) return;
SoLoud.instance.addPullBufferDataStream(
source,
fetchRange(offset, end - offset),
offset: offset,
);
},
);
final handle = SoLoud.instance.play(source);
// Seeking is free: the engine re-requests data at the new offset.
SoLoud.instance.seek(handle, const Duration(minutes: 2));
// No end-of-stream call exists: when sequential data reaches
// audioSizeBytes the engine ends the stream itself.
// Dispose on teardown: SoLoud.instance.deinit();
}
The API shape
All on SoLoud.instance (lib/src/soloud.dart). Real signatures:
AudioSource setPullBufferStream({
int bufferSizeBytes = 1024 * 1024 * 10, // 10 MB
double bufferTriggerPosition = 0.8,
int sampleRate = 44100,
Channels channels = Channels.stereo,
BufferType format = BufferType.auto,
int audioSizeBytes = 0, // 0 throws SoLoudCppException(invalidParameter)
bool autoDispose = false,
void Function(bool isBuffering, int handle, double time)? onBuffering,
void Function(AudioMetadata)? onMetadata,
void Function(double duration)? onAudioDuration,
void Function(int offset)? onMoreDataIsNeeded,
});
void resetPullBufferStream(AudioSource sound);
PlayerErrors addPullBufferDataStream(
AudioSource source,
Uint8List audioChunk, {
int offset = 0, // byte offset in the ENCODED stream
});
({PlayerErrors error, Duration startTime, Duration endTime})
getPullBufferTimeRange(AudioSource source);
setPullBufferStreamreturns anAudioSourcesynchronously (unlikeloadAsset/loadFile, which are async) and throwsSoLoudCppExceptionon error. Play it with the usual synchronousSoLoud.instance.play(source).audioSizeBytesis the total size of the encoded source and is mandatory upfront (serverContent-Length, file length). It drives duration calculation and end-of-stream detection; for Ogg formats the engine also uses it to request the tail chunk for duration probing.bufferTriggerPositionis about how much decoded audio remains ahead of the playhead, not absolute fill level:0.8fires the request when the playhead is within the last 20% of the decoded window. Values outside[0.0, 1.0]are clamped.sampleRate/channelsare ignored whenformatisBufferType.auto(the common case); they only matter for raw PCM formats (f32le,s8,s16le,s32le).BufferType.opusis deprecated — useauto.addPullBufferDataStreamtakes a namedoffset.offset: 0means "append the next sequential chunk" — always pass the real requested offset from the callback instead. Empty chunks are a no-op returningPlayerErrors.noError.getPullBufferTimeRangereturns the decoded window currently in the circular buffer asDurations; the playhead normally sits nearstartTime. Use it to render a buffered-range indicator and to detect "smart" seeks (target inside the window = no refetch needed).- Divergence from audioplayers/just_audio: there is no
setUrl, noplayer.durationStream, noAudioSource.uri. Duration arrives asynchronously through theonAudioDurationcallback (seconds asdouble), and position is polled withSoLoud.instance.getPosition(handle). There is noSourceobject per URL — you are the transport.
Pull vs push
flutter_soloud has two streaming APIs; pick by source shape:
- Pull (
setPullBufferStream) — the source is seekable/addressable and you know its size: HTTP servers with Range support, local files, encrypted containers. Supports arbitrary seek and bounded memory. This skill. - Push (
setBufferStream+addAudioDataStream+setDataIsEnded) — data arrives sequentially with no random access: live radio, microphone feeds, procedurally generated audio. See the sibling skillflutter_soloud-streaming.
Traps
audioSizeBytesof 0 throws.setPullBufferStreamcallsSoLoudCppException.fromPlayerError(PlayerErrors.invalidParameter)when it is 0. Do a HEAD request / stat the file first. A server that omitsContent-LengthandAccept-Ranges: bytesis a poor fit for pull.- Do not invent an end-of-stream call. There is no
setDataIsEndedfor pull streams (that belongs to the push API). The stream ends automatically once sequential data reachesaudioSizeBytes. - The callback can re-fire for the same offset and fire out of order. Deduplicate in-flight requests (
_pendingOffsets.add(offset)pattern) and guardoffset < 0 || offset >= audioSizeBytes— the engine may probe the tail for Ogg duration.addPullBufferDataStreamaccepts out-of-order chunks precisely for this. - Seek invalidates your dedup state. After an out-of-buffer
seek(handle, pos), the engine re-requests offsets you may have already served. Clear your "already fetched" set on such seeks, or the re-requested bytes never arrive and playback stalls silently. Seeks inside the currentgetPullBufferTimeRangewindow are free and need no refetch. - One voice per stream. A pull
AudioSourceis tied to a single playing instance; create a new stream for a new voice.PlayerErrors.invalidPullBufferState/hashIsNotAPullBufferStreammean you called a pull method on the wrong source or at the wrong time. - Web: replacing a stream. Disposing the old pull source while the audio thread still processes it can crash/deadlock. The example works around this by leaving the old source in the engine and letting callbacks capture their own
currentSource, dropping callbacks whose source is stale (currentSource != _source). Web also requires the server to send CORS headers for Range requests. - Chunk size is yours to choose. The engine only tells you where, not how much. ~64 KB keeps HTTP overhead reasonable and the trigger firing; chunks larger than the whole file defeat the pull model (everything decodes in the first callback).
- Playhead lives at the buffer's edge. The decoded window slides forward; the playhead stays near
startTime, not centered in a large pre-buffered region. Don't design UX around a just_audio-style bufferedPosition far ahead of playback. - Errors from
addPullBufferDataStreamthrow. It returnsPlayerErrorsbut throwsSoLoudCppExceptionon any non-noErrorresult — wrap feeds in try/catch and stop feeding a broken stream.
More depth
Full working demos in the plugin repo under example/lib/pull_buffer/:
http_range_stream.dart— HTTP Range-request streaming with HEAD-based size discovery, offset dedup, smart-seek detection.file_stream.dart— streaming a huge local file/asset in chunks; shows the stale-callback guard pattern for web.seek_bar.dart—PullBufferSeekBarwidget visualizing the sliding decoded window fromgetPullBufferTimeRange.
Reference doc: docs/advanced/pull_buffer_streaming.mdx (flutter_soloud_docs repo). Tests worth reading for edge cases: example/tests/tests/pull_buffer_seek_test.dart, pull_buffer_in_buffer_seek_test.dart, pull_buffer_range_test.dart.
Keeping this skill current
This skill ships inside the flutter_soloud package, so upgrading flutter_soloud can carry a newer revision of it than the copy installed in the project. To check, run:
dart run flutter_soloud:skills --check
It reports the installed and bundled skill versions and exits non-zero when an update is available. Offer to update with dart run flutter_soloud:skills (which touches only the skills, never pubspec or build files).