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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
##### 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.
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
51 changes: 43 additions & 8 deletions src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,12 @@ namespace SoLoud
static bool gDeviceInitialized = false; // Track if device is actually initialized
static std::thread *gInitThread = nullptr; // Background thread for device init
static std::mutex gInitMutex; // Protect device init state
// Serializes device start/stop operations (e.g. resume) against device
// teardown in soloud_miniaudio_deinit().
// Serializes every operation that touches `gDevice`: pause, resume, the
// device swap in miniaudio_changeDevice_impl() and teardown in
// soloud_miniaudio_deinit(). Any two of these running at once act on a
// half-initialized or already-freed device. Note this is NOT SoLoud's
// audio-thread mutex and must not be confused with it: the data callback
// never takes this one, so holding it cannot starve the audio thread.
static std::mutex gDeviceOpsMutex;

// Configuration to store for deferred initialization
Expand Down Expand Up @@ -315,6 +319,15 @@ namespace SoLoud
// state and keeps MPRemoteCommandCenter routing intact.
result soloud_miniaudio_pause(SoLoud::Soloud *aSoloud)
{
// Take the same device-ops lock as resume()/deinit()/changeDevice().
// Without it this is the one device operation that can still land on a
// `gDevice` another thread is swapping or tearing down — and it is the
// most likely one to do so, since Player's pause scheduler fires it
// from its own thread ~500ms after the last voice ends.
std::lock_guard<std::mutex> deviceOpsLock(gDeviceOpsMutex);
if (!gDeviceInitialized)
return 0; // No device to pause.

if (ma_device_get_state(&gDevice) == ma_device_state_started)
{
#if defined(__EMSCRIPTEN__) || defined(__ANDROID__)
Expand Down Expand Up @@ -625,17 +638,37 @@ namespace SoLoud
if (soloud == nullptr)
return UNKNOWN_ERROR;

// Serialize the whole swap against the other operations that act on
// `gDevice`: soloud_miniaudio_pause(), soloud_miniaudio_resume() and
// soloud_miniaudio_deinit(). Between the uninit and the init below
// there is no device at all, and a concurrent start/stop on that torn
// struct is the SIGABRT documented in soloud_miniaudio_resume().
// This is reachable in ordinary use, not just on lifecycle events:
// Player's pause scheduler runs on its own thread and calls
// Soloud::pause() ~500ms after the last voice ends.
std::lock_guard<std::mutex> deviceOpsLock(gDeviceOpsMutex);

// Stop the device before uninitializing to ensure clean shutdown
if (ma_device_get_state(&gDevice) == ma_device_state_started)
{
ma_device_stop(&gDevice);
}

// Lock the audio mutex to prevent race conditions during device change
soloud->lockAudioMutex_internal();

// SoLoud's audio-thread mutex is deliberately NOT held across the swap
// below, even though it guards the mixer, because it does not protect
// `gDevice`: the data callback reaches the engine through
// `pDevice->pUserData` and takes that mutex itself inside
// `Soloud::mix()`, and `ma_device_uninit()` already guarantees the
// callback has stopped before it returns. Holding it here only starves
// the audio thread, and on Android that is fatal: on AAudio's legacy
// (non-MMAP) path a stream reports STARTED only once its first data
// callback has run, so a held mutex makes that callback block,
// `ma_device_start()` time out after 5s, and the cleanup
// `ma_device_uninit()` then wait forever on the very callback the
// caller is blocking. That deadlock is the Android ANR.
ma_device_uninit(&gDevice);
gDeviceInitialized = false;
gDeviceStopped = true;

ma_device_config deviceConfig = ma_device_config_init(ma_device_type_playback);
deviceConfig.playback.pDeviceID = (ma_device_id *)pPlaybackInfos_id;
Expand Down Expand Up @@ -679,8 +712,8 @@ namespace SoLoud
#endif
if (result != MA_SUCCESS)
{
soloud_platform_log("miniaudio_changeDevice_impl: ma_device_init failed with error %d\n", result);
gDeviceInitialized = false;
soloud->unlockAudioMutex_internal();
return UNKNOWN_ERROR;
}

Expand All @@ -692,11 +725,13 @@ namespace SoLoud
soloud_platform_log("miniaudio_changeDevice_impl: ma_device_start failed with error %d\n", startResult);
ma_device_uninit(&gDevice);
gDeviceInitialized = false;
soloud->unlockAudioMutex_internal();
// The device never reached a running state and is now gone, so
// don't leave `deinit()` polling for a "stopped" notification that
// can no longer arrive.
gDeviceStopped = true;
return UNKNOWN_ERROR;
}

soloud->unlockAudioMutex_internal();
return 0;
}
};
Expand Down
Loading