Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 20 additions & 3 deletions src/soloud/include/soloud.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<void (*)(unsigned int*)> _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<void (*)()> _voiceInactiveCallback{nullptr};
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions src/soloud/src/core/soloud.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2332,11 +2332,60 @@ namespace SoLoud
void Soloud::unlockAudioMutex_internal()
{
SOLOUD_ASSERT(mInsideAudioThreadMutex);

if (mEndedVoiceCount != 0)
{
unlockAudioMutexAndDispatchEndedVoices_internal();
return;
}

mInsideAudioThreadMutex = false;
if (mAudioThreadMutex)
{
Thread::unlockMutex(mAudioThreadMutex);
}
}

// 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);
}
}

};
12 changes: 9 additions & 3 deletions src/soloud/src/core/soloud_core_voiceops.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading