| name | flutter_soloud-scheduling |
| version | 1 |
| description | Teaches sample-accurate audio scheduling in flutter_soloud with playClocked (sounds anchored to a running "physics time" clock) and playScheduled (absolute engine-time scheduling with sample-accurate auto-stop), plus the clock helpers getEngineTime/getPlayheadTime/getOutputLatency/resetStreamTime and stopScheduled/fadeScheduled. Use when the user asks for a metronome, step sequencer, rhythm game, pre-computed music timeline/score, evenly spaced repeated SFX, or complains that periodic play() calls clump or drift. |
Sample-accurate scheduling
Plain SoLoud.instance.play() starts a sound at the next output buffer boundary: everything launched within the same buffer starts at the exact same sample ("clumps"), and periodic sounds get an irregular rhythm that is a multiple of the buffer size (~46 ms at the default 2048-sample buffer / 44100 Hz). flutter_soloud fixes this with two scheduling APIs. Unlike audioplayers/just_audio, the unit of timing here is the audio clock in samples, not wall-clock timers — you do not compensate with Timer precision tricks; you pass a Duration and the engine places the start sample-accurately.
playClocked(sound, soundTime, ...) — you feed your own monotonically increasing "physics time"; the first call anchors it to the audio clock with a built-in ~2-output-buffer lead, subsequent calls are placed relative to that anchor. Best for evenly spaced, open-ended beats driven by your own loop (metronome, machine-gun fire, footsteps).
playScheduled(sound, atTime, {duration, ...}) — absolute time on the engine's own clock (getEngineTime()), schedulable arbitrarily far ahead; optional duration gives a sample-accurate auto-stop. Best for pre-computed timelines: scores, manifests, cutscenes.
Minimal example
Metronome with playClocked (the canonical pattern, from example/lib/metronome/metronome.dart):
import 'dart:async';
import 'package:flutter_soloud/flutter_soloud.dart';
Future<void> main() async {
await SoLoud.instance.init(); // default bufferSize 2048 is fine now
final tick = await SoLoud.instance.loadAsset('assets/tick.wav');
var physicsTime = Duration.zero;
Timer.periodic(const Duration(milliseconds: 100), (_) {
physicsTime += const Duration(milliseconds: 100);
SoLoud.instance.playClocked(tick, physicsTime);
});
}
Pre-computed timeline with playScheduled:
final anchor = SoLoud.instance.getEngineTime() +
const Duration(milliseconds: 200); // small lead
for (final note in score) {
final atTime = anchor + note.offset;
SoLoud.instance.playScheduled(note.source, atTime, duration: note.length);
}
The API shape
All methods are on the SoLoud singleton (SoLoud.instance), synchronous, and return immediately; scheduled/clocked variants return a SoundHandle like play().
SoundHandle playClocked(
AudioSource sound,
Duration soundTime, {
int busId = 0,
double volume = 1,
double pan = 0,
double scale = 1,
bool looping = false,
Duration loopingStartAt = Duration.zero,
Duration? loopingEndAt,
int? loopingStartOffsetAt,
int? loopingEndOffsetAt,
});
soundTime is your clock — any monotonically increasing Duration (accumulated ideal tick times, a game physics clock, ...). It is not engine time and not wall time.
- First call after init/
resetStreamTime() anchors your clock to the audio clock, leading by ~2 output buffers — that is the constant latency, by design.
- If the clock goes backwards or jumps > ~2 s, the engine silently re-anchors. A time already in the past plays as soon as possible.
- All clocked calls share one anchor — they must all use the same time base.
- Divergence from other packages: no
paused parameter (unlike play()), and you schedule by passing a time, not by pre-creating a paused player and calling resume later.
SoundHandle playScheduled(
AudioSource sound,
Duration atTime, {
Duration? duration,
int busId = 0,
double volume = 1,
double pan = 0,
double scale = 1,
bool looping = false,
Duration loopingStartAt = Duration.zero,
Duration? loopingEndAt,
int? loopingStartOffsetAt,
int? loopingEndOffsetAt,
});
atTime is an absolute engine time (the getEngineTime() clock), not a delay and not your physics clock. No anchor, no ~2 s window — schedule as far ahead as you like. Past times play as soon as possible.
duration: if provided, the sound auto-stops at atTime + duration, atomically and sample-accurately (even durations shorter than one buffer). Keep the returned handle to cancel a still-pending sound with stop(handle).
Clock helpers:
Duration getEngineTime(); // engine's global mix clock; time base for playScheduled/stopScheduled/fadeScheduled. Advances only while the device is mixing.
Duration getPlayheadTime(); // engine time of the sample currently reaching the device ("true output" clock). Equals getEngineTime() when the render-ahead ring is disabled (default) and on web.
Duration getOutputLatency(); // estimated output latency; Duration.zero when render-ahead is disabled and on web.
void resetStreamTime(); // reset the playClocked/play3dClocked anchor; the next clocked call re-anchors. Call when (re)starting a session.
Scheduled stop/fade (sample-accurate, pinned to the engine clock — unlike scheduleStop, which measures from call time and is buffer-quantized):
void stopScheduled(SoundHandle handle, Duration atTime);
void fadeScheduled(
SoundHandle handle,
Duration atTime,
double to,
Duration time, {
bool thenStop = false, // stop when the fade ends at atTime + time
});
Positional variant — same scheduling semantics as playClocked, 3D position/velocity instead of pan:
SoundHandle play3dClocked(
AudioSource sound,
Duration soundTime,
double posX, double posY, double posZ, {
double velX = 0, double velY = 0, double velZ = 0,
int busId = 0,
double volume = 1,
double scale = 1,
bool looping = false,
... // same looping params as playClocked
});
(There is also a play3dScheduled, the 3D version of playScheduled, and Bus.playClocked / Bus.play3dClocked / Bus.playScheduled on mixing buses.)
Which one to use: play() for reactive one-shots (UI feedback, gunshots, music) where lowest latency wins; playClocked for evenly spaced beats from your own loop — fire-and-forget, no clock queries, the 2-buffer lead absorbs Timer jitter; playScheduled for pre-planned timelines — arbitrary lookahead and exact stops/fades, at the price of reading getEngineTime() and anchoring yourself.
The metronome walkthrough
example/lib/metronome/metronome.dart runs all three modes side by side (play, playClocked, playScheduled) with bufferSize: 4096 to make buffer-boundary clumping audible. The pattern to copy:
- Load the tick sounds once in
initState (loadAsset), reuse the AudioSources.
- Keep an accumulated ideal time:
physicsTime += interval per timer tick — never the timer's own firing time, which jitters.
- Mode
clocked: playClocked(sound, physicsTime).
- Mode
scheduled: anchor once on start — engineAnchor = getEngineTime() + 200ms — then playScheduled(sound, engineAnchor + physicsTime, duration: 20ms).
- On every (re)start or parameter change: zero
physicsTime and call resetStreamTime(); re-anchor for scheduled mode. Without this, the first calls land against a stale anchor and play as soon as possible.
Render-Ahead Ring (ultra-low reactive latency)
When init(renderAheadFrames: >0) is passed on native platforms, the engine pre-mixes audio into a ring buffer:
- Reactive
play() and playScheduled() calls are retroactively remixed into the unconsumed section of the ring, achieving hardware-level latency (~11 ms with devicePeriodFrames: 512) even when bufferSize is set to 2048 or 4096.
SoLoud.instance.isRenderAheadEnabled: checks if the ring buffer is active.
SoLoud.instance.getPlayheadTime(): engine time of the sample currently reaching the device (use this to sync UI/animations to what the listener hears right now).
SoLoud.instance.getOutputLatency(): estimated hardware + ring latency.
Traps
- Times must be monotonically increasing for
playClocked. Reusing a stale accumulator or starting a new session without resetStreamTime() makes ticks fire immediately (past times play ASAP) or triggers a silent re-anchor after a >2 s jump. Always zero your accumulator and call resetStreamTime() together.
- Dart timers fire late. Never pass "now" or the timer's fire time as the scheduled time — accumulate ideal times and keep a lead. For
playScheduled, anchor with ~100–200 ms of lead (getEngineTime() + 200ms); a voice can only be delayed, never advanced.
playClocked has a ~2 s lookahead limit. Gaps longer than ~2 s look like a clock jump and cause re-anchoring. For long-range scheduling use playScheduled.
- Constant ~2-buffer latency with
playClocked. Do not use it for sounds that must react to input (keypress → sound); that is what play() is for.
- The engine clock only advances while the device is mixing.
getEngineTime() freezes when output is stopped; a pending playScheduled voice keeps the device running. Scheduling against a stopped device is safe — the voice keeps its exact offset and starts counting down once the device runs.
- No
paused on clocked/scheduled variants. Models reaching for the audioplayers/just_audio "prepare then resume" pattern should instead pass the time; to cancel a pending scheduled sound, keep the handle and call stop(handle).
- No
Future, no completion callback per call. These are synchronous fire-and-forget; device-start failures cannot throw — listen to SoLoud.instance.audioDeviceStartFailures if that matters.
- Voice limit still applies. Default max active voices is 16 (
setMaxActiveVoiceCount); a dense sequencer scheduling many simultaneous notes must raise it.
playClocked does not support buffer streams with BufferingType.released (throws SoLoudBufferStreamCanBePlayedOnlyOnceCppException on second play), same as play.
- Web:
getPlayheadTime() equals getEngineTime() and getOutputLatency() returns zero (render-ahead ring is native-only); scheduling itself works on web.
More depth
- Demo:
example/lib/metronome/metronome.dart — all three modes, slider-driven BPM changes with correct reset/re-anchor.
- Tests worth skimming for edge behavior:
example/tests/tests/play_clocked.dart, example/tests/tests/play_scheduled.dart, example/tests/tests/latency_test.dart.
- Render-ahead ring (
init(renderAheadFrames: ...), native only) for combining large mix buffers with low reactive latency — see isRenderAheadEnabled in lib/src/soloud.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).