Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
63 changes: 63 additions & 0 deletions lib/src/audio_engine_health_tracker.dart
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 8 additions & 0 deletions lib/src/bindings/bindings_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions lib/src/bindings/bindings_player_ffi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,21 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
late final _getAudioDeviceState = _getAudioDeviceStatePtr
.asFunction<int Function()>();

@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<ffi.NativeFunction<ffi.Uint64 Function()>>(
'getAudioFramesRendered',
);
late final _getAudioFramesRendered = _getAudioFramesRenderedPtr
.asFunction<int Function()>();

/// Test-only interruption injection through the native notification path.
void debugTriggerAudioInterruption({required bool began}) {
_debugTriggerAudioInterruption(began ? 1 : 0);
Expand Down
9 changes: 9 additions & 0 deletions lib/src/bindings/bindings_player_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}) {}
Expand Down
26 changes: 26 additions & 0 deletions lib/src/enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
73 changes: 73 additions & 0 deletions lib/src/soloud.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<AudioEngineHealth> 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<AudioEngineHealth>.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<PlaybackDevice> listPlaybackDevices() {
Expand Down
15 changes: 15 additions & 0 deletions src/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/ffi_gen_tmp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
4 changes: 4 additions & 0 deletions src/soloud/include/soloud_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions src/soloud/src/backend/miniaudio/soloud_miniaudio.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ namespace SoLoud
ma_context context;
std::atomic<bool> 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<unsigned long long> 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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<std::recursive_mutex> operationLock(gDeviceOperationMutex);
Expand Down
Loading
Loading