Playback Controls

Learn how to control audio playback

Basic Playback

After loading a sound, you can play it using the play() method:

dart
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 handle uniquely 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 sound remains valid in memory and can be played without lags until disposeSource() 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:

dart
SoLoud.instance.playSource(asset: 'assets/your-audio-file.mp3'); // accepts file and url also

Sample-accurate scheduling with playClocked

When 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":

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

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

dart
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 (including scale, 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:

dart
// 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 calling play() from a periodic timer and the rhythm matters, switch those calls to playClocked() with an accumulated time and stop worrying about the buffer size.

See example/lib/metronome/metronome.dart for a demo of clocked playback.

Scheduling on the engine clock: playScheduled

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:

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

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

  • playClocked is 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.
  • playScheduled is 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 reading getEngineTime() and doing the anchoring yourself — keep a lead of ~100–200 ms so calls always land ahead of their scheduled time.

Playback Controls

Pausing and Resuming

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

Stopping Playback

dart
await SoLoud.instance.stop(handle);

Seeking

dart
// Seek to specific position
SoLoud.instance.seek(handle, Duration(seconds: 5));

// Get current position
final position = SoLoud.instance.getPosition(handle);

Looping

Enable looping during playback:

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

Control looping for an already playing sound:

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

Playback Speed

Set initial playback speed directly when starting playback:

dart
// Set initial speed multiplier with [scale] (1.0 is normal speed)
final handle = SoLoud.instance.play(
  sound,
  scale: 1.5, // Play 1.5x faster
);

Adjust the playback speed of an already playing sound:

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

Render-Ahead Ring (Native Only)

The experimental render-ahead ring decouples the device buffer period from the engine's internal mixing quantum.

How It Works

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() and playScheduled() 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 mix bufferSize (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 renderAheadFrames earlier than before when the ring is enabled.
  • Web: The render-ahead ring is native-only and ignored on the web.
dart
// 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)
);

Render-Ahead Inspection APIs

dart
// 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();

Voice Protection

Protect important sounds from being stopped when voice limit is reached:

dart
// Protect background music from being stopped
SoLoud.instance.setProtectVoice(musicHandle, true);

Voice Management

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

Best Practices

  • 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