---
title: Playback Controls
description: 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
);
```

<Info>
All `play*` methods (`play`, `play3d`, `playClocked`, `play3dClocked`, `playScheduled`, and their mixing bus variants) accept `looping`, `loopingStartAt`, `loopingEndAt`, `loopingStartOffsetAt`, and `loopingEndOffsetAt` parameters.
</Info>

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
);
```

<Info>
The `scale` parameter is available on all `play*` methods (`play`, `play3d`, `playClocked`, `play3dClocked`, `playScheduled`, and `Bus.play*`).
</Info>

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

