From 5709b72c846bdfd22f9dd79876e0b91836cbf550 Mon Sep 17 00:00:00 2001 From: Colton Grubbs Date: Sun, 12 Jul 2026 18:01:22 -0400 Subject: [PATCH 1/8] Match waveform sources to the engine sample rate --- src/player.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/player.cpp b/src/player.cpp index e07eb9a6..b7e8b1d6 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -694,7 +694,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); } From 0c0c2123b26a0c8bbfad7fd55f76fa903ae96b36 Mon Sep 17 00:00:00 2001 From: Marco Bavagnoli Date: Tue, 14 Jul 2026 13:52:42 +0200 Subject: [PATCH 2/8] update CHANGELOG --- CHANGELOG.md | 83 +++++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b0068d..83fd17bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +#### 4.0.13 (XX Xxx 2026) +- fix: Waveform audio sources do not match engine sample rate #501. Thanks to @Colton127 + #### 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 +12,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 +21,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 +36,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 +57,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 +67,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 +77,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 +87,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 +109,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 +137,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 +159,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 +169,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 +201,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 +227,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 +245,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 +269,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 +277,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 +287,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 +298,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 +334,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 +349,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 +364,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 +405,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 +520,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 +563,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 +598,7 @@ fixed compilation on Windows - freeverbFilter #### 1.2.1 -- binded some more SoLoud functionalities: +- bound some more SoLoud functionalities: - fadeGlobalVolume - fadeVolume - fadePan @@ -615,7 +618,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 +635,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 From 3ce4293f665315d7b27b69bdd7b9cbf4c5d4e8a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 02:13:45 +0000 Subject: [PATCH 3/8] Add opt-in Android idle device-pause (setAndroidPauseDeviceWhenIdle) On Android, soloud_miniaudio_pause() was compiled to a no-op, so the miniaudio device (and the audioserver AudioMix partial wakelock it holds, attributed to the app's UID) stayed alive even when all voices were paused. This counts against Google Play's excessive-partial-wake-locks metric (#250). Add a runtime flag (default false) that lets the Android branch of soloud_miniaudio_pause() stop the device when idle, like every other native platform. It stays off by default to preserve the historical behavior that avoids the rare stale-buffer glitch on rapid stop->play (#446). - src/soloud/.../soloud_miniaudio.cpp: add atomic gAndroidPauseDeviceWhenIdle flag + miniaudio_setAndroidPauseDeviceWhenIdle setter; un-gate the Android pause branch behind it (Web branch unchanged). - src/soloud/include/soloud_internal.h: declare the new setter. - src/bindings.cpp + src/ffi_gen_tmp.h: FFI export mirroring setAndroidAAudioAttributes. - Dart bindings (abstract/FFI/web) + public SoLoud.setAndroidPauseDeviceWhenIdle. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Mf2uYriAipRTFF5zCXDMDw --- CHANGELOG.md | 1 + lib/src/bindings/bindings_player.dart | 7 ++++ lib/src/bindings/bindings_player_ffi.dart | 12 ++++++ lib/src/bindings/bindings_player_web.dart | 5 +++ lib/src/soloud.dart | 23 +++++++++++ src/bindings.cpp | 9 +++++ src/ffi_gen_tmp.h | 9 ++++- src/soloud/include/soloud_internal.h | 6 +++ .../backend/miniaudio/soloud_miniaudio.cpp | 39 +++++++++++++++++-- 9 files changed, 107 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83fd17bd..8ad37a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ #### 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 #### 4.0.12 (30 Jun 2026) - add `lowLatency` init option to allow recordable Android output #492. Thanks to @MjnMixael diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart index 5cde09cf..89abece7 100644 --- a/lib/src/bindings/bindings_player.dart +++ b/lib/src/bindings/bindings_player.dart @@ -96,6 +96,13 @@ 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); + /// Change the playback device. /// /// [deviceId] the device ID. -1 for default OS output device. diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index 184b2603..7c02f372 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -304,6 +304,18 @@ 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 PlayerErrors changeDevice(int deviceId) { final ret = _changeDevice(deviceId); diff --git a/lib/src/bindings/bindings_player_web.dart b/lib/src/bindings/bindings_player_web.dart index 1ea2b07c..9419eaf7 100644 --- a/lib/src/bindings/bindings_player_web.dart +++ b/lib/src/bindings/bindings_player_web.dart @@ -140,6 +140,11 @@ 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 PlayerErrors changeDevice(int deviceId) { final ret = wasmChangeDevice(deviceId); diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart index 65cd503d..c19b1ad9 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -2223,6 +2223,29 @@ 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. + /// + /// 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..b6ebc63d 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -257,6 +257,15 @@ 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) { + SoLoud::miniaudio_setAndroidPauseDeviceWhenIdle(enable != 0); +} + /// Change the playback device. /// /// [deviceID] the device ID. -1 for default OS output device. diff --git a/src/ffi_gen_tmp.h b/src/ffi_gen_tmp.h index d2e803e5..71593ecc 100644 --- a/src/ffi_gen_tmp.h +++ b/src/ffi_gen_tmp.h @@ -106,4 +106,11 @@ 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); \ No newline at end of file diff --git a/src/soloud/include/soloud_internal.h b/src/soloud/include/soloud_internal.h index dfe0ea47..13fe6f8e 100644 --- a/src/soloud/include/soloud_internal.h +++ b/src/soloud/include/soloud_internal.h @@ -85,6 +85,12 @@ 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); // 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..bedfebda 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) From 7afa92504647eccc25bc42ce7a1c7b198ec7aa33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 02:36:09 +0000 Subject: [PATCH 4/8] Apply setAndroidPauseDeviceWhenIdle immediately based on current state Previously the setter only stored the backend flag, so enabling it while the engine was already idle left the device (and its AudioMix wakelock) running until the next pause/stop event. Route the FFI export through a new Player::setAndroidPauseDeviceWhenIdle passthrough that also acts on the current runtime state: - enable + no active voices -> schedule the deferred device stop now via pauseEngine(), reusing the same ~500 ms coalescing delay as a normal idle-pause (so the delay is preserved). - disable -> restart the device immediately if a prior idle-pause stopped it (resume() is a no-op when already running). bindings.cpp now calls the Player passthrough instead of the backend directly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Mf2uYriAipRTFF5zCXDMDw --- lib/src/soloud.dart | 6 ++++++ src/bindings.cpp | 2 +- src/player.cpp | 33 +++++++++++++++++++++++++++++++++ src/player.h | 10 ++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart index c19b1ad9..7569821d 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -2240,6 +2240,12 @@ interface class SoLoud { /// 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) { diff --git a/src/bindings.cpp b/src/bindings.cpp index b6ebc63d..759a0335 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -263,7 +263,7 @@ FFI_PLUGIN_EXPORT void setAndroidAAudioAttributes(unsigned int managed) { /// behavior of leaving the device running. Can be called any time. No effect on /// other backends. FFI_PLUGIN_EXPORT void setAndroidPauseDeviceWhenIdle(unsigned int enable) { - SoLoud::miniaudio_setAndroidPauseDeviceWhenIdle(enable != 0); + player.get()->setAndroidPauseDeviceWhenIdle(enable != 0); } /// Change the playback device. diff --git a/src/player.cpp b/src/player.cpp index b7e8b1d6..b0ada98c 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -197,6 +197,7 @@ 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); } PlayerErrors Player::init(unsigned int sampleRate, unsigned int bufferSize, unsigned int channels, int deviceID, bool lowLatency) { @@ -817,6 +818,38 @@ 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 +} + void Player::startPauseEngineScheduler() { #ifndef __EMSCRIPTEN__ diff --git a/src/player.h b/src/player.h index b08af506..3c92531d 100644 --- a/src/player.h +++ b/src/player.h @@ -210,6 +210,16 @@ 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 Gets the pause state. /// @param handle the sound handle. /// @return true if paused. From f45fae10709f1ac535fd452301180832db3e5d4b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:02:31 +0000 Subject: [PATCH 5/8] Add stopAudioDevice()/startAudioDevice() to control the output device Expose a way to stop and restart the miniaudio output device without tearing down the engine. Stopping only calls ma_device_stop() on the existing device; SoLoud is not deinitialised and its loaded sources, active voices and initialized state are all preserved, so startAudioDevice() resumes rendering exactly where it left off. Unlike the idle-pause path, the stop is unconditional: it runs regardless of platform idle policy or whether voices are actively playing (needed for testing). Both methods are idempotent via ma_device_get_state(). - src/soloud/.../soloud_miniaudio.cpp: miniaudio_stopAudioDevice()/ miniaudio_startAudioDevice() using ma_device_stop/start with state checks. - src/soloud/include/soloud_internal.h: declarations. - src/player.{h,cpp}: Player::stopAudioDevice()/startAudioDevice() with the usual mInited/PlayerErrors handling. - src/bindings.cpp + src/ffi_gen_tmp.h: FFI exports mirroring changeDevice. - Dart bindings (abstract/FFI/web + js_extension) and public SoLoud.stopAudioDevice()/startAudioDevice(). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D7UTvguQ31vuiRqcPeMBTC --- CHANGELOG.md | 1 + lib/src/bindings/bindings_player.dart | 13 ++++++ lib/src/bindings/bindings_player_ffi.dart | 26 ++++++++++++ lib/src/bindings/bindings_player_web.dart | 12 ++++++ lib/src/bindings/js_extension.dart | 6 +++ lib/src/soloud.dart | 42 +++++++++++++++++++ src/bindings.cpp | 21 ++++++++++ src/ffi_gen_tmp.h | 13 +++++- src/player.cpp | 24 +++++++++++ src/player.h | 14 +++++++ src/soloud/include/soloud_internal.h | 8 ++++ .../backend/miniaudio/soloud_miniaudio.cpp | 30 +++++++++++++ 12 files changed, 209 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad37a6e..c070876d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ #### 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) #### 4.0.12 (30 Jun 2026) - add `lowLatency` init option to allow recordable Android output #492. Thanks to @MjnMixael diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart index 89abece7..fe30407b 100644 --- a/lib/src/bindings/bindings_player.dart +++ b/lib/src/bindings/bindings_player.dart @@ -103,6 +103,19 @@ abstract class FlutterSoLoud { @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. + @mustBeOverridden + 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. + @mustBeOverridden + PlayerErrors startAudioDevice(); + /// Change the playback device. /// /// [deviceId] the device ID. -1 for default OS output device. diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index 7c02f372..f7f1124f 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -316,6 +316,32 @@ class FlutterSoLoudFfi extends FlutterSoLoud { late final _setAndroidPauseDeviceWhenIdle = _setAndroidPauseDeviceWhenIdlePtr .asFunction(); + @override + PlayerErrors stopAudioDevice() { + final ret = _stopAudioDevice(); + return PlayerErrors.values[ret]; + } + + late final _stopAudioDevicePtr = + _lookup>( + 'stopAudioDevice', + ); + late final _stopAudioDevice = _stopAudioDevicePtr + .asFunction(); + + @override + PlayerErrors startAudioDevice() { + final ret = _startAudioDevice(); + return PlayerErrors.values[ret]; + } + + late final _startAudioDevicePtr = + _lookup>( + 'startAudioDevice', + ); + late final _startAudioDevice = _startAudioDevicePtr + .asFunction(); + @override PlayerErrors changeDevice(int deviceId) { final ret = _changeDevice(deviceId); diff --git a/lib/src/bindings/bindings_player_web.dart b/lib/src/bindings/bindings_player_web.dart index 9419eaf7..c05dabcb 100644 --- a/lib/src/bindings/bindings_player_web.dart +++ b/lib/src/bindings/bindings_player_web.dart @@ -145,6 +145,18 @@ class FlutterSoLoudWeb extends FlutterSoLoud { // No-op on web: no wakelock concept, device lifecycle differs. } + @override + PlayerErrors stopAudioDevice() { + final ret = wasmStopAudioDevice(); + return PlayerErrors.values[ret]; + } + + @override + PlayerErrors startAudioDevice() { + final ret = wasmStartAudioDevice(); + return PlayerErrors.values[ret]; + } + @override PlayerErrors changeDevice(int deviceId) { final ret = wasmChangeDevice(deviceId); diff --git a/lib/src/bindings/js_extension.dart b/lib/src/bindings/js_extension.dart index f5fb313d..93de716c 100644 --- a/lib/src/bindings/js_extension.dart +++ b/lib/src/bindings/js_extension.dart @@ -97,6 +97,12 @@ external int wasmInitEngine( int lowLatency, ); +@JS('Module_soloud._stopAudioDevice') +external int wasmStopAudioDevice(); + +@JS('Module_soloud._startAudioDevice') +external int wasmStartAudioDevice(); + @JS('Module_soloud._changeDevice') external int wasmChangeDevice(int deviceId); diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart index 7569821d..d2382210 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -486,6 +486,48 @@ 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. + /// + /// Throws [SoLoudNotInitializedException] if the engine is not initialized. + Future stopAudioDevice() async { + if (!isInitialized) { + throw const SoLoudNotInitializedException(); + } + + final error = _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. + /// + /// Throws [SoLoudNotInitializedException] if the engine is not initialized. + Future startAudioDevice() async { + if (!isInitialized) { + throw const SoLoudNotInitializedException(); + } + + final error = _controller.soLoudFFI.startAudioDevice(); + _logPlayerError(error, from: 'startAudioDevice() result'); + if (error != PlayerErrors.noError) { + throw SoLoudCppException.fromPlayerError(error); + } + } + /// Lists all OS available playback devices. /// Could be called safely even if the engin has not been initialized yet. List listPlaybackDevices() { diff --git a/src/bindings.cpp b/src/bindings.cpp index 759a0335..dc5cd9e7 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -266,6 +266,27 @@ 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(); +} + /// Change the playback device. /// /// [deviceID] the device ID. -1 for default OS output device. diff --git a/src/ffi_gen_tmp.h b/src/ffi_gen_tmp.h index 71593ecc..82f65f69 100644 --- a/src/ffi_gen_tmp.h +++ b/src/ffi_gen_tmp.h @@ -113,4 +113,15 @@ FFI_PLUGIN_EXPORT unsigned int busGetActiveVoiceCount(unsigned int busId); /// 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); \ No newline at end of file +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(); \ No newline at end of file diff --git a/src/player.cpp b/src/player.cpp index b0ada98c..5d85f2ce 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -198,6 +198,8 @@ void Player::setStateChangedCallback(void (*stateChangedCallback)(unsigned int)) // 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(); } PlayerErrors Player::init(unsigned int sampleRate, unsigned int bufferSize, unsigned int channels, int deviceID, bool lowLatency) { @@ -850,6 +852,28 @@ void Player::setAndroidPauseDeviceWhenIdle(bool 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; +} + void Player::startPauseEngineScheduler() { #ifndef __EMSCRIPTEN__ diff --git a/src/player.h b/src/player.h index 3c92531d..c8709375 100644 --- a/src/player.h +++ b/src/player.h @@ -220,6 +220,20 @@ class Player { /// @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 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 13fe6f8e..586996f0 100644 --- a/src/soloud/include/soloud_internal.h +++ b/src/soloud/include/soloud_internal.h @@ -91,6 +91,14 @@ namespace SoLoud // 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(); // 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 bedfebda..ace1324e 100644 --- a/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp +++ b/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp @@ -377,6 +377,36 @@ 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; + } + result miniaudio_init(SoLoud::Soloud *aSoloud, unsigned int aFlags, unsigned int aSamplerate, unsigned int aBuffer, unsigned int aChannels, void *pPlaybackInfos_id) { soloud = aSoloud; From a944ffdad7abddabcb5fc2e63c911c150fef6960 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:39:46 +0000 Subject: [PATCH 6/8] Run startAudioDevice()/stopAudioDevice() off the UI thread The native startAudioDevice()/stopAudioDevice() FFI calls were invoked synchronously on the main (UI) isolate. ma_device_start() blocks for tens of milliseconds while the OS restarts the device (~90ms on Android/AAudio), freezing the entire app for that window. Run the blocking native call on a background isolate via Isolate.run. A raw C function pointer address is a plain int and is sendable across isolates, so the already-resolved pointer's address is passed to the worker, the pointer rebuilt there, and the function called off the UI isolate. No native changes are needed: miniaudio serializes device start/stop internally and the returned future still completes only once the device has actually started/stopped (including the idempotent no-op). - bindings_player.dart: methods are now Future. - bindings_player_ffi.dart: run via Isolate.run using the pointer address. - bindings_player_web.dart: async wrappers over the synchronous wasm calls (web is single-threaded; the device change is instant). - soloud.dart: await the now-async binding calls. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D7UTvguQ31vuiRqcPeMBTC --- CHANGELOG.md | 2 +- lib/src/bindings/bindings_player.dart | 10 +++++-- lib/src/bindings/bindings_player_ffi.dart | 36 ++++++++++++++++++----- lib/src/bindings/bindings_player_web.dart | 8 +++-- lib/src/soloud.dart | 11 +++++-- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c070876d..6698d23f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ #### 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) +- 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 #### 4.0.12 (30 Jun 2026) - add `lowLatency` init option to allow recordable Android output #492. Thanks to @MjnMixael diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart index fe30407b..b63693f6 100644 --- a/lib/src/bindings/bindings_player.dart +++ b/lib/src/bindings/bindings_player.dart @@ -107,14 +107,20 @@ abstract class FlutterSoLoud { /// 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 - PlayerErrors stopAudioDevice(); + 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 - PlayerErrors startAudioDevice(); + Future startAudioDevice(); /// Change the playback device. /// diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index f7f1124f..0ed5b9f0 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,20 @@ 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(); +} + typedef DartVoiceEndedCallbackT = ffi.Pointer>; @@ -317,8 +332,12 @@ class FlutterSoLoudFfi extends FlutterSoLoud { .asFunction(); @override - PlayerErrors stopAudioDevice() { - final ret = _stopAudioDevice(); + 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]; } @@ -326,12 +345,15 @@ class FlutterSoLoudFfi extends FlutterSoLoud { _lookup>( 'stopAudioDevice', ); - late final _stopAudioDevice = _stopAudioDevicePtr - .asFunction(); @override - PlayerErrors startAudioDevice() { - final ret = _startAudioDevice(); + 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]; } @@ -339,8 +361,6 @@ class FlutterSoLoudFfi extends FlutterSoLoud { _lookup>( 'startAudioDevice', ); - late final _startAudioDevice = _startAudioDevicePtr - .asFunction(); @override PlayerErrors changeDevice(int deviceId) { diff --git a/lib/src/bindings/bindings_player_web.dart b/lib/src/bindings/bindings_player_web.dart index c05dabcb..2ee8f801 100644 --- a/lib/src/bindings/bindings_player_web.dart +++ b/lib/src/bindings/bindings_player_web.dart @@ -146,13 +146,17 @@ class FlutterSoLoudWeb extends FlutterSoLoud { } @override - PlayerErrors stopAudioDevice() { + 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 - PlayerErrors startAudioDevice() { + 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]; } diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart index d2382210..e4742c9f 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -496,13 +496,16 @@ interface class SoLoud { /// 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 = _controller.soLoudFFI.stopAudioDevice(); + final error = await _controller.soLoudFFI.stopAudioDevice(); _logPlayerError(error, from: 'stopAudioDevice() result'); if (error != PlayerErrors.noError) { throw SoLoudCppException.fromPlayerError(error); @@ -515,13 +518,17 @@ interface class SoLoud { /// 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 = _controller.soLoudFFI.startAudioDevice(); + final error = await _controller.soLoudFFI.startAudioDevice(); _logPlayerError(error, from: 'startAudioDevice() result'); if (error != PlayerErrors.noError) { throw SoLoudCppException.fromPlayerError(error); From b5f2301a0d84d90e3a35f051dcab761e8c7d296a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:22:23 +0000 Subject: [PATCH 7/8] Add getAudioDeviceState() to query the audio device state Expose a public `getAudioDeviceState()` that returns an `AudioDeviceState` enum reflecting the current state of the audio output device. The native implementation reads miniaudio's `ma_device_get_state(&gDevice)` and the enum values mirror `ma_device_state` (uninitialized, stopped, started, starting, stopping). Wired through the full stack: the miniaudio backend, Player, the FFI export, and the Dart bindings (FFI + web), surfaced as a synchronous `SoLoud.getAudioDeviceState()`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ESvHS7kXkJ7omwTmJanUfR --- CHANGELOG.md | 1 + lib/src/bindings/bindings_player.dart | 5 +++ lib/src/bindings/bindings_player_ffi.dart | 14 +++++++ lib/src/bindings/bindings_player_web.dart | 5 +++ lib/src/bindings/js_extension.dart | 3 ++ lib/src/enums.dart | 39 +++++++++++++++++++ lib/src/soloud.dart | 14 +++++++ src/bindings.cpp | 10 +++++ src/enums.h | 21 ++++++++++ src/ffi_gen_tmp.h | 6 ++- src/player.cpp | 9 +++++ src/player.h | 6 +++ src/soloud/include/soloud_internal.h | 5 +++ .../backend/miniaudio/soloud_miniaudio.cpp | 10 +++++ 14 files changed, 147 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6698d23f..bc17a4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - 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) #### 4.0.12 (30 Jun 2026) - add `lowLatency` init option to allow recordable Android output #492. Thanks to @MjnMixael diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart index b63693f6..cff972a6 100644 --- a/lib/src/bindings/bindings_player.dart +++ b/lib/src/bindings/bindings_player.dart @@ -122,6 +122,11 @@ abstract class FlutterSoLoud { @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. diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart index 0ed5b9f0..184a7534 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -362,6 +362,20 @@ class FlutterSoLoudFfi extends FlutterSoLoud { '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); diff --git a/lib/src/bindings/bindings_player_web.dart b/lib/src/bindings/bindings_player_web.dart index 2ee8f801..057dffcd 100644 --- a/lib/src/bindings/bindings_player_web.dart +++ b/lib/src/bindings/bindings_player_web.dart @@ -161,6 +161,11 @@ class FlutterSoLoudWeb extends FlutterSoLoud { return PlayerErrors.values[ret]; } + @override + AudioDeviceState getAudioDeviceState() { + return AudioDeviceState.fromValue(wasmGetAudioDeviceState()); + } + @override PlayerErrors changeDevice(int deviceId) { final ret = wasmChangeDevice(deviceId); diff --git a/lib/src/bindings/js_extension.dart b/lib/src/bindings/js_extension.dart index 93de716c..89778f39 100644 --- a/lib/src/bindings/js_extension.dart +++ b/lib/src/bindings/js_extension.dart @@ -103,6 +103,9 @@ 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 e4742c9f..b50bc42b 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -535,6 +535,20 @@ interface class SoLoud { } } + /// 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() { diff --git a/src/bindings.cpp b/src/bindings.cpp index dc5cd9e7..8b6f29b9 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -287,6 +287,16 @@ FFI_PLUGIN_EXPORT enum PlayerErrors startAudioDevice() { 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 82f65f69..8dc8d87a 100644 --- a/src/ffi_gen_tmp.h +++ b/src/ffi_gen_tmp.h @@ -124,4 +124,8 @@ 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(); \ No newline at end of file +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 5d85f2ce..2c956243 100644 --- a/src/player.cpp +++ b/src/player.cpp @@ -200,6 +200,7 @@ 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) { @@ -874,6 +875,14 @@ PlayerErrors Player::startAudioDevice() 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 c8709375..6768f9b8 100644 --- a/src/player.h +++ b/src/player.h @@ -234,6 +234,12 @@ class Player { /// @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 586996f0..2e4d69dc 100644 --- a/src/soloud/include/soloud_internal.h +++ b/src/soloud/include/soloud_internal.h @@ -99,6 +99,11 @@ namespace SoLoud // 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 ace1324e..18755502 100644 --- a/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp +++ b/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp @@ -407,6 +407,16 @@ namespace SoLoud 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; From bca7281643d84764472f46b9b2e786501763908e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:48:46 +0000 Subject: [PATCH 8/8] Run init()/deinit() blocking native calls off the UI thread SoLoud.init() runs the native initEngine() FFI call synchronously on the Dart main (UI) isolate. On Android the heavy AAudio device init/start blocks for seconds on low/mid-range devices, tripping the ANR watchdog (#481). Extend the off-thread pattern already used for start/stopAudioDevice(): a raw C function pointer address is a plain int and is sendable across isolates, so the resolved pointer's address (plus the primitive args) is passed into Isolate.run, the pointer rebuilt in the worker, and the native call executed off the UI isolate. All Dart state and NativeCallable registration stay on the main isolate, which is why this works where wrapping the whole init() in an isolate does not. miniaudio's AAudio/OpenSL init needs no JNI, so running the C call on a worker thread is safe on Android; init_deinit_mutex serializes it. init() is already Future, so this is not a public API break. deinit() stays synchronous (its synchronous contract is tested and used at app exit); a new non-blocking deinitAsync() is added that runs only the native teardown off-thread while keeping the isolate-bound callable disposal on the main isolate. - bindings_player.dart: initEngine -> Future; add deinitAsync(). - bindings_player_ffi.dart: initEngine/deinitAsync run via Isolate.run using the function pointer address; add worker entrypoints. - bindings_player_web.dart: async wrappers over the synchronous wasm calls. - soloud.dart: await initEngine; add deinitAsync(); factor shared deinit steps. Fixes #481. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D7UTvguQ31vuiRqcPeMBTC --- CHANGELOG.md | 2 + lib/src/bindings/bindings_player.dart | 11 ++- lib/src/bindings/bindings_player_ffi.dart | 83 ++++++++++++++++++++--- lib/src/bindings/bindings_player_web.dart | 9 ++- lib/src/soloud.dart | 37 +++++++++- 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc17a4ce..b0d5e399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ - 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 diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart index cff972a6..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, @@ -140,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 184a7534..ca44262e 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -35,6 +35,50 @@ int _invokeDeviceLifecycle(int address) { 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>; @@ -275,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]; } @@ -304,8 +359,6 @@ class FlutterSoLoudFfi extends FlutterSoLoud { ) > >('initEngine'); - late final _initEngine = _initEnginePtr - .asFunction(); @override void setAndroidAAudioAttributes(bool managed) { @@ -486,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 057dffcd..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( @@ -212,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/soloud.dart b/lib/src/soloud.dart index b50bc42b..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, @@ -560,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(); }