diff --git a/CHANGELOG.md b/CHANGELOG.md index 98458b1e..2c4d4164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ #### 4.0.13 (20 Jul 2026) +- add `monitorEngineHealth()`, a stream reporting whether the engine is actually producing audio. Every other way of asking lies when it matters: `isInitialized` stays `true` for a deadlocked engine, `play()` keeps returning valid handles, and `getAudioDeviceState()` keeps reporting `started`. This compares the device's own claim against a new lock-free rendered-frame heartbeat, so a stall is observed from outside the engine rather than reported by it. A deliberately stopped device (idle timeout, `stopAudioDevice()`, OS interruption) is reported as `idle`, never as a stall +- add `getAudioFramesRendered()`, the raw monotonic heartbeat behind `monitorEngineHealth()`. Lock-free, so unlike every other engine call it keeps answering while the engine is wedged; useful for telemetry and crash reports. Returns `-1` on web, where no heartbeat is available - fix: Waveform audio sources do not match engine sample rate #501. Thanks to @Colton127 - Android now stops the audio device when idle (no active voices) like every other platform, releasing the audioserver `AudioMix` partial wakelock #250; use `setAudioDeviceIdleTimeout()` to keep it running - add `stopAudioDevice()` / `startAudioDevice()` to control the audio output device without deinitializing the engine (loaded sounds and voice state are preserved); `stopAudioDevice()` is an idle-only no-op while an unpaused voice is active unless `force: true` is passed, while `startAudioDevice()` temporarily starts or prewarms the output and remains subject to the configured idle timeout; blocking native device operations run off the UI thread diff --git a/lib/src/audio_engine_health_tracker.dart b/lib/src/audio_engine_health_tracker.dart new file mode 100644 index 00000000..d085ad8a --- /dev/null +++ b/lib/src/audio_engine_health_tracker.dart @@ -0,0 +1,63 @@ +import 'package:flutter_soloud/src/enums.dart'; +import 'package:meta/meta.dart'; + +/// Decides an [AudioEngineHealth] from successive samples of the output +/// device's state and the rendered-frame heartbeat. +/// +/// Split out from `SoLoud.monitorEngineHealth` so the decision rules can be +/// tested without a running engine. The rules are deliberately conservative +/// about reporting [AudioEngineHealth.stalled]: on platforms that stop the +/// device aggressively while idle (iOS, where a silent stream breaks the +/// Control Center transport controls), a false stall would be worse than no +/// monitoring at all. +@internal +class AudioEngineHealthTracker { + /// Creates a tracker that reports a stall once the device has claimed to be + /// started for [stallThreshold] without rendering a single frame. + AudioEngineHealthTracker({required this.stallThreshold}); + + /// How long a started device may render nothing before it is a stall. + final Duration stallThreshold; + + int? _lastFrames; + DateTime? _lastProgressAt; + + /// Folds one sample into the tracker and returns the resulting health. + /// + /// [framesRendered] is the monotonic heartbeat, or a negative value on + /// platforms that do not provide one. + AudioEngineHealth evaluate({ + required AudioDeviceState state, + required int framesRendered, + required DateTime now, + }) { + // No heartbeat available: report the device's own claim rather than + // inventing a stall that cannot actually be observed. + if (framesRendered < 0) { + return state == AudioDeviceState.started + ? AudioEngineHealth.healthy + : AudioEngineHealth.idle; + } + + if (framesRendered != _lastFrames) { + _lastFrames = framesRendered; + _lastProgressAt = now; + } + + if (state != AudioDeviceState.started) { + // Stopped on purpose — idle timeout, explicit stop, OS interruption, or + // not initialized. Reset the clock so time spent legitimately stopped + // cannot be counted against the device once it starts again. + _lastProgressAt = now; + return AudioEngineHealth.idle; + } + + // First sample of a started device: treat it as the start of the window + // rather than as time already spent stalled. + final since = _lastProgressAt ??= now; + + return now.difference(since) >= stallThreshold + ? AudioEngineHealth.stalled + : AudioEngineHealth.healthy; + } +} diff --git a/lib/src/bindings/bindings_player.dart b/lib/src/bindings/bindings_player.dart index 2a13414b..aa6a87d5 100644 --- a/lib/src/bindings/bindings_player.dart +++ b/lib/src/bindings/bindings_player.dart @@ -147,6 +147,14 @@ abstract class FlutterSoLoud { @mustBeOverridden AudioDeviceState getAudioDeviceState(); + /// Monotonic count of audio frames the output device callback has finished + /// rendering, or `-1` where no heartbeat is available (web). + /// + /// Lock-free, so it keeps answering while every other engine call would + /// block. Survives deinit()/init(); only its change over time is meaningful. + @mustBeOverridden + int getAudioFramesRendered(); + /// 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 20117046..0590baae 100644 --- a/lib/src/bindings/bindings_player_ffi.dart +++ b/lib/src/bindings/bindings_player_ffi.dart @@ -488,6 +488,21 @@ class FlutterSoLoudFfi extends FlutterSoLoud { late final _getAudioDeviceState = _getAudioDeviceStatePtr .asFunction(); + @override + int getAudioFramesRendered() { + // A relaxed atomic load. Deliberately called directly on the UI isolate: + // it must stay answerable when the engine is wedged, which is exactly when + // dispatching to a worker would be useless. + return _getAudioFramesRendered(); + } + + late final _getAudioFramesRenderedPtr = + _lookup>( + 'getAudioFramesRendered', + ); + late final _getAudioFramesRendered = _getAudioFramesRenderedPtr + .asFunction(); + /// Test-only interruption injection through the native notification path. void debugTriggerAudioInterruption({required bool began}) { _debugTriggerAudioInterruption(began ? 1 : 0); diff --git a/lib/src/bindings/bindings_player_web.dart b/lib/src/bindings/bindings_player_web.dart index 74af5db4..96763db9 100644 --- a/lib/src/bindings/bindings_player_web.dart +++ b/lib/src/bindings/bindings_player_web.dart @@ -175,6 +175,15 @@ class FlutterSoLoudWeb extends FlutterSoLoud { return AudioDeviceState.fromValue(wasmGetAudioDeviceState()); } + @override + int getAudioFramesRendered() { + // No heartbeat on web. The wasm module is a prebuilt asset, so it has no + // such export, and the failure this detects — an audio callback wedged + // behind a stranded native lock — does not exist in the single-threaded + // AudioContext build. `-1` tells callers not to infer a stall from it. + return -1; + } + /// Test-only no-op. Browser AudioContext interruptions are not driven by /// miniaudio notifications. void debugTriggerAudioInterruption({required bool began}) {} diff --git a/lib/src/enums.dart b/lib/src/enums.dart index ad9d92c0..4bb138a7 100644 --- a/lib/src/enums.dart +++ b/lib/src/enums.dart @@ -477,3 +477,29 @@ enum BufferingType { /// Release the data in the buffer while playing it. released, } + +/// Whether the audio engine is actually producing audio, as observed from +/// outside it. Reported by `SoLoud.monitorEngineHealth`. +/// +/// This is deliberately not derived from `SoLoud.isInitialized` or from any +/// state the engine reports about itself: a deadlocked engine still says it is +/// initialized, still returns valid handles, and still reports its device as +/// started. Health is judged only by whether audio frames are still being +/// rendered. +enum AudioEngineHealth { + /// Frames are being rendered. Audio is flowing. + healthy, + + /// The output device is not running, which is the expected state when + /// nothing is playing — after the idle timeout, after `stopAudioDevice`, + /// during an OS interruption, or before `init`. Not a fault. + idle, + + /// The device reports itself as started but no frames have been rendered for + /// the configured threshold. + /// + /// The engine is wedged or the backend died without reporting it. Playback + /// will not recover on its own, and calls into the engine may block + /// indefinitely, so prefer tearing down and reinitializing over retrying. + stalled, +} diff --git a/lib/src/soloud.dart b/lib/src/soloud.dart index e7878035..3a4c19b3 100644 --- a/lib/src/soloud.dart +++ b/lib/src/soloud.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_soloud/src/audio_engine_health_tracker.dart'; import 'package:flutter_soloud/src/audio_source.dart'; import 'package:flutter_soloud/src/bindings/bindings_player.dart'; import 'package:flutter_soloud/src/bindings/native_metadata_ffi.dart' @@ -612,6 +613,78 @@ interface class SoLoud { return _controller.soLoudFFI.getAudioDeviceState(); } + /// Monotonic count of audio frames the output device has finished rendering, + /// or `-1` on web where no heartbeat is available. + /// + /// This is the engine's heartbeat. It is a lock-free read that takes no mutex + /// and touches nothing teardown can swap, so unlike every other engine call + /// it keeps answering even when the engine is wedged — which is precisely + /// when you need an answer. + /// + /// Only the change over time is meaningful. The counter survives + /// [deinit]/[init] cycles and its absolute value means nothing. Most callers + /// want [monitorEngineHealth] rather than this; use it directly for + /// telemetry, or to attach a frame count to a crash report. + int getAudioFramesRendered() { + return _controller.soLoudFFI.getAudioFramesRendered(); + } + + /// Watches whether the engine is actually producing audio, and emits + /// [AudioEngineHealth] whenever that changes. + /// + /// Every other way of asking "is the engine alive?" lies when it matters. + /// [isInitialized] stays `true` for a deadlocked engine, [play] keeps + /// returning valid handles, and [getAudioDeviceState] keeps reporting + /// [AudioDeviceState.started] — while no audio is produced and the next + /// synchronous call may block forever. This compares the device's own claim + /// against [getAudioFramesRendered], so a stall is detected from outside the + /// engine rather than reported by it. + /// + /// Emits [AudioEngineHealth.stalled] when the device claims to be started but + /// no frames have been rendered for [stallThreshold]. Treat that as + /// unrecoverable: tear down and reinitialize rather than retrying, and expect + /// calls into the engine to be able to block. + /// + /// A stopped device is [AudioEngineHealth.idle], never a stall — so the + /// automatic idle timeout, [stopAudioDevice], and OS interruptions do not + /// raise false alarms. + /// + /// Each call returns an independent single-subscription stream that starts + /// polling when listened to and stops when cancelled. Polling is two atomic + /// reads, so [interval] can be short; [stallThreshold] must exceed it. + /// + /// On web this only ever reports [AudioEngineHealth.healthy] or + /// [AudioEngineHealth.idle], since there is no heartbeat to compare against. + /// + /// ```dart + /// _healthSub = SoLoud.instance.monitorEngineHealth().listen((health) { + /// if (health == AudioEngineHealth.stalled) { + /// unawaited(_recoverEngine()); // report, then deinit + init + /// } + /// }); + /// ``` + Stream monitorEngineHealth({ + Duration interval = const Duration(seconds: 1), + Duration stallThreshold = const Duration(seconds: 3), + }) { + assert( + stallThreshold > interval, + 'stallThreshold ($stallThreshold) must be longer than interval ' + '($interval), otherwise a single missed poll reads as a stall.', + ); + + final tracker = AudioEngineHealthTracker(stallThreshold: stallThreshold); + + return Stream.periodic( + interval, + (_) => tracker.evaluate( + state: getAudioDeviceState(), + framesRendered: getAudioFramesRendered(), + now: DateTime.now(), + ), + ).distinct(); + } + /// 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 16cc4e91..acec81d8 100644 --- a/src/bindings.cpp +++ b/src/bindings.cpp @@ -376,6 +376,21 @@ FFI_PLUGIN_EXPORT enum AudioDeviceState getAudioDeviceState() { return (AudioDeviceState)SoLoud::miniaudio_getAudioDeviceState(); } +/// Monotonic count of audio frames the output device callback has finished +/// rendering. The engine's heartbeat. +/// +/// Like getAudioDeviceState() this takes no lock and touches no pointer that +/// teardown can swap, so it keeps answering while every other engine call would +/// block. That is the entire point: a wedged mixer, a stranded audio mutex or a +/// backend that died silently all show up here as a device reporting +/// [audioDeviceStarted] whose frame count has stopped advancing. +/// +/// The counter survives deinit()/init() cycles. Only its change over time is +/// meaningful; the absolute value is not. +FFI_PLUGIN_EXPORT uint64_t getAudioFramesRendered() { + return (uint64_t)SoLoud::miniaudio_getRenderedFrameCount(); +} + /// Test-only hook that sends an interruption through miniaudio's normal /// notification callback. This is intentionally absent from the public API. FFI_PLUGIN_EXPORT void debugTriggerAudioInterruption(unsigned int began) { diff --git a/src/ffi_gen_tmp.h b/src/ffi_gen_tmp.h index b9c8ad60..5da2d524 100644 --- a/src/ffi_gen_tmp.h +++ b/src/ffi_gen_tmp.h @@ -144,3 +144,8 @@ 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(); + +/// Monotonic count of audio frames the output device callback has finished +/// rendering. Lock-free, so it keeps answering while the engine is wedged. +/// Survives deinit()/init(); only its change over time is meaningful. +FFI_PLUGIN_EXPORT uint64_t getAudioFramesRendered(); diff --git a/src/soloud/include/soloud_internal.h b/src/soloud/include/soloud_internal.h index cf501bfd..2d4c7fe3 100644 --- a/src/soloud/include/soloud_internal.h +++ b/src/soloud/include/soloud_internal.h @@ -101,6 +101,10 @@ namespace SoLoud // 3 = starting, 4 = stopping). Returns 0 (uninitialized) if the device has // not been initialized. unsigned int miniaudio_getAudioDeviceState(); + // Monotonic count of frames the device data callback has finished + // delivering. Lock-free: safe to call while the engine is wedged. Survives + // deinit()/init(), so only changes over time are meaningful. + unsigned long long miniaudio_getRenderedFrameCount(); // 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 b8bc3eb1..00a4723a 100644 --- a/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp +++ b/src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp @@ -85,6 +85,19 @@ namespace SoLoud ma_context context; std::atomic gDeviceStopped{true}; + // Monotonic count of frames the device data callback has finished + // delivering to the OS. It is the engine's heartbeat: if the device claims + // to be started but this stops advancing, the audio callback is no longer + // completing -- a wedged mixer, a stranded lock, or a backend that died + // without telling anyone. + // + // Deliberately a process-global atomic rather than a member of Soloud or + // Player: reading it must never touch a mutex or a pointer that teardown + // can swap, because the whole point is to remain answerable when the engine + // is stuck. It intentionally survives deinit()/init() cycles -- only + // changes over time are meaningful, never the absolute value. + std::atomic gRenderedFrames{0}; + // Every operation that can initialize, start, stop, uninitialize, or // replace gDevice passes through this mutex. It is recursive because some // miniaudio backends can deliver notifications inline from an operation. @@ -261,6 +274,11 @@ namespace SoLoud first_call = false; SoLoud::Soloud *soloud = (SoLoud::Soloud *)pDevice->pUserData; soloud->mix((float *)pOutput, frameCount); + + // Advanced only after mix() returns, so a mixer that blocks or + // deadlocks stops the counter instead of appearing to make progress. + // This is the engine's liveness signal; see miniaudio_getRenderedFrameCount(). + gRenderedFrames.fetch_add(frameCount, std::memory_order_relaxed); } static void soloud_miniaudio_deinit(SoLoud::Soloud *aSoloud) @@ -436,6 +454,14 @@ namespace SoLoud return (unsigned int)ma_device_get_state(&gDevice); } + // Lock-free read of the heartbeat. Takes no mutex and dereferences no + // pointer that teardown can swap, so it stays answerable even when every + // other engine call would block. + unsigned long long miniaudio_getRenderedFrameCount() + { + return gRenderedFrames.load(std::memory_order_relaxed); + } + result miniaudio_init(SoLoud::Soloud *aSoloud, unsigned int aFlags, unsigned int aSamplerate, unsigned int aBuffer, unsigned int aChannels, void *pPlaybackInfos_id) { std::unique_lock operationLock(gDeviceOperationMutex); diff --git a/test/audio_engine_health_tracker_test.dart b/test/audio_engine_health_tracker_test.dart new file mode 100644 index 00000000..b8922a7c --- /dev/null +++ b/test/audio_engine_health_tracker_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter_soloud/src/audio_engine_health_tracker.dart'; +import 'package:flutter_soloud/src/enums.dart'; +import 'package:test/test.dart'; + +void main() { + const threshold = Duration(seconds: 3); + final t0 = DateTime.utc(2026); + + AudioEngineHealthTracker newTracker() => + AudioEngineHealthTracker(stallThreshold: threshold); + + group('AudioEngineHealthTracker', () { + test('a started device rendering frames is healthy', () { + final tracker = newTracker(); + var frames = 1000; + + for (var i = 0; i < 10; i++) { + final health = tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: frames += 512, + now: t0.add(Duration(seconds: i)), + ); + expect(health, AudioEngineHealth.healthy); + } + }); + + test('a started device that stops rendering stalls after the threshold', + () { + final tracker = newTracker(); + + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: 1000, + now: t0, + ), + AudioEngineHealth.healthy, + ); + // Frozen counter, still short of the threshold. + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: 1000, + now: t0.add(const Duration(milliseconds: 2999)), + ), + AudioEngineHealth.healthy, + ); + + // Threshold reached. + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: 1000, + now: t0.add(threshold), + ), + AudioEngineHealth.stalled, + ); + }); + + test('recovers to healthy once frames advance again', () { + final tracker = newTracker() + ..evaluate( + state: AudioDeviceState.started, + framesRendered: 1000, + now: t0, + ); + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: 1000, + now: t0.add(const Duration(seconds: 5)), + ), + AudioEngineHealth.stalled, + ); + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: 1512, + now: t0.add(const Duration(seconds: 6)), + ), + AudioEngineHealth.healthy, + ); + }); + + test('a stopped device is idle, never stalled, however long it stays so', + () { + final tracker = newTracker(); + + for (var i = 0; i < 60; i++) { + expect( + tracker.evaluate( + state: AudioDeviceState.stopped, + framesRendered: 1000, // frozen: nothing is rendering + now: t0.add(Duration(seconds: i)), + ), + AudioEngineHealth.idle, + reason: 'a deliberately stopped device must never read as a stall', + ); + } + }); + + test('transitional and uninitialized states are idle', () { + for (final state in [ + AudioDeviceState.starting, + AudioDeviceState.stopping, + AudioDeviceState.uninitialized, + ]) { + expect( + newTracker().evaluate( + state: state, + framesRendered: 1000, + now: t0, + ), + AudioEngineHealth.idle, + reason: '$state should not be reported as a fault', + ); + } + }); + + test('a long idle period is not charged against the device on restart', () { + final tracker = newTracker(); + + // Device stopped for a minute (the iOS aggressive idle-stop pattern: + // a silent stream breaks the Control Center transport, so the device is + // stopped as soon as playback ends). + for (var i = 0; i < 60; i++) { + tracker.evaluate( + state: AudioDeviceState.stopped, + framesRendered: 1000, + now: t0.add(Duration(seconds: i)), + ); + } + + // It starts again. The very first sample after a minute of idle must not + // read as a stall just because the clock moved on while it was stopped. + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: 1000, + now: t0.add(const Duration(seconds: 60)), + ), + AudioEngineHealth.healthy, + ); + }); + + test('first sample of a started device starts the window, not a stall', () { + // A tracker created long after the engine began running must not report + // a stall on its very first observation. + expect( + newTracker().evaluate( + state: AudioDeviceState.started, + framesRendered: 999999, + now: t0.add(const Duration(hours: 5)), + ), + AudioEngineHealth.healthy, + ); + }); + + test('no heartbeat available (web) never reports a stall', () { + final tracker = newTracker(); + + // Started with a frozen, negative counter for well past the threshold. + for (var i = 0; i < 30; i++) { + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: -1, + now: t0.add(Duration(seconds: i)), + ), + AudioEngineHealth.healthy, + ); + } + expect( + tracker.evaluate( + state: AudioDeviceState.stopped, + framesRendered: -1, + now: t0.add(const Duration(seconds: 31)), + ), + AudioEngineHealth.idle, + ); + }); + + test('a counter wrapping or resetting across deinit/init is not a stall', + () { + final tracker = newTracker() + ..evaluate( + state: AudioDeviceState.started, + framesRendered: 5000, + now: t0, + ); + // Any change counts as progress, including a decrease. + expect( + tracker.evaluate( + state: AudioDeviceState.started, + framesRendered: 12, + now: t0.add(const Duration(seconds: 4)), + ), + AudioEngineHealth.healthy, + ); + }); + }); +}