Configurable audio-device idle timeout and off-UI-thread lifecycle#508
Configurable audio-device idle timeout and off-UI-thread lifecycle#508Colton127 wants to merge 2 commits into
Conversation
Generalize the fixed ~500 ms deferred idle-pause into a configurable output-device lifecycle coordinator and move every blocking backend call off the UI thread. Behavior change: - The default 500 ms idle timeout now applies to Android too. Previously the Android device was kept running while idle (grouped with Web), holding the audioserver AudioMix partial wakelock indefinitely. It now stops after the idle timeout like the other native platforms. The old keep-alive behavior is available opt-in via setAudioDeviceIdleTimeout(null). Web still never idle-stops (avoids the stale-buffer glitch, alnitak#446); the deferred/coalesced timeout keeps rapid stop->play from cycling the device on Android. New API: - setAudioDeviceIdleTimeout(Duration?): null keeps the device alive indefinitely (device-level replacement for a silent keep-alive sound), Duration.zero stops as soon as idle, positive keeps it running that long after going idle. Default 500 ms. Persists across deinit()/init(). - startAudioDevice() / stopAudioDevice(force): explicit prewarm and stop-without-mutating-voices (force stops during active playback). - getAudioDeviceState(): cheap synchronous read of the miniaudio device state (AudioDeviceState enum). Threading / lifecycle: - Run init/deinit, changeDevice, and device start/stop off the UI isolate (worker isolates on FFI); fixes the startup ANR (alnitak#481). deinitAsync() added; deinit() kept for synchronous contexts. - Single persistent scheduler thread handles deferred idle-stop and asynchronous resume via generation-based request coalescing so no ma_device_start()/stop() ever blocks the caller. - Serialize all real device operations behind one mutex; make mInited atomic; make init all-or-nothing (roll back the backend on any failure). - Order native callback teardown before Dart closes its NativeCallables to avoid use-after-free; preserve request order across init/dispose worker races with an atomic shutdown flag + lifecycle generation. Interruption handling: - Route OS interruptions through a dedicated interruption callback that stops the device without pausing/mutating voices; on interruption-end restart only when active playback requires it or keep-alive is configured. iOS reactivates AVAudioSession before restarting the unit. Correctness: - play/play3d/textToSpeech/busPlayOnEngine create the voice paused, then validate the handle (isValidVoiceHandle) before registering it and requesting device startup, fixing the old newHandle != 0 check that could register an error code as a handle. changeDevice now returns the real result and supports the default device (-1). Tests: add audio_device_idle_timeout and audio_device_lifecycle_races covering timeout policy, prewarm, interruption recovery, and scheduler race/coalescing behavior.
|
Some notes:
re: #504 (comment)
Yes, keeping the audio device alive while in foreground is ideal for games/apps that need low latency playback. This could be achieved via: AppLifecycleListener(
onStateChange: (state) {
if (state == AppLifecycleState.resumed) {
///Starts the audio device, and keeps it running even while no voices are playing.
SoLoud.instance.setAudioDeviceIdleTimeout(null);
} else if (state == AppLifecycleState.paused) {
///Restores original idle timeout
SoLoud.instance.setAudioDeviceIdleTimeout(const Duration(milliseconds: 500));
}
},
);
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
///Apply immediately if in foreground
SoLoud.instance.setAudioDeviceIdleTimeout(null);
}
Some use cases do need an idle timeout even in foreground, like #126. This PR addresses the needs of everyone. The 500ms idle pause being applied to Android is the only breaking change here. The original behavior can be restored via |
Description
Generalizes the fixed ~500 ms deferred idle-pause into a configurable output-device lifecycle coordinator, and moves every blocking backend call off the UI thread.
Behavior change
device was kept running while idle (grouped with Web), holding the audioserver
AudioMixpartial wakelock indefinitely. It now stops after the idle timeout like theother native platforms. The old keep-alive behavior is available opt-in via
setAudioDeviceIdleTimeout(null). Web still never idle-stops (avoids the stale-bufferglitch, fix: small sound bleed after stop and play #446); the deferred/coalesced timeout keeps rapid stop→play from cycling the
device on Android.
New API
setAudioDeviceIdleTimeout(Duration?)—nullkeeps the device alive indefinitely,Duration.zerostops as soonas idle, a positive value keeps it running that long after going idle. Default 500 ms.
Persists across
deinit()/init().startAudioDevice()/stopAudioDevice({force})— explicit prewarm, and stop withoutmutating voices (
forcestops during active playback).getAudioDeviceState()— cheap synchronous read of the miniaudio device state(
AudioDeviceState).Threading / lifecycle
init/deinit,changeDevice, and device start/stop off the UI isolate; fixes thestartup ANR (fix: ANR on init #481).
deinitAsync()added;deinit()kept for synchronous contexts.generation-based request coalescing, so no
ma_device_start()/stop()ever blocks the caller.mInitedmade atomic;initisnow all-or-nothing (backend rolled back on any failure).
NativeCallables (avoidsuse-after-free); init/dispose worker-isolate races closed with an atomic shutdown flag.
Interruption handling
pausing/mutating voices; on interruption-end the device restarts only when active playback
requires it or keep-alive is configured. iOS reactivates
AVAudioSessionbefore restart.Correctness
play/play3d/textToSpeech/busPlayOnEnginecreate the voice paused, then validate thehandle (
isValidVoiceHandle) before registering it and requesting startup — fixes the oldnewHandle != 0check that could register an error code as a handle.changeDevicenowreturns the real result and supports the default device (
-1).Tests
Adds
audio_device_idle_timeoutandaudio_device_lifecycle_racescovering timeout policy,prewarm, interruption recovery, and scheduler race/coalescing behavior. Passing on all my
devices.
Type of Change