Skip to content

Configurable audio-device idle timeout and off-UI-thread lifecycle#508

Draft
Colton127 wants to merge 2 commits into
alnitak:mainfrom
Colton127:lifecycle-revamp
Draft

Configurable audio-device idle timeout and off-UI-thread lifecycle#508
Colton127 wants to merge 2 commits into
alnitak:mainfrom
Colton127:lifecycle-revamp

Conversation

@Colton127

Copy link
Copy Markdown
Contributor

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

  • 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, 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?)null keeps the device alive indefinitely, Duration.zero stops as soon
    as 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 without
    mutating voices (force stops during active playback).
  • getAudioDeviceState() — cheap synchronous read of the miniaudio device state
    (AudioDeviceState).

Threading / lifecycle

  • Run init/deinit, changeDevice, and device start/stop off the UI isolate; fixes the
    startup ANR (fix: ANR on init #481). deinitAsync() added; deinit() kept for synchronous contexts.
  • Single persistent scheduler thread handles deferred idle-stop and async resume via
    generation-based request coalescing, so no ma_device_start()/stop() ever blocks the caller.
  • All real device operations serialized behind one mutex; mInited made atomic; init is
    now all-or-nothing (backend rolled back on any failure).
  • Native callback teardown ordered before Dart closes its NativeCallables (avoids
    use-after-free); init/dispose worker-isolate races closed with an atomic shutdown flag.

Interruption handling

  • OS interruptions routed through a dedicated callback that stops the device without
    pausing/mutating voices; on interruption-end the device restarts only when active playback
    requires it or keep-alive is configured. iOS reactivates AVAudioSession before restart.

Correctness

  • play/play3d/textToSpeech/busPlayOnEngine create the voice paused, then validate the
    handle (isValidVoiceHandle) before registering it and requesting startup — fixes 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

Adds audio_device_idle_timeout and audio_device_lifecycle_races covering timeout policy,
prewarm, interruption recovery, and scheduler race/coalescing behavior. Passing on all my
devices.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

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.
@Colton127

Copy link
Copy Markdown
Contributor Author

@alnitak

Some notes:

  • I couldn't replicate fix: AudioSources seemingly become invalid after leaving standby (on Android) #126, but I'm almost certain the issue is the audio device being terminated while soloud still sees it as active. This PR allows forcing a restart on the audio device via stopAudioDevice(force: true) followed by resumeAudioDevice().

  • I intentionally commented out the speechText test in all_tests.dart because it was crashing on my android devices, even on the current main branch. Still needs to be addressed in a separate issue/PR.

re: #504 (comment)

The "Pause the audio device after 500ms of inactivity" is a workaround I did without noticing the delay when starting the device again. So it's worth considering pausing the audio device after 500ms of inactivity only when the app is in the background?

But I don't think the app lifecycle can be monitored from the plugin (in Dart), and maybe this should be made using method channels. Of course, your setAudioDeviceKeepAlive is still a valid functionality.

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 SoLoud.instance.setAudioDeviceIdleTimeout(null);. The pause was always applied on iOS. This ensures consistent behavior on all platforms. I didn't touch web because #446.

@Colton127
Colton127 marked this pull request as draft July 25, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant