diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc6f142..d705466a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ #### 4.0.13 (20 Jul 2026) +- fix: the voice-ended callback is no longer invoked while SoLoud's audio mutex is held. It ran there via `stopVoice_internal()` and reached back into `Player` state, which deadlocked against `disposeSound()` (which holds `sounds_mutex` across `soloud.stop()`). The symptom was a wedged engine: handles and sources still looked valid, no audio was produced, and `deinit()` never completed. Ended voices are now queued and dispatched once the mutex is released - Android: the plugin now does no native work at app startup. Plugin registration and `onAttachedToEngine` are pure Java bookkeeping; the native library is loaded lazily, only when an engine-lifecycle hook actually has to call into it. Previously a static initializer pulled the whole library onto the main thread during app launch even for apps that never played a sound, and a load failure there crashed plugin registration - 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 diff --git a/src/soloud/include/soloud.h b/src/soloud/include/soloud.h index 33c413bd..9c9cc084 100644 --- a/src/soloud/include/soloud.h +++ b/src/soloud/include/soloud.h @@ -171,12 +171,25 @@ namespace SoLoud soloudResultFunction mBackendPauseFunc; soloudResultFunction mBackendResumeFunc; - // Set the callback to call when a voice is ended/stopped - void (*_voiceEndedCallback)(unsigned int*) = nullptr; + // Set the callback to call when a voice is ended/stopped. + // + // stopVoice_internal() runs with the audio mutex held, so it must not + // call out to the embedder directly: an embedder callback that crashes, + // stalls, or blocks (for example a Dart NativeCallable whose isolate has + // gone away) would strand the audio mutex and wedge every later SoLoud + // call, including deinit(). Ended voices are queued instead and + // dispatched by unlockAudioMutex_internal() once the mutex is released. + std::atomic _voiceEndedCallback{nullptr}; void setVoiceEndedCallback(void (*voiceEndedCallback)(unsigned int*)) { - _voiceEndedCallback = voiceEndedCallback; + _voiceEndedCallback.store(voiceEndedCallback, + std::memory_order_release); } + // Handles of voices that ended while the audio mutex was held, pending + // dispatch. Both members are only touched with the audio mutex held. + unsigned int mEndedVoiceQueue[VOICE_COUNT]; + unsigned int mEndedVoiceCount = 0; + // Called after a mix cycle in which a voice stopped or became paused. // The callback runs after the audio mutex has been released. std::atomic _voiceInactiveCallback{nullptr}; @@ -545,6 +558,10 @@ namespace SoLoud void lockAudioMutex_internal(); // Unlock audio thread mutex. void unlockAudioMutex_internal(); + // Slow path of unlockAudioMutex_internal(): drains mEndedVoiceQueue. + // Kept out of line so the common (empty queue) path does not carry the + // snapshot buffer in its stack frame. + void unlockAudioMutexAndDispatchEndedVoices_internal(); // Max. number of active voices. Busses and tickable inaudibles also count against this. unsigned int mMaxActiveVoices; diff --git a/src/soloud/src/core/soloud.cpp b/src/soloud/src/core/soloud.cpp index 7c206b4d..22ce9762 100644 --- a/src/soloud/src/core/soloud.cpp +++ b/src/soloud/src/core/soloud.cpp @@ -2332,6 +2332,13 @@ namespace SoLoud void Soloud::unlockAudioMutex_internal() { SOLOUD_ASSERT(mInsideAudioThreadMutex); + + if (mEndedVoiceCount != 0) + { + unlockAudioMutexAndDispatchEndedVoices_internal(); + return; + } + mInsideAudioThreadMutex = false; if (mAudioThreadMutex) { @@ -2339,4 +2346,46 @@ namespace SoLoud } } + // Release the audio mutex, then notify the embedder about voices that ended + // while it was held. stopVoice_internal() cannot call out directly because it + // runs under the mutex, and an embedder callback that stalls or crashes there + // would strand the mutex and wedge every later SoLoud call, teardown included. + // + // The pending handles are copied out and the queue cleared *before* the + // unlock, so another thread that acquires the mutex and queues more work + // cannot corrupt this dispatch or have its own work consumed here. + void Soloud::unlockAudioMutexAndDispatchEndedVoices_internal() + { + SOLOUD_ASSERT(mInsideAudioThreadMutex); + + unsigned int endedVoices[VOICE_COUNT]; + unsigned int endedVoiceCount = mEndedVoiceCount; + unsigned int i; + + if (endedVoiceCount > VOICE_COUNT) + endedVoiceCount = VOICE_COUNT; + for (i = 0; i < endedVoiceCount; i++) + endedVoices[i] = mEndedVoiceQueue[i]; + mEndedVoiceCount = 0; + + mInsideAudioThreadMutex = false; + if (mAudioThreadMutex) + { + Thread::unlockMutex(mAudioThreadMutex); + } + + // Read after unlocking so a concurrent setVoiceEndedCallback(nullptr) + // during teardown is honoured as early as possible. + auto voiceEndedCallback = + _voiceEndedCallback.load(std::memory_order_acquire); + if (voiceEndedCallback == nullptr) + return; + + for (i = 0; i < endedVoiceCount; i++) + { + unsigned int handle = endedVoices[i]; + voiceEndedCallback(&handle); + } + } + }; diff --git a/src/soloud/src/core/soloud_core_voiceops.cpp b/src/soloud/src/core/soloud_core_voiceops.cpp index d850a686..42591fea 100644 --- a/src/soloud/src/core/soloud_core_voiceops.cpp +++ b/src/soloud/src/core/soloud_core_voiceops.cpp @@ -134,9 +134,15 @@ namespace SoLoud { if (mResampleDataOwner[i] == v) { - if (_voiceEndedCallback != nullptr) { - unsigned int handle = (aVoice + 1) | (mResampleDataOwner[i]->mPlayIndex << 12); - _voiceEndedCallback(&handle); + // Queue rather than call: the audio mutex is held here (see + // the assert above) and the embedder callback must not run + // under it. unlockAudioMutex_internal() dispatches these. + // mEndedVoiceQueue holds VOICE_COUNT entries and a voice can + // only be queued once per stop, so it cannot overflow. + if (mEndedVoiceCount < VOICE_COUNT) + { + mEndedVoiceQueue[mEndedVoiceCount++] = + (aVoice + 1) | (mResampleDataOwner[i]->mPlayIndex << 12); } mResampleDataOwner[i] = NULL; }