Propagate playback and voice errors - #527
Conversation
Soloud::play() returned UNKNOWN_ERROR when findFreeVoice_internal() could
not give it a voice. Handles are encoded as
(voice + 1) | (playIndex << 12)
so UNKNOWN_ERROR's numeric value, 7, is itself a legal handle: voice slot
6 with play index 0. mPlayIndex wraps at 0xfffff, so once it comes back
around while slots 0..5 are busy, a live voice really does own handle 7
and a failure becomes indistinguishable from it - isValidVoiceHandle()
included, since it resolves the value like any other handle.
Callers that did not re-validate then operated on somebody else's voice:
playClocked() and playScheduled() applied a delay and unpaused it, and
play3d()/play3dClocked() resolved it, flagged it PROCESS_3D and pushed 3D
parameters into it.
play() now returns 0. That value can never encode a voice (the low 12
bits hold voice + 1, which is at least 1) and is already the failure
value used by getHandleFromVoice_internal() and every Bus::play*, so the
whole family now shares one sentinel. The four compound helpers bail out
on it before touching anything.
findFreeVoice_internal() also fell through to stopVoice_internal(-1) when
every voice was protected, converting -1 to UINT_MAX and indexing mVoice[]
out of bounds. It returns -1 now, which play() already handles.
test/voice_allocation_failure_test.cpp reproduces the aliasing state by
wrapping the play index, then pins down that a failed allocation never
returns a live voice's handle and never disturbs it. Against the previous
engine it aborts on the out-of-bounds stopVoice_internal(-1); with this
change all 31 assertions pass. Run it with
./test/run_voice_allocation_failure_test.sh - it uses the null backend, so
it needs no audio device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6jLgMnUaqZimhHk2PiWUW
Several native paths reported success when nothing was playing, so Dart could get back an unusable SoundHandle - or never learn that an operation had failed at all. Device startup. Seven playback paths called soloud.resume() and threw the result away, continuing with no usable output. Player::ensureAudioDevice- Started() is now the single place that inspects it, and every path that will actually produce audio goes through it. A voice requested paused is exempt: it makes no sound, so requiring the device would stop callers creating and configuring one during an interruption, and the setPause() that later unpauses it does the check itself. NOT_IMPLEMENTED is treated as success - it means the backend has no pause/resume concept, so there is nothing to start. Voice creation. The `newHandle != 0` checks are replaced by an explicit test against the invalid-handle sentinel, and no path registers a value the engine did not give it. Looping playback creates the voice paused to set the loop region up; if the final setPause() fails, the voice is stopped, its bookkeeping removed and the failure returned rather than a half-initialised handle. pauseSwitch(), setPause() and stop() return PlayerErrors now, so invalid handles and device failures reach Dart instead of vanishing. A failed unpause leaves the voice paused and its isUserPaused flag untouched. stop() still schedules the deferred engine pause even for a handle that has already gone, matching the previous unconditional behaviour. textToSpeech() no longer builds an ActiveSound - nor, on failure, the stray empty entry it used to append - before the voice exists. busPlayOnEngine() reports errors, starts the device when not starting paused, and never stores or configures an invalid bus voice. Its C export zeroes the out-parameter before its early return: the web binding passes _malloc'd memory, which is not zeroed, so Dart could otherwise read uninitialised heap as a handle. SoLoud results are no longer cast to PlayerErrors. The two enums agree only up to fileLoadFailed (3) and then diverge, because PlayerErrors inserts fileAlreadyLoaded at 4, so the cast shifted everything above it - loadFile()/loadMem() reported a genuine OUT_OF_MEMORY as dllNotFound, telling the user "The DLL was not found" when a large asset would not fit in memory. fromSoLoudError() maps them explicitly. On iOS a failed AVAudioSession activation is reported instead of starting the Audio Unit against an inactive session. Two new errors, appended so nothing is renumbered: audioDeviceFailedToStart (34) and failedToStartPlayback (35), with matching Dart exceptions. The signature changes are carried through the C exports, ffi_gen_tmp.h, the abstract binding, the FFI binding (regenerated with ffigen), the Web binding and js_extension, and the committed WASM artifact is rebuilt to match. Bus.playOnEngine() keeps returning a SoundHandle and throws on failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6jLgMnUaqZimhHk2PiWUW
The playback methods stop swallowing failures: anything other than noError is thrown, and a handle is only registered against its AudioSource once the engine has confirmed a valid voice. maxActiveVoiceCountReached stays non-blocking as before - it is logged, not thrown, and the caller still gets the same zeroed handle back - but that handle is no longer added to handlesInternal, which is what made it look like a playing sound. pauseSwitch() and setPause() keep their signatures and now throw. stop() does too, while staying idempotent: a voice that ends between Dart's validity check and the native call reports soundHandleNotFound, which is treated as success, and its voiceEndedCompleters entry is not leaked when an exception is thrown. A failed loadFile()/loadMem() no longer also raises an error nothing can catch. The file-loaded listener threw after already delivering the same exception through the completer, and an exception thrown from a stream listener bypasses the subscription's onError and escapes to the zone. The load result future is also built and marked handled before the load is awaited, because the native callback can complete it with an error while nothing is listening yet, which Dart reports as an unhandled async error. The future the caller awaits is the only channel now. Tests. test/player_errors_test.dart checks the Dart enum against src/enums.h so the two cannot drift, asserts that SOLOUD_ERRORS and PlayerErrors do diverge past fileLoadFailed - documenting why the conversion must map rather than cast - and covers the exception mapping, including that maxActiveVoiceCountReached has none. A PlayerErrorPropa- gation entry is added to the example suite for the paths that need a real audio device. Documentation on every affected method now names the exceptions it can throw, and the CHANGELOG records the behaviour changes under 4.1.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6jLgMnUaqZimhHk2PiWUW
|
Hey @Colton127, thanks for the thorough work here — the fixes themselves look great! One request: could you shrink the changelog entry to 2–3 lines? Changelogs are read by end users of the plugin, and an entry this long won't be read — it also buries the part they actually need (e.g. that pauseSwitch/setPause/stop can now throw).
Feel free to add/modify this. |
|
Maybe also:
|
Done. |
Description
Several native paths report success when nothing is playing, so Dart gets back an unusable
SoundHandle— or never learns the operation failed at all. This makes those failures observable.Three commits, each self-contained and independently buildable:
1.
soloud: make a failed voice allocation unambiguousSoloud::play()returnedUNKNOWN_ERRORwhen no voice could be allocated. Handles are encoded as(voice + 1) | (playIndex << 12), soUNKNOWN_ERROR's value — 7 — is itself a legal handle: voice slot 6 with play index 0.mPlayIndexwraps at0xfffff, so once it comes back around while slots 0–5 are busy, a live voice really does own handle 7 and a failure becomes indistinguishable from it —isValidVoiceHandle()included, since it resolves the value like any other handle.Callers that didn't re-validate then operated on someone else's voice:
playClocked()/playScheduled()applied a delay and unpaused it;play3d()/play3dClocked()flagged itPROCESS_3Dand pushed 3D parameters into it.play()now returns0, which can never encode a voice (the low 12 bits holdvoice + 1) and is already the failure value used bygetHandleFromVoice_internal()and everyBus::play*. The four compound helpers bail out on it.Separately,
findFreeVoice_internal()fell through tostopVoice_internal(-1)when every voice was protected, converting-1toUINT_MAXand indexingmVoice[]out of bounds. It returns-1now, whichplay()already handles.test/voice_allocation_failure_test.cppreproduces the aliasing state by wrapping the play index. Against the current engine it aborts on the out-of-boundsstopVoice_internal(-1); with the fix all 31 assertions pass. It uses the null backend, so./test/run_voice_allocation_failure_test.shneeds no audio device.2. Return real errors from the player
soloud.resume()and discarded the result, continuing with no usable output.Player::ensureAudioDeviceStarted()is now the single place that inspects it. A voice requested paused is exempt — it makes no sound, so requiring the device would stop callers creating and configuring one during an interruption, and the latersetPause()does the check when it unpauses.NOT_IMPLEMENTEDcounts as success (the backend simply has no pause/resume concept).newHandle != 0checks replaced by an explicit sentinel test; no path registers a value the engine didn't give it. Looping playback rolls back cleanly if the finalsetPause()fails, rather than handing back a half-initialised handle.pauseSwitch(),setPause()andstop()returnPlayerErrors. A failed unpause leaves the voice paused.textToSpeech()no longer creates bookkeeping — nor the stray emptysoundsentry it appended on failure — before the voice exists.busPlayOnEngine()reports errors and never stores an invalid bus voice.PlayerErrors. The enums agree only tofileLoadFailed(3), then diverge becausePlayerErrorsinsertsfileAlreadyLoadedat 4.loadFile()/loadMem()reported a genuineOUT_OF_MEMORYasdllNotFound— telling users "The DLL was not found" when a large asset wouldn't fit in memory.AVAudioSessionactivation is reported instead of starting the Audio Unit against an inactive session.New errors appended so nothing is renumbered:
audioDeviceFailedToStart(34),failedToStartPlayback(35).3. Surface them in the public API
Playback methods throw on failure and only register a handle after a confirmed valid voice. A failed
loadFile()/loadMem()no longer also raises an error nothing can catch — the listener threw after already delivering the same exception through the completer (a throw from a stream listener bypassesonErrorand escapes to the zone), and the result future was completed with an error before anything listened to it.Compatibility notes
pauseSwitch(),setPause(),stop()andBus.playOnEngine()keep their signatures but can now throw.stop()stays idempotent — a voice ending between Dart's validity check and the native call is treated as success.maxActiveVoiceCountReachedstays non-blocking: still logged, not thrown, same zeroed handle returned. The only change is that the handle is no longer added tohandlesInternal, which is what made it look like a playing sound.Testing
dart format·dart analyze --fatal-infos --fatal-warnings lib/·flutter analyze·flutter test(12) · native suite (31) ·flutter pub publish --dry-run(0 warnings) — all green, and each of the three commits builds and analyzes on its own.Behaviour verified at runtime on Linux against the rebuilt plugin: play/valid-handle/registration, pause round-trip, stop + idempotency + invalid-handle throws, looping and paused-looping,
paused: truecreation, clocked, scheduled, 3D, bus, TTS, non-blocking max-voice, and load error mapping. Example builds for Linux and Web (--wasm).Type of Change