After loading a sound, you can play it using the play() method:
final sound = await SoLoud.instance.loadAsset('assets/your-audio-file.mp3');
final handle = SoLoud.instance.play(
sound,
volume: 1.0,
pan: 0.0,
paused: false,
looping: false,
loopingStartAt: Duration.zero,
loopingEndAt: null,
scale: 1.0,
);
[...]
/// Dispose the sound when it's no longer needed
await SoLoud.instance.disposeSource(sound);Note:
- The returned
handleuniquely identifies this instance of the playing sound and it becomes invalid when the sound is stopped or ends. play()is synchronous and non-blocking.- The
soundremains valid in memory and can be played without lags untildisposeSource()is called.
The load* methods accept the autoDispose parameter, which will automatically dispose the sound when all its playing handles are finished.
To make things simpler and faster in some circumstances, you can use the playSource method to play audio from the given source and forget about the sound handle and its lifecycle. It will automatically dispose the source when it is finished playing:
SoLoud.instance.playSource(asset: 'assets/your-audio-file.mp3'); // accepts file and url alsoWhen you call play(), the sound starts at the beginning of the next output audio buffer. This has two consequences:
- the delay between your call and the audible start is anywhere between 0 and one buffer (~46 ms with the default buffer size of 2048 samples at 44100 Hz, ~93 ms at 4096), and
- every sound started within the same buffer begins at the exact same sample. Rapidly launched sounds "clump" together (they sum into one louder sound), and periodic sounds — a metronome being the classic example — get an audibly irregular rhythm: the spacing between ticks becomes a multiple of the buffer size instead of the spacing you asked for.
playClocked() solves this by scheduling the sound at a given time instead of "as soon as possible":
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,
});You pass your app's own "physics time" — any monotonically increasing clock in Duration form. The first call anchors that clock to the audio output clock; every following call is then placed on the output timeline with sample accuracy, so the spacing between sounds matches the spacing of the given times regardless of the engine buffer size:
var physicsTime = Duration.zero;
// Metronome: an even rhythm even with a large audio buffer.
Timer.periodic(const Duration(milliseconds: 100), (_) {
physicsTime += const Duration(milliseconds: 100);
SoLoud.instance.playClocked(tickSound, physicsTime);
});The 3D variant works the same way, with position and velocity instead of pan:
SoLoud.instance.play3dClocked(
sound,
physicsTime,
posX, posY, posZ,
velX: 0, velY: 0, velZ: 0,
volume: 1.0,
scale: 1.0,
looping: false,
);And both are available on mixing buses as bus.playClocked(...) and bus.play3dClocked(...).
How it behaves:
- The first clocked play after
init()anchors your clock to the audio clock and plays about two output buffers later. This built-in lead guarantees there is always at least one buffer of slack to absorb the jitter of your timer/clock calls. - Times must be monotonically increasing. If the engine sees the clock going backwards (e.g. a new session with its own time base) or a jump of more than ~2 seconds, it re-anchors to the new time.
- A call whose scheduled time is already in the past plays as soon as possible instead — the engine can delay sounds, not advance them. So call slightly ahead of the intended moment.
- All
play*parameters (includingscale,looping,loopingStartAt,loopingEndAt,loopingStartOffsetAt,loopingEndOffsetAt) are fully supported. - All clocked calls share a single anchor per engine, so they should all use the same time base.
Related low-level methods:
// Delay a sound by an exact number of samples (this is what playClocked
// uses internally). Start paused, set the delay, then unpause:
final handle = SoLoud.instance.play(sound, paused: true);
SoLoud.instance.setDelaySamples(handle, 44100); // 1 second at 44100 Hz
SoLoud.instance.setPause(handle, false);
// How long a voice has been playing (stream time):
final streamTime = SoLoud.instance.getStreamTime(handle);
// Reset the clocked-play clock: the next playClocked/play3dClocked call
// re-anchors to your time base as if no clocked play was ever made. Useful
// when starting a new scheduling session or resuming a paused clock.
SoLoud.instance.resetStreamTime();Pros of playClocked vs play:
- Sample-accurate spacing between sounds (sub-millisecond), independent of the buffer size
- No clumping of rapidly launched sounds
- No rhythm drift over time: each call is placed against a persistent anchor, so timer jitter does not accumulate
Cons of playClocked vs play:
- Higher, constant latency (~2 output buffers behind the given times, by design)
- You must provide a monotonically increasing time and call slightly ahead of the scheduled time
- A single shared anchor: all clocked calls must use the same clock
Rule of thumb:
- Use
play()for one-shot, reactive sounds — UI feedback, gunshots on demand, background music, anything looping — where "as soon as possible" is the right answer and the lowest latency matters. - Use
playClocked()for scheduled sound — metronomes, step sequencers, rhythm games, footstep or machine-gun patterns, and any rapid repeated effect that must not clump. If you are callingplay()from a periodic timer and the rhythm matters, switch those calls toplayClocked()with an accumulated time and stop worrying about the buffer size.
See example/lib/metronome/metronome.dart for a demo of clocked playback.
playClocked() is fed with your clock and can't schedule more than ~2 seconds ahead (longer gaps look like a clock jump and get re-anchored). When you need to schedule sounds at absolute times — a music score, a "playback manifest", cutscenes — use the engine's own clock instead:
// The engine's global stream time. Advances only while audio is mixing.
final now = SoLoud.instance.getEngineTime();
// Start a sound at an absolute engine time, with sample accuracy, at any
// distance in the future. Optional [duration] stops it automatically.
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,
});
// Stop or fade a sound at an absolute engine time. Both are
// sample-accurate; [thenStop] stops the sound when the fade ends.
void stopScheduled(SoundHandle handle, Duration atTime);
void fadeScheduled(
SoundHandle handle,
Duration atTime,
double to,
Duration time, {
bool thenStop = false,
});Read the clock once, schedule a batch against it, and cancel anything still pending on pause:
final now = SoLoud.instance.getEngineTime();
for (final note in upcomingNotes) {
final atTime = now + note.offsetFromNow;
final handle = SoLoud.instance.playScheduled(note.source, atTime);
SoLoud.instance.stopScheduled(handle, atTime + note.duration);
}
// On pause: cancel sounds that haven't started yet.
final cutoff = SoLoud.instance.getEngineTime();Times in the past are harmless: a playScheduled in the past plays as soon as possible, a stopScheduled/fadeScheduled in the past applies immediately. There is no anchor to reset and no time-window limit. Scheduled stops are sample-accurate — unlike scheduleStop(), which measures from call time and is quantized to buffer boundaries. Also available as bus.playScheduled(...).
playClocked or playScheduled?
playClockedis better for rapid, open-ended sounds driven by your own loop (metronome, game SFX patterns): fire-and-forget with your own time base, no clock queries, and its built-in 2-buffer lead absorbs timer jitter automatically.playScheduledis better for pre-planned playback (scores, manifests): schedule arbitrarily far ahead, pin stops and fades to absolute times, and cancel pending sounds on pause. The price is readinggetEngineTime()and doing the anchoring yourself — keep a lead of ~100–200 ms so calls always land ahead of their scheduled time.
// Toggle pause state
SoLoud.instance.pauseSwitch(handle);
// Set specific pause state
SoLoud.instance.setPause(handle, true); // pause
SoLoud.instance.setPause(handle, false); // resume
// Check pause state
final isPaused = SoLoud.instance.getPause(handle);await SoLoud.instance.stop(handle);// Seek to specific position
SoLoud.instance.seek(handle, Duration(seconds: 5));
// Get current position
final position = SoLoud.instance.getPosition(handle);Enable looping during playback:
// Duration-based looping:
final handle = SoLoud.instance.play(
sound,
looping: true,
loopingStartAt: const Duration(seconds: 1), // start of loop region
loopingEndAt: const Duration(seconds: 5), // optional exclusive end point
);
// Or frame-offset-based looping (mutually exclusive with Duration-based):
final handle2 = SoLoud.instance.play(
sound,
looping: true,
loopingStartOffsetAt: 44100, // sample frame offset
loopingEndOffsetAt: 220500, // sample frame offset
);All play* methods (play, play3d, playClocked, play3dClocked, playScheduled, and their mixing bus variants) accept looping, loopingStartAt, loopingEndAt, loopingStartOffsetAt, and loopingEndOffsetAt parameters.
Control looping for an already playing sound:
// Enable/disable looping
SoLoud.instance.setLooping(handle, true);
// Set loop point (start of the loop)
SoLoud.instance.setLoopPoint(handle, Duration(seconds: 1));
// Check if looping
final isLooping = SoLoud.instance.getLooping(handle);
/// Get the exclusive loop end point of a currently playing sound.
Duration? SoLoud.instance.getLoopEndPoint(handle);
/// Set the exclusive loop end point of a currently playing sound.
SoLoud.instance.setLoopEndPoint(handle, time);Set initial playback speed directly when starting playback:
// Set initial speed multiplier with [scale] (1.0 is normal speed)
final handle = SoLoud.instance.play(
sound,
scale: 1.5, // Play 1.5x faster
);The scale parameter is available on all play* methods (play, play3d, playClocked, play3dClocked, playScheduled, and Bus.play*).
Adjust the playback speed of an already playing sound:
// Set relative play speed (1.0 is normal speed)
SoLoud.instance.setRelativePlaySpeed(handle, 2.0); // Play twice as fast
// Get current play speed
final speed = SoLoud.instance.getRelativePlaySpeed(handle);The experimental render-ahead ring decouples the device buffer period from the engine's internal mixing quantum.
When renderAheadFrames > 0 is passed to SoLoud.instance.init(), the engine mixes into an engine-owned ring buffer ahead of the output device:
- Ultra-low latency: Reactive calls like
play()andplayScheduled()are mixed retroactively into the not-yet-played portion of the ring buffer. As a result, keypress-to-sound latency approaches the small device period (e.g. 512 frames / ~11 ms) even when running with a large mixbufferSize(e.g. 2048 or 4096 frames). - Graceful degradation: Sources that cannot be re-read (such as released buffer streams, live pull streams, or speech) and voices using non-snapshot-able filters degrade gracefully to standard buffer-boundary behavior.
- Ended-voice timing: Ended-voice callbacks may fire up to
renderAheadFramesearlier than before when the ring is enabled. - Web: The render-ahead ring is native-only and ignored on the web.
// Initialize with render-ahead ring enabled:
await SoLoud.instance.init(
bufferSize: 2048,
devicePeriodFrames: 512, // Hardware device period
renderAheadFrames: 1536, // Mix ahead depth (e.g. bufferSize - devicePeriodFrames)
);// Check if the ring buffer is enabled
final isEnabled = SoLoud.instance.isRenderAheadEnabled;
// Get the true playhead clock (engine time of the sample currently reaching the device)
// Equals getEngineTime() when the render-ahead ring is disabled or on web.
final playheadTime = SoLoud.instance.getPlayheadTime();
// Estimated output latency (render-ahead ring depth plus one device period).
// Returns Duration.zero when the ring is disabled or on web.
final latency = SoLoud.instance.getOutputLatency();Protect important sounds from being stopped when voice limit is reached:
// Protect background music from being stopped
SoLoud.instance.setProtectVoice(musicHandle, true);// Set maximum number of concurrent sounds (default is 16)
SoLoud.instance.setMaxActiveVoiceCount(32);
// Get current number of playing sounds
final activeVoices = SoLoud.instance.getActiveVoiceCount();
// Check if a handle is still valid
final isValid = SoLoud.instance.getIsValidVoiceHandle(handle);- Always keep track of sound handles for sounds you need to control
- Dispose sounds when they're no longer needed
- Use voice protection for important sounds like background music
- Consider setting appropriate voice limits based on your app's needs