diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89b0068d..b0d5e399 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+#### 4.0.13 (XX Xxx 2026)
+- fix: Waveform audio sources do not match engine sample rate #501. Thanks to @Colton127
+- add `setAndroidPauseDeviceWhenIdle` (opt-in, default off) to stop the audio device when idle on Android, releasing the audioserver `AudioMix` partial wakelock #250
+- add `stopAudioDevice()` / `startAudioDevice()` to stop and restart the audio output device without deinitializing the engine (loaded sounds and voices are preserved); the blocking native device operation runs off the UI thread so it does not freeze the app
+- add `getAudioDeviceState()` returning an `AudioDeviceState` enum to query the current state of the audio output device (uninitialized, stopped, started, starting, stopping)
+- fix: `init()` no longer blocks the UI thread — the blocking native engine/device initialization now runs off the UI thread, preventing ANRs on startup #481
+- add `deinitAsync()`, a non-blocking alternative to `deinit()` that runs the native teardown off the UI thread (the synchronous `deinit()` is unchanged)
+
#### 4.0.12 (30 Jun 2026)
- add `lowLatency` init option to allow recordable Android output #492. Thanks to @MjnMixael
- added WAV to Buffer streams supported formats. Fixes loading wav files from web #494
@@ -9,7 +17,7 @@
- wait some ms to pause device when there are no more sounds playing #486
#### 4.0.9 (13 Jun 2026)
-- Windows: prevent compiler to complain about `min` and `max` macros. Fixes #483
+- Windows: prevent the compiler from complaining about `min` and `max` macros. Fixes #483
#### 4.0.8 (10 Jun 2026)
- fix released buffer stream size reporting #480. Thanks to @Kunstderfug
@@ -18,7 +26,7 @@
#### 4.0.7 (1 Jun 2026)
- add look-ahead brickwall limiter and fix planar DSP indexing #468. Thanks to @Kunstderfug
- fix iOS CocoaPods wrapper double compile #467. Thanks to @DavidPluxia
-- fix Apple: added linker settings to do not stripe symbols when building the ipa using SPM #472
+- fix Apple: added linker settings to not strip symbols when building the ipa using SPM #472
- Android fix: fix audio crackling using `BufferingType.released` on some Android devices #476
- Android fix: quick play & stop causes glitches and probably UI jank #478
- fix: the `wet` parameter of parametric eq was not evaluated #477
@@ -33,7 +41,7 @@
#### 4.0.4 (4 May 2026)
- fix Apple: build forcing to add c++ std lib #456
-- fix reading Opus with not standard samplerate #457
+- fix reading Opus with non-standard samplerate #457
#### 4.0.3 (24 Apr 2026)
- fix: rebind Dart callbacks after hot restart #444. Thanks to @skylartaylor
@@ -54,8 +62,8 @@
#### 4.0.0 (3 Apr 2026)
- fix: some OGG audio files don't trigger `SoundEventType.handleIsNoMoreValid`
- fix: setBufferStream fails to decode small MP3 files under 32 KB #434. Thanks to @chaudharydeepanshu
-- fix web: `createVoiceGroup` return was interpred as a signed int instead of unsigned because it has always the sign bit flag
-- fixed switching output devices on macOS and maybe on others platforms, didn't initialize correctly the new one
+- fix web: `createVoiceGroup` return was interpreted as a signed int instead of an unsigned because it always has the sign bit flag
+- fixed switching output devices on macOS and maybe on other platforms, didn't initialize the new one correctly
- added some more tests
- removed deprecated `equalizerFilter` in favor of `parametricEqFilter`
@@ -64,7 +72,7 @@
- fix decreasing volume when adding a bus to another
#### 4.0.0-pre.2 (28 Mar 2026)
-- macOS/iOS fix: check for cmake in path while building
+- macOS/iOS fix: check for cmake in the path while building
- iOS simulator: fix libs linking
#### 4.0.0-pre.1 (26 Mar 2026)
@@ -74,7 +82,7 @@
- added Mixing Bus feature and example https://docs.page/alnitak/flutter_soloud_docs/advanced/mixing_bus
- added `getApproximateVolume` to get the approximate volume of a channel of the player
- added `autoDispose` parameter to `load*` methods to automatically dispose the sound when it is finished. This eliminates the need to manually call disposeSource
-- added `playSource` to play a source from assets, URLs, or file and automatically dispose its `AudioSource` when it is finished
+- added `playSource` to play a source from assets, URLs, or a file and automatically dispose of its `AudioSource` when it is finished
- added `filters/parametric_eq.dart` example
- added parametric equalizer with 1 to 64 bands and FFT window size from 32 to 4096 for quality and performance
- added Swift Package Manager support
@@ -84,7 +92,7 @@
- win fix: UTF8 file name conversion was causing crash or file not found #427
- fixed a possible crash during app shutdown
- possible fix for #333 which caused an ANR on Android when stopping/deinit or closing the app
-- Linux feat: choose to link ogg, opus, vorbis, and flac libraries from the system with `TRY_SYSTEM_LIBS_FIRST=1` environment variable #421. Useful for Rasperry Pi because the precompiled libs are available only for x86_64.
+- Linux feat: choose to link ogg, opus, vorbis, and flac libraries from the system with `TRY_SYSTEM_LIBS_FIRST=1` environment variable #421. Useful for Raspberry Pi because the precompiled libs are available only for x86_64.
- Android fix: elevate audio thread priority on Android #396. Thanks to @djkingCanada
- Android: build optimizations
---
@@ -106,9 +114,9 @@
- updated audio_context example to demostrate how to integrate with `audio_session` and `audio_service`
#### 3.5.1 (14 Mar 2026)
-- `getStreamTimeConsumed` return wrong time for s16le and s8 #419
+- `getStreamTimeConsumed` returns the wrong time for s16le and s8 #419
- win fix: hang on app exit #413
-- win fix: prevent Windows message pump goes irresponsive with plugins like `desktop_drop` and maybe others #401
+- win fix: prevent Windows message pump from going unresponsive with plugins like `desktop_drop` and maybe others #401
- fix: only unpause when buffer covers playback position #393. Thanks to @nukes
- wasm fix: runtimeType error when voice ended #414
@@ -134,7 +142,7 @@
- win fix: loadMem/loadAsset futures never finish when run in parallel with the same file #376
#### 3.4.5 (22 Nov 2025)
-- fixed `Bad state: Future already completed` error during integrations tests. Thanks to @Taormina #373
+- fixed `Bad state: Future already completed` error during integration tests. Thanks to @Taormina #373
- win: don't copy pdb file when in profile mode avoiding build error. Fixes #372
#### 3.4.4 (17 Nov 2025)
@@ -156,7 +164,7 @@
#### 3.4.0 (28 Oct 2025)
- added support for OGG FLAC and its metadata to BufferStream #294
- fix Opus BufferStream end clicks on short sounds #344. Thanks to @eddyleelin
-- fixex a crash on old Widnows PCs with CPUs that don't support AVX2 extensions (using now SSE2) #340
+- fix a crash on old Windows PCs with CPUs that don't support AVX2 extensions (using now SSE2) #340
- fixed `Player::findByHandle` crash in some circumstancies #342
#### 3.3.9 (21 Oct 2025)
@@ -166,7 +174,7 @@
#### 3.3.8 (13 Oct 2025)
- fix: audio stream with released mode failed to consume BufferStream #335 #318
- iOS fix: maybe fixed no sound probably in older iOS devices without AirPods #330
-- fix: incorrect seek position on multi channel audio streams #328
+- fix: incorrect seek position on multi-channel audio streams #328
#### 3.3.7 (25 Sep 2025)
- iOS fix: update build_iOS.sh to make fat libraries #315. Thanks to @kumamotone
@@ -198,7 +206,7 @@
- Added `web_radio.dart` example to demonstrate how to receive an audio stream (ie, an icecast stream) and then add the audio chunks to BufferStream
- Add `BufferType.auto` to auto detect ogg/opus/vorbis/mp3 streams
- Deprecate `BufferType.opus` in favor of `BufferType.auto`
-- Get TAGs info also while streaming and not only by sending chunks of an audio file. For MP3s, the TAGs are obtained from ID3V2 or passing `icy-metaint` (obtained from the header of the online stream) before adding audio chunks to the BufferStream. New metadata are notifified by the `onMetadata` callback of `setBufferStream`
+- Get TAGs info also while streaming and not only by sending chunks of an audio file. For MP3s, the TAGs are obtained from ID3V2 or passing `icy-metaint` (obtained from the header of the online stream) before adding audio chunks to the BufferStream. New metadata is notified by the `onMetadata` callback of `setBufferStream`
#### 3.2.7 (18 Aug 2025)
- feat: allow to specify NO_OPUS_OGG_LIBS in Android build config #282. Thanks to @ekuleshov
@@ -224,7 +232,7 @@
- fix #104, #245, #249. It is now possible to use a 3rd party plugin like `audio_session` to manage audio context.
- new audio context example in `example/lib/audio_context/audio_context.dart`.
- fix GetPosition returned value for buffer streams.
-- fix Web hot reload/restard #258 and #259.
+- fix Web hot reload/restart #258 and #259.
#### 3.1.12 (21 Jun 2025)
- added `getStreamTimeConsumed()` to get the time consumed by a buffer stream of kind `BufferingType.released`. Since the position of this kind of stream is always 0, this method is useful to know the time already played.
@@ -242,7 +250,7 @@
- fix: `disposeSource` crash on Android #240
#### 3.1.8 (2 May 2025)
-- fix: adding audio data to a not ended BufferStream throws error #235
+- fix: adding audio data to an unended BufferStream throws error #235
#### 3.1.7 (23 Apr 2025)
- docs: clarify docs regarding `semitones` and `shift` parameters of the `pitchShiftFilter` #233 by @bemain
@@ -266,7 +274,7 @@
#### 3.1.2 (27 Mar 2025)
- enhanced documentation clarity and organization by moving it to the dedicated [flutter_soloud_docs](https://github.com/alnitak/flutter_soloud_docs) repo. Powered by [docs.page](https://docs.page/) from Invertase and can be viewed [here](https://docs.page/alnitak/flutter_soloud_docs).
- Web fix: Uncaught (in promise) TypeError #208
-- fix: error when loading very short MP3s file #181
+- fix: error when loading very short MP3 files #181
#### 3.1.1 (21 Mar 2025)
- fix: Sounds seemingly "backed up in a queue" when playing too many at once #204
@@ -274,7 +282,7 @@
#### 3.1.0 (18 Mar 2025)
- when calling `AudioData.getAudioData` is now possible to check if the audio data is the same as before. Useful to visualize waveforms. This is because `AudioData.getAudioData` returns the current data in the buffer and if it is called before the buffer has been updated, it will return the previous data.
- better FFT data for a better visualization.
-- added `resetBufferStream` method to `SoLoud`. It happens that when playing a stream, maybe from the web, it is needed to change it to another source. The player continues to play the already added audio data to the buffer. This method can be used to reset the buffer and start with the new audio data.
+- added `resetBufferStream` method to `SoLoud`. It happens that when playing a stream, maybe from the web, it is necessary to change it to another source. The player continues to play the already added audio data to the buffer. This method can be used to reset the buffer and start with the new audio data.
#### 3.0.3 (7 Mar 2025)
- it's now possible to choose to not link opus and ogg libraries (see `NO_OPUS_OGG_LIBS.md`). Fix for #191 and #192.
@@ -284,7 +292,7 @@
#### 3.0.1 (20 Feb 2025)
- fix: error while calling listPlaybackDevices() #186.
-- android example folder recreated.
+- Android example folder recreated.
#### 3.0.0 (13 Feb 2025)
- `BufferStream` now supports 2 type of buffering:
@@ -295,23 +303,23 @@
- removed deprecated `timeout` parameter in `SoLoud.init`.
- removed deprecated `filter_params.dart`.
- fixed biquad resonant filter `frequency` default parameter #179
-- fix: on some unclear conditions `isInitialized` returning false on MacOS after engine starts with no error #177
+- fix: on some unclear conditions `isInitialized` returns false on MacOS after the engine starts with no error #177
- fix: Call `loadMem` will crash the application #174.
#### 3.0.0-pre.0 (2 Feb 2025)
- fix: clicks and pops when changing waveform frequency #156.
- added `Limiter` and `Compressor` filters (see `example/lib/filters/`).
-- added BufferStream #148. Now it's possible to add audio data and listen to them. It provides a customizable buffering length which automatycally pauses the playing handle if there is not enough data, for example when receiving audio data from the web. It also provides a callback that allows you to know when the buffering is started and stopped. The audio data can of of the following formats:
+- added BufferStream #148. Now it's possible to add audio data and listen to it. It provides a customizable buffering length which automatically pauses the playing handle if there is not enough data, for example, when receiving audio data from the web. It also provides a callback that allows you to know when the buffering is started and stopped. The audio data can of of the following formats:
- `s8` signed 8 bit
- `s16le` signed 16 bit little endian
- `s32le` signed 32 bit little endian
- `f32le` float 32 bit little endian
- `opus` Opus codec compressed audio with Ogg container. Useful for streaming from the Web (ie using OpenAI APIs).
-- fixed Web Worker initialization non fatal error that could occur on Web.
-- fixed sound distortion using single pitchShift filter and changing relative play speed #154.
-- fixed the use of `LoadMode.disk` on the Web platform which in some cases caused the `allInstancesFinished` event to not be emitted.
+- fixed Web Worker initialization non-fatal error that could occur on Web.
+- fixed sound distortion using a single pitchShift filter and changing the relative play speed #154.
+- fixed the use of `LoadMode.disk` on the Web platform, which in some cases caused the `allInstancesFinished` event to not be emitted.
- improved performance on Web, MacOS and iOS.
-- get wave and FFT samples is now simpler and faster.
+- getting wave and FFT samples is now simpler and faster.
- To avoid future incompatibilities when using other WASM compiled plugins, it is now necessary to add a new script to `index.html`:
```
@@ -331,7 +339,7 @@
- added `readSamplesFrom*()` methods to read N audio data within a time range from a file or memory #75. Example in `example/lib/wave_data/wave_data.dart`.
#### 2.1.4 (18 Sep 2024)
-- fixed waveform generation which somehow oscillate frequencies after some time #129.
+- fixed waveform generation, which somehow oscillates frequencies after some time #129.
- fixed iOS compilation by rising minimum iOS version to 13 #128.
- fixed iOS compilation on the new MacOS 15 with XCode 16 #130.
@@ -346,14 +354,14 @@
#### 2.1.1 (28 Aug 2024)
- added `bool isActive` and `int index` getters to filters.
-- added a `timeStretch()` method to single pitchshift filter.
+- added a `timeStretch()` method to the single pitchshift filter.
- fixed building error on Windows.
- updated examples.
#### 2.1.0 (23 Aug 2024)
- added support for the Web platform.
- added `getPan()`, `setPan()` and `setPanAbsolute()`.
-- added `loadMem()` to read the give audio file bytes buffer (not RAW data). Useful for the Web platform.
+- added `loadMem()` to read the given audio file bytes buffer (not RAW data). Useful for the Web platform.
- fixed `getFilterParamNames()`.
- added `AudioData` class to manage audio samples.
- added player initialization parameters: sample rate, buffer size, number of channels (mono, stereo, quad, 5.1, 7.1).
@@ -361,7 +369,7 @@
- it's now possible to set filters not only globally, but also to single audio sources (not on the web platform).
- fade and oscillate filter parameters.
- experimental capture feature removed.
-- now accessing to filter has been simplified with the use of `SoLoud.filters` and `AudioSource.filters` to use global and single sound filters.
+- now accessing the filter has been simplified with the use of `SoLoud.filters` and `AudioSource.filters` to use global and single sound filters.
#### 2.0.2 (23 May 2024)
- Fixed wrong exception raised by `setVolume()` when a handle is no more valid.
@@ -402,10 +410,10 @@
- New methods `SoLoud.loadAsset()` and `SoLoud.loadUrl()` to load audio from assets and URLs, respectively.
- added `mode` property to `SoLoud.loadFile()` and `SoloudTools.loadFrom*` to prevent to load the whole audio data into memory:
- *LoadMode.memory* by default. Means less CPU, more memory allocated.
- - *LoadMode.disk* means more CPU, less memory allocated. Lags can occurs while seeking MP3s, especially when using a slider.
+ - *LoadMode.disk* means more CPU, less memory allocated. Lags can occur while seeking MP3s, especially when using a slider.
- Switched from `print()` logging to using the standard `package:logging`.
See `README.md` to learn how to capture log messages and how to filter them.
-- The capture feature is on experimental stage to be fine tuned in the near future. All methods related to audio capture have been extracted to a separate class.
+- The capture feature is on experimental stage to be fine-tuned in the near future. All methods related to audio capture have been extracted to a separate class.
So now, there are two classes:
- `SoLoud` for _playing_ audio
- `SoLoudCapture` for _capturing_ audio
@@ -517,9 +525,9 @@ to `2.0.0-pre.2` and beyond.
#### 2.0.0-pre.0 (11 Mar 2024)
- added `bool SoLoud.getVisualizationEnabled()` to get the current state of the visualization.
-- added `mode` property to `SoLoud.loadFile()` and `SoloudTools.loadFrom*` to prevent to load the whole audio data into memory:
+- added `mode` property to `SoLoud.loadFile()` and `SoloudTools.loadFrom*` to prevent loading the whole audio data into memory:
- *LoadMode.memory* by default. Means less CPU, more memory allocated.
- - *LoadMode.disk* means more CPU, less memory allocated. Lags can occurs while seeking MP3s, especially when using a slider.
+ - *LoadMode.disk* means more CPU, less memory allocated. Lags can occur while seeking MP3s, especially when using a slider.
- Switched from `print()` logging to using the standard `package:logging`.
See `README.md` to learn how to capture log messages and how to filter
them.
@@ -560,20 +568,20 @@ to `2.0.0-pre.2` and beyond.
to avoid various race conditions.
- Sound handles and sound hashes are now typed: `SoundHandle` and `SoundHash`
instead of raw integers.
- This prevents from erroneously passing a sound handle as a sound hash,
- for example. This is a breaking API change but, in practice, shouldn't
+ This prevents erroneously passing a sound handle as a sound hash,
+ for example. This is a breaking API change, but, in practice, shouldn't
be much of a problem, since these objects were always meant as
identifiers (to be taken from some API calls and put into others).
- `SoundProps.handle` renamed to `SoundProps.handles` (because it's a Set)
and also disallowed modifying it from outside the package.
- All fields of `SoundProps` marked `final`. This is a breaking change
- but unlikely to have effect (as most users hopefully don't assign
+ but unlikely to have an effect (as most users hopefully don't assign
to these fields).
#### 1.2.5 (2 Mar 2024)
- updated mp3, flac and wav decoders
- updated miniaudio to 0.11.21
-- fixed doppler effect in 3D audio example
+- fixed the doppler effect in 3D audio example
#### 1.2.4
fixed compilation on Windows
@@ -595,7 +603,7 @@ fixed compilation on Windows
- freeverbFilter
#### 1.2.1
-- binded some more SoLoud functionalities:
+- bound some more SoLoud functionalities:
- fadeGlobalVolume
- fadeVolume
- fadePan
@@ -615,7 +623,7 @@ fixed compilation on Windows
- miniaudio updated to v0.11.18
#### 1.1.1
-- *SoLoud().loadFile* now can return *PlayerErrors.fileAlreadyLoaded* when a sound has already been loaded previously. It still return the SoundProps sound. It's not a breaking error.
+- *SoLoud().loadFile* now can return *PlayerErrors.fileAlreadyLoaded* when a sound has already been loaded previously. It still returns the SoundProps sound. It's not a breaking error.
- added *Soloud().disposeAllSound* to stop and dispose all active sounds
**breaking change**: *Soloud().stopSound* has been renamed to *Soloud().disposeSound*
@@ -632,7 +640,7 @@ added also a spin around example
- added 3D audio with example
#### 0.9.0
-- added capture from microphone with example
+- added capture from the microphone with an example
#### 0.1.0
diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart
index 5cde09cf..fcbb4c22 100644
--- a/lib/src/bindings/bindings_player.dart
+++ b/lib/src/bindings/bindings_player.dart
@@ -79,8 +79,12 @@ abstract class FlutterSoLoud {
/// [channels] mono, stereo, quad, 5.1, 7.1.
///
/// Returns [PlayerErrors.noError] if success.
+ ///
+ /// The blocking native engine/device initialization runs off the UI thread so
+ /// it does not freeze the app (#481); the future completes once the engine is
+ /// initialized.
@mustBeOverridden
- PlayerErrors initEngine(
+ Future initEngine(
int deviceId,
int sampleRate,
int bufferSize,
@@ -96,6 +100,37 @@ abstract class FlutterSoLoud {
@mustBeOverridden
void setAndroidAAudioAttributes(bool managed);
+ /// Android only: when [enable] is true, SoLoud stops the audio device once
+ /// the engine goes idle (no active voices), releasing the audioserver
+ /// AudioMix partial wakelock. Defaults to false, keeping the device running.
+ /// Can be called any time. No effect on non-Android backends or on web.
+ @mustBeOverridden
+ void setAndroidPauseDeviceWhenIdle(bool enable);
+
+ /// Stop the audio output device without deinitializing the engine. Only the
+ /// miniaudio device is stopped; loaded sounds, active voices and the
+ /// initialized state are preserved so playback can resume later with
+ /// [startAudioDevice]. Idempotent: a no-op if the device is already stopped.
+ ///
+ /// The blocking native device call runs off the UI thread so it does not
+ /// freeze the app; the returned future completes once the device is stopped.
+ @mustBeOverridden
+ Future stopAudioDevice();
+
+ /// Restart the audio output device previously stopped by [stopAudioDevice],
+ /// so existing voices and loaded sounds keep operating. Idempotent: a no-op
+ /// if the device is already started.
+ ///
+ /// The blocking native device call runs off the UI thread so it does not
+ /// freeze the app; the returned future completes once the device is running.
+ @mustBeOverridden
+ Future startAudioDevice();
+
+ /// Get the current state of the audio output device. Returns
+ /// [AudioDeviceState.uninitialized] if the engine is not initialized.
+ @mustBeOverridden
+ AudioDeviceState getAudioDeviceState();
+
/// Change the playback device.
///
/// [deviceId] the device ID. -1 for default OS output device.
@@ -109,6 +144,11 @@ abstract class FlutterSoLoud {
@mustBeOverridden
void deinit();
+ /// Like [deinit], but runs the blocking native teardown off the UI thread so
+ /// it does not freeze the app; the future completes once teardown is done.
+ @mustBeOverridden
+ Future deinitAsync();
+
/// Gets the state of player
///
/// Return true if initilized
diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart
index 184b2603..ca44262e 100644
--- a/lib/src/bindings/bindings_player_ffi.dart
+++ b/lib/src/bindings/bindings_player_ffi.dart
@@ -5,6 +5,7 @@
// ignore_for_file: omit_local_variable_types,public_member_api_docs
import 'dart:ffi' as ffi;
+import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
@@ -20,6 +21,64 @@ import 'package:flutter_soloud/src/sound_hash.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
+/// Rebuilds a `PlayerErrors Function()` device-lifecycle native function from
+/// its raw pointer [address] and invokes it, returning the raw error code.
+///
+/// Top-level so it can run inside an [Isolate.run] worker: the blocking native
+/// device start/stop then executes off the UI isolate instead of stalling it.
+/// Only [address] (a sendable int) crosses the isolate boundary; the pointer is
+/// reconstructed here and the same process-global device is operated on.
+int _invokeDeviceLifecycle(int address) {
+ final fn = ffi.Pointer>
+ .fromAddress(address)
+ .asFunction();
+ return fn();
+}
+
+/// Rebuilds the native `initEngine` function from its raw pointer [address] and
+/// invokes it, returning the raw [PlayerErrors] code.
+///
+/// Top-level so it can run inside an [Isolate.run] worker: the blocking native
+/// engine/device initialization (which can take seconds on Android/AAudio) then
+/// executes off the UI isolate instead of stalling it (#481). Only sendable
+/// ints cross the isolate boundary; the pointer is reconstructed here and the
+/// same process-global engine is initialized.
+int _invokeInitEngine(
+ int address,
+ int deviceId,
+ int sampleRate,
+ int bufferSize,
+ int channels,
+ int lowLatency,
+) {
+ final fn =
+ ffi.Pointer<
+ ffi.NativeFunction<
+ ffi.Int32 Function(
+ ffi.Int,
+ ffi.UnsignedInt,
+ ffi.UnsignedInt,
+ ffi.UnsignedInt,
+ ffi.UnsignedInt,
+ )
+ >
+ >.fromAddress(address)
+ .asFunction();
+ return fn(deviceId, sampleRate, bufferSize, channels, lowLatency);
+}
+
+/// Rebuilds a `void Function()` native function from its raw pointer [address]
+/// and invokes it.
+///
+/// Top-level so it can run inside an [Isolate.run] worker: the blocking native
+/// teardown (device uninit) then executes off the UI isolate instead of
+/// stalling it. Only [address] (a sendable int) crosses the isolate boundary.
+void _invokeVoidNative(int address) {
+ ffi.Pointer>
+ .fromAddress(address)
+ .asFunction()();
+}
+
typedef DartVoiceEndedCallbackT =
ffi.Pointer>;
@@ -260,19 +319,30 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
.asFunction)>();
@override
- PlayerErrors initEngine(
+ Future initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Channels channels,
bool lowLatency,
- ) {
- final ret = _initEngine(
- deviceId,
- sampleRate,
- bufferSize,
- channels.count,
- lowLatency ? 1 : 0,
+ ) async {
+ // Run the blocking native engine/device initialization off the UI isolate
+ // so it does not freeze the app (it can take seconds on Android/AAudio,
+ // tripping the ANR watchdog — see #481). Only the raw function pointer
+ // address and the primitive arguments (all sendable ints) are captured; the
+ // pointer is rebuilt and called inside the worker.
+ final address = _initEnginePtr.address;
+ final channelCount = channels.count;
+ final lowLatencyInt = lowLatency ? 1 : 0;
+ final ret = await Isolate.run(
+ () => _invokeInitEngine(
+ address,
+ deviceId,
+ sampleRate,
+ bufferSize,
+ channelCount,
+ lowLatencyInt,
+ ),
);
return PlayerErrors.values[ret];
}
@@ -289,8 +359,6 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
)
>
>('initEngine');
- late final _initEngine = _initEnginePtr
- .asFunction();
@override
void setAndroidAAudioAttributes(bool managed) {
@@ -304,6 +372,63 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
late final _setAndroidAAudioAttributes = _setAndroidAAudioAttributesPtr
.asFunction();
+ @override
+ void setAndroidPauseDeviceWhenIdle(bool enable) {
+ _setAndroidPauseDeviceWhenIdle(enable ? 1 : 0);
+ }
+
+ late final _setAndroidPauseDeviceWhenIdlePtr =
+ _lookup>(
+ 'setAndroidPauseDeviceWhenIdle',
+ );
+ late final _setAndroidPauseDeviceWhenIdle = _setAndroidPauseDeviceWhenIdlePtr
+ .asFunction();
+
+ @override
+ Future stopAudioDevice() async {
+ // Run the blocking native ma_device_stop() off the UI isolate. Only the
+ // raw function pointer address (a sendable int) is captured; the pointer
+ // is rebuilt and called inside the worker.
+ final address = _stopAudioDevicePtr.address;
+ final ret = await Isolate.run(() => _invokeDeviceLifecycle(address));
+ return PlayerErrors.values[ret];
+ }
+
+ late final _stopAudioDevicePtr =
+ _lookup>(
+ 'stopAudioDevice',
+ );
+
+ @override
+ Future startAudioDevice() async {
+ // Run the blocking native ma_device_start() off the UI isolate so the app
+ // stays responsive (it can take tens of ms while the OS restarts the
+ // device). Only the raw function pointer address (a sendable int) is
+ // captured; the pointer is rebuilt and called inside the worker.
+ final address = _startAudioDevicePtr.address;
+ final ret = await Isolate.run(() => _invokeDeviceLifecycle(address));
+ return PlayerErrors.values[ret];
+ }
+
+ late final _startAudioDevicePtr =
+ _lookup>(
+ 'startAudioDevice',
+ );
+
+ @override
+ AudioDeviceState getAudioDeviceState() {
+ // Reading the device state is a cheap, non-blocking atomic load, so call
+ // it directly on the UI isolate.
+ return AudioDeviceState.fromValue(_getAudioDeviceState());
+ }
+
+ late final _getAudioDeviceStatePtr =
+ _lookup>(
+ 'getAudioDeviceState',
+ );
+ late final _getAudioDeviceState = _getAudioDeviceStatePtr
+ .asFunction();
+
@override
PlayerErrors changeDevice(int deviceId) {
final ret = _changeDevice(deviceId);
@@ -414,6 +539,16 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
return _dispose();
}
+ @override
+ Future deinitAsync() async {
+ // Run the blocking native teardown (device uninit) off the UI isolate so
+ // it does not freeze the app. Only the raw function pointer address (a
+ // sendable int) is captured; the pointer is rebuilt and called in the
+ // worker.
+ final address = _disposePtr.address;
+ await Isolate.run(() => _invokeVoidNative(address));
+ }
+
late final _disposePtr = _lookup>(
'dispose',
);
diff --git a/lib/src/bindings/bindings_player_web.dart b/lib/src/bindings/bindings_player_web.dart
index 1ea2b07c..caea1a2a 100644
--- a/lib/src/bindings/bindings_player_web.dart
+++ b/lib/src/bindings/bindings_player_web.dart
@@ -116,13 +116,14 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
bool areXiphLibsAvailable() => wasmAreXiphLibsAvailable() == 1;
@override
- PlayerErrors initEngine(
+ Future initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Channels channels,
bool lowLatency,
- ) {
+ ) async {
+ // Web is single-threaded (no isolates), so call the wasm function directly.
// [lowLatency] only affects the native miniaudio backends (it selects the
// AAudio/CoreAudio performance profile); the Web Audio backend ignores it.
final ret = wasmInitEngine(
@@ -140,6 +141,32 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
// No-op on web: AAudio stream attributes are Android-only.
}
+ @override
+ void setAndroidPauseDeviceWhenIdle(bool enable) {
+ // No-op on web: no wakelock concept, device lifecycle differs.
+ }
+
+ @override
+ Future stopAudioDevice() async {
+ // Web is single-threaded (no isolates) and the device change is instant,
+ // so call the wasm function directly.
+ final ret = wasmStopAudioDevice();
+ return PlayerErrors.values[ret];
+ }
+
+ @override
+ Future startAudioDevice() async {
+ // Web is single-threaded (no isolates) and the device change is instant,
+ // so call the wasm function directly.
+ final ret = wasmStartAudioDevice();
+ return PlayerErrors.values[ret];
+ }
+
+ @override
+ AudioDeviceState getAudioDeviceState() {
+ return AudioDeviceState.fromValue(wasmGetAudioDeviceState());
+ }
+
@override
PlayerErrors changeDevice(int deviceId) {
final ret = wasmChangeDevice(deviceId);
@@ -186,6 +213,10 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
@override
void deinit() => wasmDeinit();
+ @override
+ // Web is single-threaded (no isolates), so call the wasm function directly.
+ Future deinitAsync() async => wasmDeinit();
+
@override
bool isInited() => wasmIsInited() == 1;
diff --git a/lib/src/bindings/js_extension.dart b/lib/src/bindings/js_extension.dart
index f5fb313d..89778f39 100644
--- a/lib/src/bindings/js_extension.dart
+++ b/lib/src/bindings/js_extension.dart
@@ -97,6 +97,15 @@ external int wasmInitEngine(
int lowLatency,
);
+@JS('Module_soloud._stopAudioDevice')
+external int wasmStopAudioDevice();
+
+@JS('Module_soloud._startAudioDevice')
+external int wasmStartAudioDevice();
+
+@JS('Module_soloud._getAudioDeviceState')
+external int wasmGetAudioDeviceState();
+
@JS('Module_soloud._changeDevice')
external int wasmChangeDevice(int deviceId);
diff --git a/lib/src/enums.dart b/lib/src/enums.dart
index eb8bd5ac..451f9660 100644
--- a/lib/src/enums.dart
+++ b/lib/src/enums.dart
@@ -336,6 +336,45 @@ enum PlayerStateNotification {
unlocked,
}
+/// The state of the audio output device, as reported by
+/// `SoLoud.getAudioDeviceState`.
+///
+/// The values mirror miniaudio's `ma_device_state`.
+///
+/// WARNING: Keep these in sync with `src/enums.h`.
+enum AudioDeviceState {
+ /// The device is uninitialized. Also returned before the engine is
+ /// initialized or after it has been deinitialized.
+ uninitialized(0),
+
+ /// The device is stopped. This is the device's default state right after
+ /// initialization (for example after `SoLoud.stopAudioDevice`).
+ stopped(1),
+
+ /// The device is started and is requesting and/or delivering audio data.
+ started(2),
+
+ /// The device is transitioning from a stopped state to a started state.
+ starting(3),
+
+ /// The device is transitioning from a started state to a stopped state.
+ stopping(4);
+
+ const AudioDeviceState(this.value);
+
+ /// Returns the [AudioDeviceState] for the given native integer [value],
+ /// falling back to [uninitialized] for any unknown value.
+ factory AudioDeviceState.fromValue(int value) {
+ return AudioDeviceState.values.firstWhere(
+ (state) => state.value == value,
+ orElse: () => AudioDeviceState.uninitialized,
+ );
+ }
+
+ /// The native integer value of the state.
+ final int value;
+}
+
/// The channels to be used while initializing the player.
enum Channels {
/// One channel.
diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart
index 65cd503d..94a580d2 100644
--- a/lib/src/soloud.dart
+++ b/lib/src/soloud.dart
@@ -429,7 +429,10 @@ interface class SoLoud {
androidAAudioAttributes == AndroidAAudioAttributes.mediaMusic,
);
- final error = _controller.soLoudFFI.initEngine(
+ // The blocking native engine/device initialization runs off the UI thread
+ // (via a worker isolate inside the binding) so it no longer freezes the app
+ // during startup — the ANR reported in #481.
+ final error = await _controller.soLoudFFI.initEngine(
device?.id ?? -1,
sampleRate,
bufferSize,
@@ -486,6 +489,69 @@ interface class SoLoud {
}
}
+ /// Stops the audio output device without deinitializing the engine.
+ ///
+ /// Only the underlying audio device is stopped. Loaded [AudioSource]s, active
+ /// voices, filters and the [isInitialized] state are all left untouched, so
+ /// playback resumes exactly where it left off once [startAudioDevice] is
+ /// called. The device is stopped even while sounds are actively playing.
+ ///
+ /// This is idempotent: calling it while the device is already stopped does
+ /// nothing.
+ ///
+ /// The blocking native device operation runs off the UI thread, so this does
+ /// not freeze the app; await the returned future to know when it completed.
+ ///
+ /// Throws [SoLoudNotInitializedException] if the engine is not initialized.
+ Future stopAudioDevice() async {
+ if (!isInitialized) {
+ throw const SoLoudNotInitializedException();
+ }
+
+ final error = await _controller.soLoudFFI.stopAudioDevice();
+ _logPlayerError(error, from: 'stopAudioDevice() result');
+ if (error != PlayerErrors.noError) {
+ throw SoLoudCppException.fromPlayerError(error);
+ }
+ }
+
+ /// Restarts the audio output device previously stopped by [stopAudioDevice],
+ /// so existing voices and loaded [AudioSource]s keep operating.
+ ///
+ /// This is idempotent: calling it while the device is already started does
+ /// nothing.
+ ///
+ /// The blocking native device operation runs off the UI thread, so this does
+ /// not freeze the app; await the returned future to know when the device is
+ /// running again.
+ ///
+ /// Throws [SoLoudNotInitializedException] if the engine is not initialized.
+ Future startAudioDevice() async {
+ if (!isInitialized) {
+ throw const SoLoudNotInitializedException();
+ }
+
+ final error = await _controller.soLoudFFI.startAudioDevice();
+ _logPlayerError(error, from: 'startAudioDevice() result');
+ if (error != PlayerErrors.noError) {
+ throw SoLoudCppException.fromPlayerError(error);
+ }
+ }
+
+ /// Gets the current state of the audio output device.
+ ///
+ /// Use this to check whether the device is currently
+ /// [AudioDeviceState.started] (actively delivering audio),
+ /// [AudioDeviceState.stopped] (for example after [stopAudioDevice]), or in a
+ /// transitional state. Returns [AudioDeviceState.uninitialized] if the engine
+ /// has not been initialized.
+ ///
+ /// This is a cheap, synchronous read and is safe to call at any time,
+ /// including before the engine is initialized.
+ AudioDeviceState getAudioDeviceState() {
+ return _controller.soLoudFFI.getAudioDeviceState();
+ }
+
/// Lists all OS available playback devices.
/// Could be called safely even if the engin has not been initialized yet.
List listPlaybackDevices() {
@@ -497,12 +563,42 @@ interface class SoLoud {
/// This method is meant to be called when exiting the app. For example
/// within the `dispose()` of the uppermost widget in the tree
/// or inside "AppLifecycleListener.onExitRequested".
+ ///
+ /// This is synchronous: the native teardown (which uninitializes the audio
+ /// device) runs on the calling thread. Use [deinitAsync] to run that teardown
+ /// off the UI thread when you can await it.
void deinit() {
_log.finest('deinit() called');
+ _predeinit();
+ _controller.soLoudFFI.deinit();
+ _postdeinit();
+ }
+
+ /// Like [deinit], but runs the blocking native teardown (audio device
+ /// uninitialization) off the UI thread so it does not freeze the app.
+ ///
+ /// Prefer this over [deinit] wherever you can await the result. [deinit] is
+ /// still provided for synchronous contexts such as
+ /// "AppLifecycleListener.onExitRequested".
+ Future deinitAsync() async {
+ _log.finest('deinitAsync() called');
+ _predeinit();
+ await _controller.soLoudFFI.deinitAsync();
+ _postdeinit();
+ }
+
+ /// Shared teardown steps that must run on the calling (UI) isolate before the
+ /// native teardown: closing the isolate-bound native callables and disposing
+ /// loaded sounds. See [deinit] and [deinitAsync].
+ void _predeinit() {
_nativeCallbacksInitialized = false;
_controller.soLoudFFI.disposeNativeCallables();
_controller.soLoudFFI.disposeAllSound();
- _controller.soLoudFFI.deinit();
+ }
+
+ /// Shared teardown steps that run after the native teardown. See [deinit] and
+ /// [deinitAsync].
+ void _postdeinit() {
_activeSounds.clear();
}
@@ -2223,6 +2319,35 @@ interface class SoLoud {
_controller.soLoudFFI.setMaxActiveVoiceCount(maxVoiceCount);
}
+ /// Controls whether the audio device is stopped on Android when no voices
+ /// are active (after SoLoud's internal ~500 ms idle-pause delay).
+ ///
+ /// Stopping the device does NOT invalidate any [AudioSource]s, sound
+ /// handles, or filters — engine state is preserved and the device restarts
+ /// automatically on the next play/resume, at the cost of a device-restart
+ /// latency of roughly tens of milliseconds.
+ ///
+ /// While the device is running — even rendering silence — Android's
+ /// audioserver holds an `AudioMix` partial wakelock attributed to your app.
+ /// Enabling this releases that wakelock while idle, which otherwise counts
+ /// toward Google Play's excessive-partial-wake-locks metric.
+ ///
+ /// Defaults to `false`: the historical Android behavior, which avoids rare
+ /// stale-buffer glitches on very rapid stop→play cycles. Recommended `true`
+ /// for apps where playback commonly sits paused in the background.
+ ///
+ /// Takes effect immediately: enabling this while nothing is playing schedules
+ /// the device to stop (after SoLoud's ~500 ms idle-pause delay) rather than
+ /// waiting for the next pause/stop; disabling it restarts a device that a
+ /// previous idle-pause had stopped. The ~500 ms delay before the device stops
+ /// on pause is always applied.
+ ///
+ /// No effect on iOS/macOS/desktop (already stop the device when idle) or
+ /// Web (no-op). Can be called any time, before or after [init].
+ void setAndroidPauseDeviceWhenIdle(bool enable) {
+ _controller.soLoudFFI.setAndroidPauseDeviceWhenIdle(enable);
+ }
+
/// Smooth FFT data.
/// When new data is read and the values are decreasing, the new value
/// will be decreased with an amplitude between the old and the new value.
diff --git a/src/bindings.cpp b/src/bindings.cpp
index 5e9e4a1b..8b6f29b9 100644
--- a/src/bindings.cpp
+++ b/src/bindings.cpp
@@ -257,6 +257,46 @@ FFI_PLUGIN_EXPORT void setAndroidAAudioAttributes(unsigned int managed) {
SoLoud::miniaudio_setAndroidAAudioAttributes(managed != 0);
}
+/// Android only: choose whether SoLoud stops the audio device when the engine
+/// goes idle (no active voices), releasing the audioserver AudioMix partial
+/// wakelock. [enable] != 0 turns this on; 0 (default) keeps the historical
+/// behavior of leaving the device running. Can be called any time. No effect on
+/// other backends.
+FFI_PLUGIN_EXPORT void setAndroidPauseDeviceWhenIdle(unsigned int enable) {
+ player.get()->setAndroidPauseDeviceWhenIdle(enable != 0);
+}
+
+/// Stop the audio output device without deinitializing the engine. Only the
+/// miniaudio device is stopped; loaded sounds, active voices and the
+/// initialized state are preserved so playback can be resumed later with
+/// startAudioDevice(). Idempotent: a no-op if the device is already stopped.
+FFI_PLUGIN_EXPORT enum PlayerErrors stopAudioDevice() {
+ if (player.get() == nullptr)
+ return backendNotInited;
+
+ return player.get()->stopAudioDevice();
+}
+
+/// Restart the audio output device previously stopped by stopAudioDevice(), so
+/// existing voices and loaded sounds keep operating. Idempotent: a no-op if the
+/// device is already started.
+FFI_PLUGIN_EXPORT enum PlayerErrors startAudioDevice() {
+ if (player.get() == nullptr)
+ return backendNotInited;
+
+ return player.get()->startAudioDevice();
+}
+
+/// Get the current state of the audio output device. Returns
+/// [AudioDeviceState.audioDeviceUninitialized] if the engine is not
+/// initialized.
+FFI_PLUGIN_EXPORT enum AudioDeviceState getAudioDeviceState() {
+ if (player.get() == nullptr)
+ return audioDeviceUninitialized;
+
+ return player.get()->getAudioDeviceState();
+}
+
/// Change the playback device.
///
/// [deviceID] the device ID. -1 for default OS output device.
diff --git a/src/enums.h b/src/enums.h
index 84b0675b..9de4ee4e 100644
--- a/src/enums.h
+++ b/src/enums.h
@@ -99,6 +99,27 @@ typedef enum PlayerStateEvents {
event_unlocked,
} PlayerEvents_t;
+/// The state of the audio output device.
+///
+/// The values mirror miniaudio's `ma_device_state` so they can be returned
+/// directly from the backend without translation.
+///
+/// WARNING: Keep these in sync with `lib/src/enums.dart`.
+typedef enum AudioDeviceState {
+ /// The device is uninitialized. Also returned before the engine is
+ /// initialized or after it has been deinitialized.
+ audioDeviceUninitialized = 0,
+ /// The device is stopped. This is the device's default state right after
+ /// initialization.
+ audioDeviceStopped = 1,
+ /// The device is started and is requesting and/or delivering audio data.
+ audioDeviceStarted = 2,
+ /// The device is transitioning from a stopped state to a started state.
+ audioDeviceStarting = 3,
+ /// The device is transitioning from a started state to a stopped state.
+ audioDeviceStopping = 4,
+} AudioDeviceState_t;
+
typedef enum SoundType {
// using Soloud::wav
TYPE_WAV,
diff --git a/src/ffi_gen_tmp.h b/src/ffi_gen_tmp.h
index d2e803e5..8dc8d87a 100644
--- a/src/ffi_gen_tmp.h
+++ b/src/ffi_gen_tmp.h
@@ -106,4 +106,26 @@ FFI_PLUGIN_EXPORT void busAnnexSound(unsigned int busId,
///
/// [busId] the bus ID.
/// Returns the active voice count, or 0 if the bus is not found.
-FFI_PLUGIN_EXPORT unsigned int busGetActiveVoiceCount(unsigned int busId);
\ No newline at end of file
+FFI_PLUGIN_EXPORT unsigned int busGetActiveVoiceCount(unsigned int busId);
+
+/// Android only: choose whether SoLoud stops the audio device when the engine
+/// goes idle (no active voices), releasing the audioserver AudioMix partial
+/// wakelock. [enable] != 0 turns this on; 0 (default) keeps the historical
+/// behavior of leaving the device running. Can be called any time. No effect on
+/// other backends.
+FFI_PLUGIN_EXPORT void setAndroidPauseDeviceWhenIdle(unsigned int enable);
+
+/// Stop the audio output device without deinitializing the engine. Only the
+/// miniaudio device is stopped; loaded sounds, active voices and the
+/// initialized state are preserved so playback can be resumed later with
+/// startAudioDevice(). Idempotent: a no-op if the device is already stopped.
+FFI_PLUGIN_EXPORT enum PlayerErrors stopAudioDevice();
+
+/// Restart the audio output device previously stopped by stopAudioDevice(), so
+/// existing voices and loaded sounds keep operating. Idempotent: a no-op if the
+/// device is already started.
+FFI_PLUGIN_EXPORT enum PlayerErrors startAudioDevice();
+
+/// Get the current state of the audio output device. Returns
+/// audioDeviceUninitialized if the engine is not initialized.
+FFI_PLUGIN_EXPORT enum AudioDeviceState getAudioDeviceState();
\ No newline at end of file
diff --git a/src/player.cpp b/src/player.cpp
index e07eb9a6..2c956243 100644
--- a/src/player.cpp
+++ b/src/player.cpp
@@ -197,6 +197,10 @@ void Player::setStateChangedCallback(void (*stateChangedCallback)(unsigned int))
// Defined in the miniaudio backend (soloud_miniaudio.cpp). Forward-declared
// here so we don't need to pull in the backend-internal header.
namespace SoLoud { void miniaudio_setLowLatency(bool aLowLatency); }
+namespace SoLoud { void miniaudio_setAndroidPauseDeviceWhenIdle(bool aEnable); }
+namespace SoLoud { SoLoud::result miniaudio_stopAudioDevice(); }
+namespace SoLoud { SoLoud::result miniaudio_startAudioDevice(); }
+namespace SoLoud { unsigned int miniaudio_getAudioDeviceState(); }
PlayerErrors Player::init(unsigned int sampleRate, unsigned int bufferSize, unsigned int channels, int deviceID, bool lowLatency)
{
@@ -694,7 +698,9 @@ PlayerErrors Player::loadWaveform(
sounds.push_back(std::make_unique());
sounds.back().get()->completeFileName = "";
sounds.back().get()->soundHash = hash;
- sounds.back().get()->sound = std::make_unique((SoLoud::Soloud::WAVEFORM)waveform, superWave, detune, scale);
+ auto basicWave = std::make_unique((SoLoud::Soloud::WAVEFORM)waveform, superWave, detune, scale);
+ basicWave->setSamplerate(mSampleRate);
+ sounds.back().get()->sound = std::move(basicWave);
sounds.back().get()->soundType = TYPE_SYNTH;
sounds.back().get()->filters = std::make_unique(&soloud, sounds.back().get(), nullptr);
}
@@ -815,6 +821,68 @@ void Player::pauseEngine()
#endif
}
+void Player::setAndroidPauseDeviceWhenIdle(bool enable)
+{
+#if defined(__ANDROID__)
+ // Update the backend flag first so any subsequent pause honors the choice.
+ SoLoud::miniaudio_setAndroidPauseDeviceWhenIdle(enable);
+
+ // Apply the change to the running device immediately, based on the current
+ // state, instead of waiting for the next pause/stop event.
+ if (!mInited)
+ return;
+
+ if (enable)
+ {
+ // If nothing is actively playing, the device may be sitting started
+ // but idle (holding the AudioMix wakelock). Schedule the deferred stop
+ // now; this reuses pauseEngine()'s ~500 ms coalescing so it behaves
+ // exactly like an idle-pause triggered by the last voice stopping.
+ if (soloud.getActiveVoiceCount() == 0)
+ pauseEngine();
+ }
+ else
+ {
+ // Reverting to the historical always-on behavior: if a previous
+ // idle-pause already stopped the device, start it back up right away.
+ // resume() is a no-op when the device is already running.
+ soloud.resume();
+ }
+#else
+ (void)enable;
+#endif
+}
+
+PlayerErrors Player::stopAudioDevice()
+{
+ if (!mInited)
+ return backendNotInited;
+
+ SoLoud::result result = SoLoud::miniaudio_stopAudioDevice();
+ if (result != SoLoud::SO_NO_ERROR)
+ return unknownError;
+ return noError;
+}
+
+PlayerErrors Player::startAudioDevice()
+{
+ if (!mInited)
+ return backendNotInited;
+
+ SoLoud::result result = SoLoud::miniaudio_startAudioDevice();
+ if (result != SoLoud::SO_NO_ERROR)
+ return unknownError;
+ return noError;
+}
+
+AudioDeviceState Player::getAudioDeviceState()
+{
+ if (!mInited)
+ return audioDeviceUninitialized;
+
+ return (AudioDeviceState)SoLoud::miniaudio_getAudioDeviceState();
+}
+
void Player::startPauseEngineScheduler()
{
#ifndef __EMSCRIPTEN__
diff --git a/src/player.h b/src/player.h
index b08af506..6768f9b8 100644
--- a/src/player.h
+++ b/src/player.h
@@ -210,6 +210,36 @@ class Player {
/// audio session (e.g. Control Center on iOS).
void pauseEngine();
+ /// @brief Android only: opt in/out of stopping the audio device when the
+ /// engine goes idle (no active voices), releasing the audioserver AudioMix
+ /// partial wakelock. Takes effect immediately based on the current state:
+ /// when enabling while already idle, a deferred device stop is scheduled
+ /// (honoring the same ~500 ms coalescing delay as any other pause); when
+ /// disabling, a device previously stopped by an idle-pause is restarted at
+ /// once. No effect on non-Android backends.
+ /// @param enable whether the device should be stopped while idle.
+ void setAndroidPauseDeviceWhenIdle(bool enable);
+
+ /// @brief Stop the audio output device without deinitializing the engine.
+ /// Only the miniaudio device is stopped; loaded sounds, active voices and
+ /// the initialized state are all preserved so playback can be resumed later
+ /// with startAudioDevice(). Stops the device even while voices are actively
+ /// playing. Idempotent: a no-op if the device is already stopped.
+ /// @return Returns [PlayerErrors.SO_NO_ERROR] if success.
+ PlayerErrors stopAudioDevice();
+
+ /// @brief Restart the audio output device previously stopped by
+ /// stopAudioDevice(), so existing voices and loaded sounds keep operating.
+ /// Idempotent: a no-op if the device is already started.
+ /// @return Returns [PlayerErrors.SO_NO_ERROR] if success.
+ PlayerErrors startAudioDevice();
+
+ /// @brief Get the current state of the audio output device.
+ /// @return The current [AudioDeviceState]. Returns
+ /// [AudioDeviceState.audioDeviceUninitialized] if the engine is not
+ /// initialized.
+ AudioDeviceState getAudioDeviceState();
+
/// @brief Gets the pause state.
/// @param handle the sound handle.
/// @return true if paused.
diff --git a/src/soloud/include/soloud_internal.h b/src/soloud/include/soloud_internal.h
index dfe0ea47..2e4d69dc 100644
--- a/src/soloud/include/soloud_internal.h
+++ b/src/soloud/include/soloud_internal.h
@@ -85,6 +85,25 @@ namespace SoLoud
// AudioAttributes externally (e.g. via audio_session). Must be called before
// init. No-op effect on non-Android backends.
void miniaudio_setAndroidAAudioAttributes(bool aManaged);
+ // Android only: when true, stops the miniaudio device once the engine goes
+ // idle (no active voices), releasing the audioserver AudioMix partial
+ // wakelock (#250). Defaults to false, keeping the historical behavior of
+ // leaving the device running to avoid rare stale-buffer glitches (#446).
+ // Can be called any time. No-op effect on non-Android backends.
+ void miniaudio_setAndroidPauseDeviceWhenIdle(bool aEnable);
+ // Unconditionally stop the miniaudio output device (regardless of platform
+ // idle-pause policy or active voices) without deinitialising SoLoud or
+ // touching its voices/sources. Idempotent: no-op if already stopped.
+ result miniaudio_stopAudioDevice();
+ // Restart the miniaudio output device stopped by miniaudio_stopAudioDevice()
+ // so existing voices and sources keep operating. Idempotent: no-op if
+ // already started.
+ result miniaudio_startAudioDevice();
+ // Returns the current state of the miniaudio output device as the raw
+ // ma_device_state value (0 = uninitialized, 1 = stopped, 2 = started,
+ // 3 = starting, 4 = stopping). Returns 0 (uninitialized) if the device has
+ // not been initialized.
+ unsigned int miniaudio_getAudioDeviceState();
// nosound back-end initialization call
result nosound_init(SoLoud::Soloud* aSoloud, unsigned int aFlags = Soloud::CLIP_ROUNDOFF, unsigned int aSamplerate = 44100, unsigned int aBuffer = 2048, unsigned int aChannels = 2);
diff --git a/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp b/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp
index d44d2469..18755502 100644
--- a/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp
+++ b/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp
@@ -66,6 +66,7 @@ namespace SoLoud
#include
#endif
#include
+#include
#include
#include
#include
@@ -104,6 +105,16 @@ namespace SoLoud
static ma_aaudio_usage gMiniaudioAAudioUsage = ma_aaudio_usage_media;
static ma_aaudio_content_type gMiniaudioAAudioContentType = ma_aaudio_content_type_music;
+ // Android only: opt-in flag controlling whether soloud_miniaudio_pause()
+ // actually stops the miniaudio device when the engine goes idle (no active
+ // voices). Default false preserves the historical Android behavior of
+ // keeping the device running (see #446). When true, Android behaves like
+ // every other native platform and stops the device, releasing the
+ // audioserver AudioMix partial wakelock (see #250). Written rarely from the
+ // caller thread, read on the pause path — made atomic for that cross-thread
+ // read/write.
+ static std::atomic gAndroidPauseDeviceWhenIdle{false};
+
// Forward declarations for functions used in on_notification
result soloud_miniaudio_pause(SoLoud::Soloud *aSoloud);
result soloud_miniaudio_resume(SoLoud::Soloud *aSoloud);
@@ -192,6 +203,11 @@ namespace SoLoud
aManaged ? ma_aaudio_content_type_music : ma_aaudio_content_type_default;
}
+ void miniaudio_setAndroidPauseDeviceWhenIdle(bool aEnable)
+ {
+ gAndroidPauseDeviceWhenIdle.store(aEnable);
+ }
+
void soloud_miniaudio_audiomixer(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount)
{
static bool first_call = true;
@@ -288,16 +304,33 @@ namespace SoLoud
{
if (ma_device_get_state(&gDevice) == ma_device_state_started)
{
-#if defined(__EMSCRIPTEN__) || defined(__ANDROID__)
- /* On Web and Android, don't suspend the audio device to avoid a bug where
+#if defined(__EMSCRIPTEN__)
+ /* On Web, don't suspend the audio device to avoid a bug where
stale buffered audio data can fire after the device is stopped but before
it takes effect. When stop() and play() are called in quick succession,
those stale buffers get queued and play after resume(), causing audio
glitches and lag. Keeping the device running is safe: soloud->mix()
produces silence when no voices are active, which has negligible overhead.
- This solves #446 on both Web and Android. */
+ This solves #446 on Web. */
(void)aSoloud;
return 0;
+#elif defined(__ANDROID__)
+ /* On Android the same #446 stale-buffer glitch can occur on very rapid
+ stop->play cycles, so the device is kept running by default (mix()
+ renders silence when idle, negligible overhead). But leaving the device
+ started also keeps the audioserver AudioMix partial wakelock alive
+ against the app's UID, which counts toward Google Play's
+ excessive-partial-wake-locks metric (#250). Opt in via
+ miniaudio_setAndroidPauseDeviceWhenIdle(true) to stop the device when
+ idle and release that wakelock, accepting the rare #446 risk. */
+ if (!gAndroidPauseDeviceWhenIdle.load())
+ {
+ (void)aSoloud;
+ return 0;
+ }
+ ma_result res = ma_device_stop(&gDevice);
+ if (res != MA_SUCCESS)
+ return UNKNOWN_ERROR;
#else
ma_result res = ma_device_stop(&gDevice);
if (res != MA_SUCCESS)
@@ -344,6 +377,46 @@ namespace SoLoud
return 0;
}
+ // Unconditionally stop the miniaudio output device, regardless of platform
+ // idle-pause policy or whether voices are still active. Only the device is
+ // touched: SoLoud is not deinitialised and its voices/sources are left
+ // untouched, so miniaudio_startAudioDevice() can resume rendering exactly
+ // where it left off. Idempotent: a no-op if the device is already stopped.
+ result miniaudio_stopAudioDevice()
+ {
+ if (ma_device_get_state(&gDevice) == ma_device_state_started)
+ {
+ ma_result res = ma_device_stop(&gDevice);
+ if (res != MA_SUCCESS)
+ return UNKNOWN_ERROR;
+ }
+ return 0;
+ }
+
+ // Restart the miniaudio output device previously stopped by
+ // miniaudio_stopAudioDevice(). Idempotent: a no-op if the device is already
+ // started.
+ result miniaudio_startAudioDevice()
+ {
+ if (ma_device_get_state(&gDevice) == ma_device_state_stopped)
+ {
+ ma_result res = ma_device_start(&gDevice);
+ if (res != MA_SUCCESS)
+ return UNKNOWN_ERROR;
+ }
+ return 0;
+ }
+
+ // Return the current state of the miniaudio output device as the raw
+ // ma_device_state value. When the device has not been initialized there is
+ // no valid device to query, so report ma_device_state_uninitialized.
+ unsigned int miniaudio_getAudioDeviceState()
+ {
+ if (!gDeviceInitialized)
+ return ma_device_state_uninitialized;
+ return (unsigned int)ma_device_get_state(&gDevice);
+ }
+
result miniaudio_init(SoLoud::Soloud *aSoloud, unsigned int aFlags, unsigned int aSamplerate, unsigned int aBuffer, unsigned int aChannels, void *pPlaybackInfos_id)
{
soloud = aSoloud;