Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
##### 4.1.7 (X Xxx 2026)
- fix: a device change that still fails now reports `SoLoudAudioDeviceFailedToStartCppException` instead of hanging. Thanks to @Colton127
- fix: `changeDevice()` now selects the system default device when called without an argument and reports device-change failures instead of silently succeeding. Thanks to @Colton127 #532
- fix: `init()` no longer blocks the UI thread while the audio device starts. On Android a slow or busy audio HAL could stall the platform thread long enough for the app to be reported as not responding; engine startup and teardown now run on a short-lived worker isolate. Thanks to @Colton127 #481
- added `deinitAsync()`, a non-blocking counterpart to `deinit()`. `deinit()` is unchanged and still supported, but it can stall the UI thread when it lands while `init()` is still starting the device — prefer `deinitAsync()` in new code.

##### 4.1.6 (3 Aug 2026)
- fix: iOS/macOS SPM build fails with error: unknown argument: '-Wl,-undefined,dynamic_lookup' #530
Expand Down
39 changes: 17 additions & 22 deletions example/tests/tests/asynchronous_deinit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,25 @@ import 'common.dart';
Future<OutputBuffer> testAsynchronousDeinit() async {
/// test asynchronous init-deinit looping with a short decreasing time
for (var t = 10; t >= 0; t--) {
var error = '';

/// Initialize the player
unawaited(
SoLoud.instance.init().then(
(_) {},
onError: (Object e) {
deinit();
if (e is SoLoudInitializationStoppedByDeinitException) {
// This is to be expected.
debugPrint('$e\n');
return;
}
debugPrint('TEST FAILED delay: $t. Player starting error: $e\n');
error = e.toString();
},
),
);

assert(error.isEmpty, error);
final initResult =
SoLoud.instance.init().then<({Object? error, StackTrace? stack})>(
(_) => (error: null, stack: null),
onError: (Object error, StackTrace stack) =>
(error: error, stack: stack),
);

/// wait for [t] ms and deinit()
await delay(t);
deinit();
await SoLoud.instance.deinitAsync();

final result = await initResult;
final error = result.error;

if (error is SoLoudInitializationStoppedByDeinitException) {
debugPrint('$error\n');
} else if (error != null) {
Error.throwWithStackTrace(error, result.stack!);
}

final after = SoLoudController().soLoudFFI.isInited();

assert(
Expand Down
101 changes: 96 additions & 5 deletions example/tests/tests/playback_devices.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import 'package:flutter_soloud/src/enums.dart';

import 'common.dart';

/// Wall-clock budget for a single `changeDevice()` call.
///
/// A swap closes one stream and opens another: tens of milliseconds in
/// practice, a few hundred on a slow emulator. What matters is the number this
/// excludes. Holding SoLoud's audio-thread mutex across the swap starves the
/// audio callback, and on AAudio's legacy (non-MMAP) path a stream only reports
/// STARTED once its first data callback has run — so `ma_device_start()` sits
/// in `AAudioStream_waitForStateChange()` for its full 5s timeout before it
/// fails.
/// Anything near that is the mutex regression, not a slow device.
const _changeDeviceBudget = Duration(seconds: 2);

/// Test playback device enumeration and switching.
Future<OutputBuffer> testPlaybackDevices() async {
final strBuf = OutputBuffer();
Expand Down Expand Up @@ -38,22 +50,29 @@ Future<OutputBuffer> testPlaybackDevices() async {
// device count.
// Note: this is also the first time this path is reachable on Web, where it
// tears down and re-initializes the WebAudio device.
SoLoud.instance.changeDevice();
final toDefault = _timeChangeDevice('Switching to the default device');
assert(
await _isPlaybackUsable(sound),
'The engine should still play after switching to the default device',
);
strBuf.writeln('Switched to the default device, playback still usable');
strBuf.writeln(
'Switched to the default device in ${toDefault.inMilliseconds}ms, '
'playback still usable',
);

// Regression: an enumerated device still works. Only one device is required
// because CI/desktop machines commonly expose a single one.
SoLoud.instance.changeDevice(newDevice: devices.first);
final toEnumerated = _timeChangeDevice(
'Switching to device "${devices.first.name}"',
newDevice: devices.first,
);
assert(
await _isPlaybackUsable(sound),
'The engine should still play after switching to an enumerated device',
);
strBuf.writeln(
'Switched to device "${devices.first.name}", playback still usable',
'Switched to device "${devices.first.name}" in '
'${toEnumerated.inMilliseconds}ms, playback still usable',
);

// Invalid native selectors are rejected before the current device is
Expand All @@ -77,14 +96,65 @@ Future<OutputBuffer> testPlaybackDevices() async {
);
strBuf.writeln('Invalid device IDs rejected without disrupting playback');

// Swap repeatedly while the mixer is actually running. Nothing serializes the
// audio callback against the swap any more: `ma_device_uninit()` alone is
// responsible for quiescing the callback before its stream is closed, and
// `Soloud::mix()` takes the audio mutex itself. If that ordering were not
// enough, a live voice across back-to-back swaps is what would expose it.
final looped = SoLoud.instance.play(sound, looping: true);
assert(
SoLoud.instance.getIsValidVoiceHandle(looped),
'The looping voice used for the swap stress test should start',
);
var slowestSwap = Duration.zero;
for (var i = 0; i < 10; i++) {
final elapsed = _timeChangeDevice('Stress swap $i');
if (elapsed > slowestSwap) slowestSwap = elapsed;
assert(
SoLoud.instance.getIsValidVoiceHandle(looped),
'The looping voice should survive swap $i: a device change replaces the '
'output device, it does not touch voices',
);
await delay(100);
}
assert(
SoLoud.instance.getActiveVoiceCount() > 0,
'The engine should still be mixing after 10 device swaps',
);
strBuf.writeln(
'10 back-to-back swaps under a live voice, slowest '
'${slowestSwap.inMilliseconds}ms',
);

// Aim a swap at the deferred engine pause. `Player`'s pause scheduler stops
// the audio device from its own thread ~500ms (kPauseEngineDelayMs) after the
// last voice ends, so it is the one device operation an app can drive
// concurrently with a swap without any OS lifecycle event. Both act on the
// same `ma_device`, and mid-swap there is no device at all — a start or stop
// landing there is operating on a torn struct.
//
// Note this is a probe, not a proof: it can only make the collision likely,
// and on web there is no scheduler thread at all (the wasm build pauses
// inline). A green run is evidence, not a guarantee of correct locking.
await SoLoud.instance.stop(looped);
for (var i = 0; i < 5; i++) {
await delay(450);
_timeChangeDevice('Swap $i racing the deferred engine pause');
assert(
await _isPlaybackUsable(sound),
'The engine should still play after swap $i raced the engine pause',
);
}
strBuf.writeln('5 swaps aimed at the deferred engine pause window survived');

// On desktop platforms, we can test changing devices
// On mobile and web, there's typically only the default device
// Note: not all output devices can be heard.
if (!kIsWeb && devices.length > 1) {
for (final device in devices) {
strBuf.writeln('Testing device: ${device.name}');
debugPrint('Testing device: ${device.name}');
SoLoud.instance.changeDevice(newDevice: device);
_timeChangeDevice('Switching to "${device.name}"', newDevice: device);

await delay(3000);
}
Expand Down Expand Up @@ -134,6 +204,27 @@ Future<OutputBuffer> testPlaybackDevices() async {
return strBuf;
}

/// Calls `changeDevice()` and reports how long the native call took, asserting
/// it stayed inside [_changeDeviceBudget].
///
/// This cannot catch a true deadlock: `changeDevice()` is a synchronous FFI
/// call on the UI isolate, so once the native side wedges there is no Dart code
/// left to time it out — the app just freezes (the Android ANR). What it does
/// catch is the multi-second stall that precedes that deadlock, which has the
/// same cause and is visible on every platform where the swap then recovers.
Duration _timeChangeDevice(String what, {PlaybackDevice? newDevice}) {
final stopwatch = Stopwatch()..start();
SoLoud.instance.changeDevice(newDevice: newDevice);
stopwatch.stop();
assert(
stopwatch.elapsed < _changeDeviceBudget,
'$what took ${stopwatch.elapsedMilliseconds}ms, over the '
'${_changeDeviceBudget.inMilliseconds}ms budget. The audio callback is '
'being starved during the swap — see _changeDeviceBudget.',
);
return stopwatch.elapsed;
}

/// Starts a voice and checks the engine handed back a usable handle, then
/// stops it again. Used to confirm the output device survived a change.
Future<bool> _isPlaybackUsable(AudioSource sound) async {
Expand Down
14 changes: 13 additions & 1 deletion lib/src/bindings/bindings_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ abstract class FlutterSoLoud {
///
/// Returns [PlayerErrors.noError] if success.
@mustBeOverridden
PlayerErrors initEngine(
Future<PlayerErrors> initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Expand Down Expand Up @@ -198,6 +198,18 @@ abstract class FlutterSoLoud {
@mustBeOverridden
void deinit();

/// Dispose the native engine without blocking the calling isolate.
@mustBeOverridden
Future<void> deinitAsync();

/// Prepare native init state before dispatching an asynchronous init.
@mustBeOverridden
void prepareEngineInit();

/// Publish a native shutdown request before dispatching asynchronous dispose.
@mustBeOverridden
void requestEngineShutdown();

/// Gets the state of player
///
/// Return true if initilized
Expand Down
79 changes: 68 additions & 11 deletions lib/src/bindings/bindings_player_ffi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import 'dart:async';
import 'dart:ffi' as ffi;
import 'dart:isolate';
import 'dart:typed_data';

import 'package:ffi/ffi.dart';
Expand Down Expand Up @@ -73,6 +74,36 @@ typedef OnAudioDurationCallbackTFunction = void Function(double duration);

typedef OnMoreDataIsNeededCallbackTFunction = void Function(int offset);

int _invokeInitEngine(
int address,
int deviceId,
int sampleRate,
int bufferSize,
int channels,
int lowLatency,
) {
final function =
ffi.Pointer<
ffi.NativeFunction<
ffi.Int32 Function(
ffi.Int,
ffi.UnsignedInt,
ffi.UnsignedInt,
ffi.UnsignedInt,
ffi.UnsignedInt,
)
>
>.fromAddress(address)
.asFunction<int Function(int, int, int, int, int)>();
return function(deviceId, sampleRate, bufferSize, channels, lowLatency);
}

void _invokeVoidNative(int address) {
ffi.Pointer<ffi.NativeFunction<ffi.Void Function()>>.fromAddress(
address,
).asFunction<void Function()>()();
}

final class _BufferStreamNativeCallbacks {
_BufferStreamNativeCallbacks({
this.onBuffering,
Expand Down Expand Up @@ -509,21 +540,27 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();

@override
PlayerErrors initEngine(
Future<PlayerErrors> initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Channels channels,
bool lowLatency,
) {
final ret = _initEngine(
deviceId,
sampleRate,
bufferSize,
channels.count,
lowLatency ? 1 : 0,
) async {
final address = _initEnginePtr.address;
final channelCount = channels.count;
final lowLatencyValue = lowLatency ? 1 : 0;
final result = await Isolate.run(
() => _invokeInitEngine(
address,
deviceId,
sampleRate,
bufferSize,
channelCount,
lowLatencyValue,
),
);
return PlayerErrors.values[ret];
return PlayerErrors.values[result];
}

late final _initEnginePtr =
Expand All @@ -538,8 +575,6 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
)
>
>('initEngine');
late final _initEngine = _initEnginePtr
.asFunction<int Function(int, int, int, int, int)>();

@override
void setAndroidAAudioAttributes(bool managed) {
Expand Down Expand Up @@ -663,6 +698,28 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
return _dispose();
}

@override
Future<void> deinitAsync() async {
final address = _disposePtr.address;
await Isolate.run(() => _invokeVoidNative(address));
}

@override
void prepareEngineInit() => _prepareEngineInit();

late final _prepareEngineInit = _prepareEngineInitPtr
.asFunction<void Function()>();
late final _prepareEngineInitPtr =
_lookup<ffi.NativeFunction<ffi.Void Function()>>('prepareEngineInit');

@override
void requestEngineShutdown() => _requestEngineShutdown();

late final _requestEngineShutdown = _requestEngineShutdownPtr
.asFunction<void Function()>();
late final _requestEngineShutdownPtr =
_lookup<ffi.NativeFunction<ffi.Void Function()>>('requestEngineShutdown');

late final _disposePtr = _lookup<ffi.NativeFunction<ffi.Void Function()>>(
'dispose',
);
Expand Down
13 changes: 11 additions & 2 deletions lib/src/bindings/bindings_player_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,13 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
}

@override
PlayerErrors initEngine(
Future<PlayerErrors> initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Channels channels,
bool lowLatency,
) {
) async {
// [lowLatency] only affects the native miniaudio backends (it selects the
// AAudio/CoreAudio performance profile); the Web Audio backend ignores it.
final ret = wasmInitEngine(
Expand All @@ -252,6 +252,15 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
return PlayerErrors.values[ret];
}

@override
Future<void> deinitAsync() async => deinit();

@override
void prepareEngineInit() {}

@override
void requestEngineShutdown() {}

@override
void setAndroidAAudioAttributes(bool managed) {
// No-op on web: AAudio stream attributes are Android-only.
Expand Down
Loading
Loading