Repository navigation
fix: rtAudioMixer hardening, round 2 (mixer core, filters, streams, capture, VST3, platform) - #612
Conversation
Soloud::getHardwareOutputLatency() called miniaudio_getHardwareOutputLatency() unconditionally. The core's other calls into the miniaudio backend are under WITH_MINIAUDIO, so a build with another backend does not compile soloud_miniaudio.cpp, and since getHardwareOutputLatency() it failed to link: undefined symbol SoLoud::miniaudio_getHardwareOutputLatency(). That is every host test built with WITH_NULL, such as run_render_ring_test.sh, run_checkpoint_test.sh and run_retroactive_remix_test.sh. Without miniaudio it now returns 0, as the backend does when it has no latency to report. The plugin builds with WITH_MINIAUDIO, so its behaviour does not change.
The CoreAudio branch of miniaudio_getHardwareOutputLatency() was under MA_SUPPORT_COREAUDIO, which only says that the platform has CoreAudio. The CoreAudio types it uses (Float64, UInt32, AudioUnit, OSStatus) and miniaudio's function-pointer typedefs for it come with MA_HAS_COREAUDIO, which a build with MA_NO_COREAUDIO, or one that enables only some backends without CoreAudio (MA_ENABLE_ONLY_SPECIFIC_BACKENDS with only MA_ENABLE_NULL, say), does not define. Such a build failed with 15 errors, unknown type name 'Float64' and so on. The branch is now under MA_HAS_COREAUDIO, as the WASAPI branch below it is under MA_HAS_WASAPI. A build with the CoreAudio backend compiles the same code as before.
The render worker and the device callback (when the worker falls behind) both append whole engine quanta to the render-ahead ring. Each checked for room before taking the audio mutex and not again under it. While the worker waited for the mutex, the device could mix a quantum and play a period; the worker then mixed a second quantum into the room that was left. RenderRing::write() clamps to the free space, so the end of that quantum was dropped: an audible skip, and a mix clock that stays ahead of the ring by the dropped frames for the rest of the session. The playhead, the ring positions checkpoints record (where a retroactive re-mix writes) and the global filters' clock are all off by as much. The ring holds renderAhead + quantum + 4 device periods, so a quantum larger than renderAhead + 3 periods is exposed: the default 2048-frame buffer with 256-frame periods and a 512-frame render-ahead, for one. Both top-up loops now check again once they hold the mutex. Every writer holds it, so the answer stands until the quantum is written. renderRingMixQuantumLocked_internal() also refuses to mix a quantum the ring has no room for. Test: testWritersRecheckRoomUnderTheMutex in test/render_ring_test.cpp holds the mutex while the worker waits for it, mixes the device's quantum and plays a period, then lets the worker go. Before the fix the ring held 3840 frames instead of 2048, the clock ran 256 frames ahead of it and the played sawtooth jumped (5 failures). It passes plain, under TSan and under ASan+UBSan; run_render_ring_test.sh now takes --tsan and --asan.
With the render-ahead ring the device callback only copies frames out, unless the render worker fell behind: then it mixes the quanta it needs itself (renderRingTopUpNoWait_internal()). Its unlock dispatched the voices those quanta ended, and it called the voice-inactive callback, on the device thread. flutter_soloud's callbacks take the player's locks (removeHandle() waits for sounds_mutex, the idle evaluation for the interruption lock), and the dispatch takes the audio mutex again with a blocking lock afterwards. A device read that should copy a few frames waited for whatever held those locks: up to 61 ms against a 5.8 ms period while plays and loads held the sounds lock. The device's top-up now unlocks without dispatching. It leaves the ended-voice queue to the render worker, keeps the voice-inactive callback due under the mutex and wakes the worker, which delivers both (renderRingDeliverDeferred_internal()), as it already does for the quanta it mixes itself. Another thread's unlock still dispatches the queue if it gets there first; each handle is delivered once. Test: testDeviceTopUpLeavesCallbacksToTheWorker in test/render_ring_test.cpp ends a voice in a quantum the device mixes, with the worker running and no API call in between. It requires both callbacks off the device thread and delivered by the worker, the ended one exactly once. Before the fix both ran on the device thread (2 failures). It passes plain, under TSan and under ASan+UBSan.
getOutputLatency() is called from Dart without the audio mutex, so neither ring counter is its own. It loaded the write position, then the read position. When the render worker appended and the device read past the loaded write position in between, it subtracted a newer read position from an older write position, and the unsigned depth wrapped to about 2^32 frames: 97392 s, 27 hours, from a ring that holds 30 ms. With the device reading right behind the write head that takes milliseconds, not luck. RenderRing::availableToReadForObserver() loads the write position again after the read position and retries until it did not move, so the depth it returns is one the ring had. getOutputLatency() uses it; the mixer and the device callback keep using availableToRead() on their own counters. Test: testOutputLatencyStaysWithinTheRing in test/render_ring_test.cpp queries the latency from 4 threads for 400 ms while the worker mixes and the device reads as fast as it can. Before the fix 2242 of 630 million readings exceeded what the ring can hold (worst 97392 s); with it none do. It passes plain, under TSan and under ASan+UBSan.
fadeVolume(), fadePan(), fadeRelativePlaySpeed() and scheduleFadeAt() read the starting value once, through the handle they were given. A voice-group handle resolves to the group's first voice, so every voice in the group faded from that voice's value. With voices at 0.2 and 1.0, fadeVolume(group, 0.6, 1 s) started the second voice at 0.2: the mix dropped from 0.64 to 0.25 in one block (-8 dB) before fading back up. A fade from pans -1 and +1 swung the right voice to the left at once. When the target equalled the first voice's value, the whole group was set to it at once instead of fading. This hit the direct mix and the going-forward path of the render-ahead ring; a retroactive re-mix already started from each voice's value, except for that last case. Each fade now reads the value of the voice it is fading, under the same lock. A voice already at the target is set as the setter would set it; the others fade. Test: testGroupFadesStartFromEachVoice in test/retroactive_remix_test.cpp fades a two-voice group (volume, the first voice's volume, pan, speed and scheduleFadeAt) on the direct mix, on the ring with a non-restorable source and on the ring with a restorable one. Before the fix 22 checks fail; with it none do. It passes plain and under ASan+UBSan.
A volume, pan or speed change that the render-ahead ring re-mixes in at the playhead restores the checkpoint at or before the playhead, up to a quantum earlier, and replays what changed since as scheduled faders. The new fade then replaced that fader and started from the value restored with the checkpoint, so whatever had moved the voice between the checkpoint and the playhead was lost. setVolume(h, 0) followed 200 frames later, in the same 1024-frame quantum, by fadeVolume(h, 1, 1 s) put the voice straight back at full volume instead of fading it in from silence. A fade over a running 186 ms fade-out jumped back from 0.69 to 0.75, where the fade-out had been at the checkpoint. The new fade now starts from the value the voice's fader gives where the re-mix becomes audible: at the playhead, or at the checkpoint when a fade scheduled ahead of the playhead restores a later one. Until its start time the fader holds that value, as a scheduled fade does on the direct mix path. Test: testRetroactiveFadeStartsFromTheLiveValue in test/retroactive_remix_test.cpp covers both cases. Before the fix the fade in starts from 1.0 and the voice plays at 0.71 just after it, and the other fade starts from 0.75; with it they start from 0 and 0.69. It passes plain and under ASan+UBSan.
The mixer auto-stops a voice that does not loop at the end of the pass in which a read past its last block comes back empty. Linear outputs source sample n at position n + 1, so by then it has output the last sample of that block. Catmull-Rom outputs it at n + 2: position 1 of the empty block, one output sample after the one that made the read. When a mix call ended on that output sample, the voice was stopped first. A 512-sample sound with an impulse in its last sample, mixed in a 512-frame call and then a 1-frame call at 48 kHz, never output the impulse; with a 4-frame second call it did. With upsampling up to three output samples were cut. With Catmull-Rom, a voice whose latest read is the first to come back empty is now kept until it has output a position at or after 1 in that block. The branch that ticks inaudible voices does the same, so both stop in the same call. Point and linear stop as before. Test: testOneSampleCalls in test/voice_tail_test.cpp mixes voices up to the output sample that reads past their last block, then one sample per call, and compares with one long call. testTail now expects a Catmull-Rom voice to stop in the call that outputs that position. Before the fix 8 checks fail, all for Catmull-Rom: per rate 3 to 9 of 37 sounds lose output, by up to 0.36, and most voices stop a call early. With it none fail. The voice tail and resampler carry tests pass plain and under ASan+UBSan.
retroactiveVoiceStart_internal() reads the playhead and moves the event time up to it, then retroactiveBegin_internal() reads the playhead again to pick the checkpoint to restore. The device reads the ring without the audio mutex, so the playhead can move between the two reads. When it moved past a checkpoint, the rollback restored that checkpoint, which lies after the event time. The voice's start delay, (event time - checkpoint time) * rate, then came out negative and was cast to unsigned: undefined behaviour. A stress run under UBSan reports "-256 is outside the range of representable values"; on x86_64 the delay becomes 4294967040 samples and the voice never plays. retroactiveBegin_internal() now records the time it placed the rollback at (mRemixEventTime: the caller's time moved up to the playhead it read, never before the checkpoint), and the voice starts there, at the newer playhead, with a delay of zero or more. Test: testRetroactivePlayWhileTheDeviceReads in test/retroactive_remix_test.cpp plays a voice 8 frames short of a checkpoint and reads 16 frames of the ring, as the device would, right before each of the playhead reads play() makes in turn. It does so through a hook compiled in only with SOLOUD_RETRO_TEST_HOOKS, which test/run_retroactive_remix_test.sh now defines; 7194182 has no hook, so the test was run against this change with its one fix line removed. Then the read before the rollback's playhead read leaves the voice silent, a delay of -8 frames cast to unsigned, and UBSan reports the cast. With the fix the voice starts at the playhead every time. It passes plain and under ASan+UBSan.
fadeVolume(), fadePan() and fadeRelativePlaySpeed() compared the target with the voice's value at the write head and, when they were equal, set the voice to it instead of fading. On the render-ahead ring the write head is up to the ring's depth ahead of what is playing, so a fade that would have started from the playhead became a step at the playhead. A 30 ms fade to 0.25 has finished at the write head while the playhead is 9 ms into it; fadeVolume(h, 0.25, 1 s) then dropped the output from 0.27 to 0.13 within 400 frames, while scheduleFadeAt(h, playhead, 0.25, 1 s) faded. The fades now try the retroactive fade first, as scheduleFadeAt() does, and it starts from the voice's value at the playhead. Only the going-forward fade from the write head still sets a voice that is already at the target. Test: testFadeToTheWriteHeadValueFades in test/retroactive_remix_test.cpp. Before the fix, at 7194182 and at the previous commit, fadeVolume differs from scheduleFadeAt by up to 0.18 and halves the output within 400 frames, and fadePan and fadeRelativePlaySpeed step in 0.1 ms instead of fading over 1 s. With it all four checks pass. It passes plain and under ASan+UBSan.
The mixer sets a voice's speed once per quantum, from the speed fader's value at the quantum's end, for the whole quantum. A speed change at the playhead re-mixed the quantum the device is playing from its start at the new speed, so the device skipped or repeated the source by (new - old speed) * (playhead - quantum start) frames. With 1024-frame quanta and the playhead at 2900, 852 frames into the quantum from 2048, setRelativePlaySpeed(h, 2) skipped about 850 source frames, 19 ms of audio. Stepping back to 1 during a fade to 2 repeated about 120. A retroactive speed change now starts at the next quantum boundary, the end of the quantum the device is playing, from the speed the voice has there, which is the speed that quantum was mixed at. Up to there the re-mix plays what the device has been playing, and the change lands at most one quantum after the playhead. A voice that starts at or after the playhead has played nothing the device heard, so its change still starts at the event: play() followed by setRelativePlaySpeed() plays at the new speed from the first frame. One trade-off remains: two speed changes to a playing voice inside one quantum land together, and the later one replaces the earlier one. A speed set or fade followed by a speed fade in the same quantum loses the first change. testFadeToTheWriteHeadValueFades now makes its two speed fades in different quanta: in one quantum the short fade would not have started where the long one starts. Test: testSpeedChangeAtThePlayheadDoesNotSkip in test/retroactive_remix_test.cpp changes the speed with the playhead 852 frames into a quantum, once at a steady speed and once during a fade, and compares the output with a run without the change. Before the fix, at the previous commit, the rest of that quantum moves by up to 1023 and 143 source frames. With it the rest of the quantum is unchanged and the new speed holds from the next quantum on. testSpeedChangeOfANewVoiceStartsAtOnce plays a voice 852 frames into a quantum and sets its speed to 2 right away; moving every change to the boundary played it at speed 1 until there, and it now plays at 2 from its first frame. Both pass plain and under ASan+UBSan.
The device callback read the ring once, after waiting for it to hold the whole callback. A callback can ask for more than the ring holds: with a 2048-frame quantum, a 512-frame render-ahead and 256-frame periods the ring holds 3584 frames, and a backend that moves to 4096-frame callbacks asks for 4096. The writers stop while less than a quantum is free, so the ring never held more than 2048 of them. With the render worker the callback spun for its whole wait budget, 46 ms of a 93 ms period, and with or without the worker it played 2048 frames and then 2048 of silence, every callback. The callback now reads what the ring holds and mixes, or waits for the worker, between reads, until it has the whole callback or the wait budget runs out. Each read makes room for the next quantum. Test: testDeviceReadsLongerThanTheRing in test/render_ring_test.cpp makes four 4096-frame device reads of a sawtooth from that 3584-frame ring, with and without the worker. Before the fix, at 7194182 and at the previous commit, half of the frames come from the ring, the rest is silence, and with the worker a read spins for 46 ms. With it every frame comes from the ring and the sawtooth has no jumps. The test also prints how long the longest read took, but does not assert it. It passes plain, under TSan and under ASan+UBSan.
generateWaveform() looks WAVE_FSQUARE and WAVE_FSAW up in tables of 4097 entries (soloud_misc.cpp), at the phase wrapped by p - floorf(p). For a phase in (-2^-25, 0) that wrap rounds to exactly 1.0f, so the lookup reads entry 4097, past the table: a Fourier saw set to -0.0001 Hz starts its phase there. A NaN or infinite phase has no table position at all. Its float to int conversion is undefined; on x86-64 it gives INT_MIN and the read crashes. Basicwave keeps a NaN or infinite frequency in its phase for good, and setWaveformFreq() passed any value through. The lookup now starts the period over when the wrap reaches 1.0f or the phase is not finite (a bit test, which fast-math flags cannot fold), and Player::setWaveformFreq() ignores a non-finite frequency. Verified by test/waveform_synth_test.cpp (new, run by test/run_waveform_synth_test.sh), scenarios fourier and freq. At the previous commit the plain run reads fsquare(-1e-30) as -1.8e-15 instead of 0 and then dies of SIGSEGV at the NaN phase; under ASan and UBSan it reports index 4097 out of bounds; freq fails 3 of 8 assertions. With the fix all 31 assertions pass, plain and under ASan and UBSan.
The f32x4 fast_log2() and fast_exp2() in src/simd/soloud_simd.h return std::log2/std::exp2 per lane in their #else branch, but the minimax polynomial after the #endif was still compiled in that build, and it uses y, ef, f and scale, which only the SSE2, NEON and WASM branches declare. Every translation unit that includes the header failed to compile without a SIMD backend: with -DDISABLE_SIMD (32 of the plugin's sources), and on any target that has none, such as wasm built without -msimd128. The polynomial now compiles only with a SIMD backend; the fallback keeps its per-lane std::log2 and std::exp2. Verified by test/simd_fallback_test.cpp (new, run by test/run_simd_fallback_test.sh). It builds the engine core, the analyzer, the filters and the test with the host's SIMD backend and with -DDISABLE_SIMD, checks the f32x4 helpers against scalar math in both, and compares a scene mixed through five voice filters and a global limiter. At the previous commit the -DDISABLE_SIMD build fails with 221 errors. With the fix both builds pass (18 and 20 assertions, also under ASan and UBSan), and the scalar scene matches the SSE2 one within 1.5e-8.
Player::loadWaveform() built the Basicwave with detune and scale swapped (player.cpp:1610), while its constructor takes (waveform, superWave, scale, detune). A superwave loaded with scale 0.25 and detune 1.0 played with scale 1.0 and detune 0.25, until the app set them again. Verified by test/waveform_synth_test.cpp scenario params: three loadWaveform() calls, checking the stored scale, detune and superWave. At the previous commit it fails 6 of 16 assertions (each scale and detune swapped); with the fix the whole file passes (47 assertions), plain and under ASan and UBSan.
setWaveformFreq/Scale/Detune, setSuperWave and setWaveform write plain fields of the Basicwave (basic_wave.h:51-55) on the calling isolate's thread, with no lock, while BasicwaveInstance::getAudio() reads them on the audio thread: a data race on every parameter change of a playing waveform. getAudio() already takes one copy of each parameter per block. The parameters are now atomics. getAudio() loads each once per block, the superwave flag included, which it used to read in place. Verified by test/waveform_synth_test.cpp scenario race: a superwave plays at volume 0.001 on the default device while the test thread calls the five setters for 1.5 s. At the previous commit ThreadSanitizer reports 4 data races (getAudio() lines 46, 47, 74 and 78 against the setters); with the fix the whole file passes with no report (53 assertions), plain and under ASan, UBSan and TSan.
Soloud::getFilterParameter() resolved a voice handle with getVoiceFromHandle_internal() before it took the audio mutex (soloud_core_filterops.cpp:103), and after locking checked only that the slot was not empty. The lookup reads mVoice[] and the voice's play index, which the mixer changes when a voice ends and a new play reuses the slot, so a getter racing the mixer could read a voice being deleted, or return a parameter of the voice that took the slot. The lookup now runs under the audio mutex, like the setters' FOR_ALL_VOICES_PRE, and the invalid-handle return releases it. Verified by test/filter_ops_test.cpp (new, run by test/run_filter_ops_test.sh) scenario getter: a mixer thread, a thread playing 64-frame blips that carry a biquad filter, and a thread reading a filter parameter of the latest blip, for 1.5 s. At the previous commit ThreadSanitizer reports 3 data races, getVoiceFromHandle_internal() under getFilterParameter() against the mixer's voice removal; with the fix it passes (3 assertions) with no report, and plain and under ASan and UBSan.
A new CompressorInstance started its level detector, which runs in dB, at 0 dBFS (compressor.cpp:23-25). Every voice that plays with the compressor gets a new instance, so each one compressed its first samples as if they were at full scale until the release brought the envelope down: a steady -20 dBFS signal, far below the default -6 dB threshold, came out 4 dB down at the start and took about 40 ms to reach unity. The envelope now starts at the detector's floor, 20 * log10(1e-8) = -160 dB. Verified by test/filter_ops_test.cpp scenario compressor: a -20 dBFS signal through a new instance, then a signal 5 dB over the threshold. At the previous commit 3644 of 16384 samples change and the first comes out at -4.00 dB; with the fix none changes and the loud signal is still compressed. The whole file passes (5 assertions) plain and under ASan, UBSan and TSan.
play() snapshots a new voice for the checkpoint journal before its filters have run, so an echo or flanger snapshot taken then has no delay line. Restoring it (EchoFilterInstance::restoreState, FlangerFilterInstance::restoreState) restored the offsets and the parameters but kept the live delay line and everything it had heard since. With render-ahead on, a second play() inside the rendered window re-mixes the first voice from its birth, so the echo played the old delay line again: a steady 0.1 voice with a 1 ms echo came out at 0.1414 instead of 0.0707 at frame 32 of the window, before its echo was due, and a flanger mixed in its old taps. A restore from such a snapshot now zeroes the live delay line. Its storage is kept, so the restore allocates nothing and the next block uses it like a new one. Verified by test/filter_ops_test.cpp scenario restore: an echo and a flanger instance restored from a fresh snapshot repeat their first block, and with each on a voice, a second play() inside the render-ahead window leaves the filter's share of the output (filtered over unfiltered) unchanged. At the previous commit all 4 checks fail (the echo's share doubles, the flanger's goes from 0.5 to 1); with the fix the whole file passes (13 assertions) plain and under ASan, UBSan and TSan, and the checkpoint, retroactive re-mix and realtime/custom DSP suites still pass.
FlangerFilterInstance::filter() advanced the LFO phase (mIndex) by the block's length once per channel, so each channel started where the previous one ended; stock SoLoud does the same per sample. With two channels the LFO ran at twice its frequency, the right channel's sweep ran one block ahead of the left, and the output depended on the block sizes the mixer happened to use: the same stereo signal through one 2048-frame call and through 16 calls of 128 frames differed by up to 0.40. Every channel now sweeps the block's frames from the same phase, which advances once per block. Mono output is unchanged. A stereo flanger now sweeps at the frequency it is set to, with both channels in step. Verified by test/filter_ops_test.cpp scenario flanger, at 10 and 40 Hz: two channels with the same input must match each other and a mono run, and blocks of 128 must match one 8192-frame block except for isolated samples. At the previous commit all 6 checks fail (7764 of 8192 frames differ between the channels; 15786 of 16384 samples, worst 0.40, between the block sizes). With the fix the channels match exactly, and 6 and 34 of 16384 samples differ between the block sizes, where the rounded delay steps the other way, as it already did for mono. The whole file passes (19 assertions) plain and under ASan, UBSan and TSan.
The getFilterParams() export rejected every value that was neither normal nor NaN (bindings.cpp:3627 for a bus, :3643 for a voice), and zero is not a normal float. Reading back a parameter set to 0, such as the wet level of a fully dry echo, failed with filterParameterGetError, which the Dart side throws as an exception. Both branches now reject what that error documents, a NaN or infinite value, and accept any finite one, zero included. A NaN value, which the old check let through, is now reported as that error too. Verified by test/filter_params_test.cpp (new, run by test/run_filter_params_test.sh) scenario zero: an echo on a voice and on a bus, its wet level set to 0 and read back, and to 0.25 on the voice. At the previous commit 2 of 12 assertions fail (error 18 for the voice and the bus); with the fix all pass, plain and under ASan and UBSan.
Filters::getFilterParams() returned 9999.0 when the filter was not
active, and the getFilterParams() and getCustomFilterParams() exports
took that value to mean filterNotFound. A real parameter of 9999, such
as a parametric EQ band at 9999 Hz, then failed to read back with
filterNotFound on a voice, a bus or a custom filter. A global filter
that was not active went the other way: getFilterParams() returned
noError with 9999.0 as its value, so ParametricEqGlobal.bandFrequency()
reported 9999 Hz for an EQ that was never added.
Filters::getFilterParams() now returns whether the filter is active and
passes the value separately. Every branch of both exports, the global
ones included, reports filterNotFound for a filter that is not active,
filterParameterGetError for a NaN or infinite value, and noError for
any other value. Reading a global filter that is not active through
FilterParam.value now throws, as it already did for a voice or a bus.
When bandFrequency() can't read a band's frequency it spaces the bands
by the EQ's band count, and an EQ that is not active has none: the
fallback threw ArgumentError ("Band index must be between 0 and -1").
A voice or bus EQ already threw; once the global read failed too, the
global EQ would have thrown as well. Both now report the band's default
frequency for an EQ that is not active (30, 600 and 12000 Hz for the
first three bands, 1000 Hz for the rest). The example's parametric EQ
page reads a band's Q only while the EQ is active, so resetting the
bands or changing their number after Deactivate no longer throws.
Verified by test/filter_params_test.cpp scenario missing (new): an EQ
band set to 9999 Hz on a voice, a bus and globally, a custom filter
parameter set to 9999, filters that were never added, and a NaN global
parameter. At the previous commit 5 of its 22 assertions fail (error 12
for the voice, the bus and the custom filter; noError for the missing
global echo and the NaN); with the fix the whole file passes (34
assertions), plain and under ASan and UBSan.
And by test/parametric_eq_band_frequency_test.dart (new, Dart VM with
the native library): a global and a bus EQ that are not active report
30, 600, 12000 and 1000 Hz, and a global EQ reads a band set to 9999 Hz
back, then 600 Hz once deactivated. At the previous commit all 3 tests
fail (9999 Hz for the global EQ, ArgumentError for the bus); with the
fix they pass.
Player::stopDeviceAndDestroyAllSounds(), behind disposeAllSound(), detached every global filter from the engine but left them in the player's list of global filters. Afterwards isFilterActive() still reported each one at its old index, adding it again failed with filterAlreadyAdded, and setting a parameter went nowhere: an echo added globally, then disposeAllSound(), stayed at index 0, could not be added again (error 15), and its wet level read back as 1 after being set to 0.5. The list is now emptied as well, each filter removed the way removeFilter() removes one (a custom filter keeps its parameter definitions but not its callback, as after deactivate()), so the filters are reported as inactive and can be added again. The doc of SoLoud.disposeAllSources() now says that it deactivates every global filter, so an app activates again the ones it still wants and sets a custom filter's callback again. The example's compressor and limiter pages read the filter's state back after "stop all", so their checkbox shows the filter as inactive. Before, unchecking it called deactivate() on a filter that was no longer active, which now throws filterNotFound. Verified by test/filter_params_test.cpp scenario dispose (new): a global echo and custom filter, disposeAllSound(), then both added again and the echo's wet level set and read back. At the previous commit 5 of its 11 assertions fail; with the fix the whole file passes (45 assertions), plain and under ASan and UBSan.
BusData::handle, the bus's voice, was left uninitialized until busPlayOnEngine(), yet MixingBus.filters can be used right after createBus(). Setting, reading, fading or oscillating a bus filter parameter before then handed whatever the field held to the engine. Garbage made getVoiceFromHandle_internal() index mVoice out of bounds (under ASan, whose fill byte is 0xbe, UBSan reports index 3773 for an array of 1024). Zero, which a fresh allocation often holds, addresses the global filters, so the call read or changed the global filter in the same slot instead: with a global echo at wet 0.25 and a bus echo, both in slot 0, setting the bus's wet to 0.75 set the global one, and a parameter definition for the bus's custom filter overwrote the global custom filter's value with its default. The handle now starts at 0. While it is 0, the set, get, fade and oscillate exports for a bus's built-in and custom filters return soundHandleNotFound, as they do for a voice that does not exist, and Filters never passes handle 0 to the engine for a bus. A parameter definition is still stored and applies once the bus plays. Setting a bus filter parameter before playOnEngine() now throws in Dart instead of being lost. The example's mixing bus page set its pitch shift right after activating the filter, which now throws before "play bus". It sets the shift only while the bus plays, and again after "play bus". Verified by test/filter_params_test.cpp scenario bus (new): global filters in the slots a bus's echo and custom filter use, then each call on the unplayed bus, the global values read back, and the bus's filters used once it plays. At the previous commit 11 of its 23 assertions fail in a plain build (every call returns noError and the global filters take the bus's values); under ASan 8 fail and UBSan reports the out-of-bounds index. With the fix the whole file passes (68 assertions), plain and under ASan and UBSan.
getFilterParamNames() writes one malloc()'d name per parameter into the caller's array without knowing its size. The FFI binding allocated 30 slots and the parametric EQ has 195 parameters, so the export wrote 1560 bytes into a 240-byte block (AddressSanitizer: heap-buffer-overflow at the strdup() loop); the binding then freed the names with calloc.free(), which is not the native free() on Windows. The web binding allocated 600 bytes, room for 150 of the EQ's 195 pointers, read them at a stride of each name's length instead of 4, so every name after the first was wrong, and never freed the names. A new export, getFilterParamNamesBounded(), takes the array's capacity, writes no more names than that and still reports how many parameters there are, so a caller can ask for the count first; the old export now calls it with no bound and keeps working. The FFI binding asks for the count, allocates that many slots and frees each name with nativeFree(). The web binding keeps the old export, which the prebuilt module has, with room for 256 names (the EQ's 195 are the most any filter has), reads them at a stride of 4 and frees each. Both bindings free the names in a finally block, so none leaks if one fails to decode. The web binding can move to the new export once the module is rebuilt with it. Verified by test/filter_params_test.cpp scenario names (new): the count alone, 30 and 195 slots, and the old export. A weak stand-in keeps the file building at a commit without the export, where 8 of the scenario's 10 assertions fail; with the fix the whole file passes (78 assertions), plain and under ASan and UBSan. Also by test/filter_param_names_test.dart (new, flutter test with the real native library): at the previous commit the overflow corrupts the heap and the test fails in a way that depends on the heap's layout; in one run the test process aborted (SIGABRT), in another a later name failed to decode (FormatException). With the fix both tests pass. And by test/web_bindings_test.dart (new, flutter test --platform chrome) against a stand-in for the module that tracks its allocations: at the previous commit the EQ's names read back empty and both tests fail; with the fix all 195 are read and every allocation is freed, also when a name can't be read (freeing each name in the read loop instead leaks 188 of them). Not tested here: freeing the names on Windows, and the FFI binding's finally block, since no filter has a name that fails to decode.
FlutterSoLoudFfi.readSamplesFromMem() copied the input file into native memory and never freed the copy, and both it and readSamplesFromFile() returned a view of a native samples buffer that was never freed either, on the mistaken belief that it would be garbage collected; the buffer was also four times too large, sized in bytes but counted in floats. readSamplesFromFile() leaked its native copy of the file name as well. Reading the waveform of an 8 MiB file 40 times grew the process by 320 MiB. Both now return a copy of the samples, still unmodifiable, and free the samples buffer, the copy of the file and the file name in a finally block, so an error from the native reader frees them too. Verified by test/read_samples_test.dart (new, flutter test with the real native library): the resident set over 40 calls of readSamplesFromMem() on an 8 MiB file and 400 calls of readSamplesFromFile() for 16384 samples. At the previous commit it grows by 320 and 99 MiB and both tests fail (the limit is 32 MiB); with the fix it grows by 0.1 and 6 MiB and both pass.
FlutterSoLoudWeb.readSamplesFromMem() returned Float32List.sublistView() of the module's heap and then freed that memory. The list kept showing whatever the module wrote there next, such as the input of the following call, and went empty once the heap grew and the view's buffer was replaced. The samples are now copied out of the heap with sublist() before the memory is freed, and both blocks are freed in a finally block, so an error from the reader frees them too. Verified by test/web_bindings_test.dart (flutter test --platform chrome) against a stand-in for the module that reuses freed memory and fills it with NaNs: two calls in a row, the first one's samples checked afterwards. At the previous commit they read back as NaN; with the fix they keep their values and all 3 tests in the file pass.
BasicwaveInstance::getAudio() advanced each phase, the waveform's and a superwave's three harmonics', by the frequency over the sample rate and took 1 off once it reached 1. setWaveformFreq() accepts a negative frequency, which runs the waveform backwards, but its phase then fell below 0 and kept falling: one second of a saw at -440 Hz ended at -440.5 with every sample outside [-0.5, 0.5], and a square at -440 Hz stayed at -0.5 instead of playing a tone. A superwave's harmonics run at 2, 2 + detune and 2 + 2 * detune times the frequency, so at a high one a harmonic can advance by more than a period per sample, which taking 1 off once does not undo: at 15 kHz with a detune of 1 the harmonics at 3 and 4 times it advance by 1.02 and 1.36 periods per sample, and one second of a superwave saw ended at 4200. Each phase now wraps into [0, 1) with floor() whenever it leaves that range, whichever way and however far it moved. Verified by test/waveform_synth_test.cpp scenario negfreq (new): one second each of a saw and a square at -440 Hz, and of a superwave saw at -440 Hz and at 15 kHz, rendered directly. At the previous commit all 5 of its assertions fail; with the fix the whole file passes (58 assertions), plain, under ASan and UBSan, and under TSan.
The FFI loadMem() (bindings_player_ffi.dart) copied the bytes into a calloc'd buffer and never freed it. In memory mode Wav::loadMem copies them again, so every call leaked the whole file: 12 loads of a 16 MB WAV, each disposed, grew the process by 384 MB. In disk mode Player::decodeMem handed the buffer to WavStream::loadMem without a copy, so the stream owned it: - it streamed from it and freed it with delete[] when the sound was disposed, though the buffer came from calloc (CoTaskMemAlloc on Windows, where that corrupts the heap); - when it rejected the data (M4A, AAC) it freed the buffer at once, and decodeMem read it again for the platform-decoder fallback: a heap-use-after-free reading the whole 243,369-byte file. WavStream now streams from its own copy, so the buffer stays the caller's. Dart copies the bytes in bulk and frees the buffer and the name in a finally block, in both modes. Tests: - test/load_mem_ownership_test.cpp (run_load_mem_ownership_test.sh, plain and --asan, through the plugin's exports on miniaudio's null backend) loads WAV, MP3, M4A and AAC in both modes from a calloc'd buffer, checks the buffer is untouched, frees it as Dart does, plays the sound and disposes of it. On the previous commit the plain build aborts with "malloc: double free" when the disk-mode WAV is disposed; under ASan it reports a heap-use-after-free in WavStreamInstance's constructor reading the freed buffer, and the M4A/AAC group the one in Player::decodeMem. With the fix it passes: 41 assertions plain, and 21 + 21 under ASan and UBSan, which runs the two groups apart. - test/load_mem_allocation_test.dart (flutter test, real native library): 12 loads of a 16 MB WAV, each disposed, must grow the process by less than 64 MB in either mode. On the previous commit memory mode grows it by 384 MB; with the fix both pass.
A WavStream loaded from a path in disk mode keeps only the path, and each voice opens the file again. When the file has been deleted or moved since loading, WavStreamInstance's constructor drops the DiskFile it cannot open, and when the file opens but no decoder accepts it (a file overwritten after loading, say) it drops it too. getAudio() then plays nothing, but hasEnded() compared the voice's position, 0, with the source's length, so the voice never ended and never reported voice-ended: it played silence until it was stopped, holding a voice slot. - WavStreamInstance::hasEnded() is true without a file, so the voice ends at the first mix that reads it and is reported like any voice that ends. - DiskFile::read(), seek(), pos() and eof() act as on an empty file without an open file, as length() already did, instead of passing a null FILE* to stdio. The mixer never ends a looping voice, so a looping voice of a missing file still stays silent until it is stopped. Tests: test/wavstream_missing_file_test.cpp, run by test/run_wavstream_missing_file_test.sh (plain, --asan), null backend. For the repo's WAV, MP3, FLAC and Ogg Vorbis samples, loaded from a temporary copy that is then deleted, or overwritten with zeros: play(), playClocked(), playScheduled(), play3d(), play3dClocked() and play3dScheduled() give a voice that ends at the first mix that reads it, is reported through the voice-ended callback and leaves only the voice already playing, whose output is exactly what it is alone. Once the file is written back, the source plays again. A DiskFile on a path that does not exist reads nothing, stays at 0 and is at its end. On the previous commit 144 assertions fail (none of the 48 voices ends), then the DiskFile check crashes with SIGSEGV in fread(). With the fix: 333 assertions pass plain and under ASan and UBSan, and 251 with NO_XIPH=1 (no Ogg). The runner also builds without Ogg, and says why, when a Xiph header or library it needs is missing.
An out-of-process editor (for a bundle that embeds a Flutter app) shares memory with the host. Each effect block writes up to 4 x 16 KiB of telemetry into it from the audio thread, and parameter changes from Dart go there too. The writers checked mIsOpen, then used the plain mShm pointer, while the thread closing the editor unmapped it. macOS close() cleared mIsOpen and unmapped without waiting for a write in progress. The Linux and Windows poll timers (the user closed the helper's window) unmapped and nulled mShm before clearing mIsOpen. A block that overlaps the unmap faults on the audio thread. In the test 60 remote editors are opened and closed, by the host and by the helper, while an effect processes on another thread. ThreadSanitizer reported the race on mShm between close() and writeAudioTelemetry() in 6 runs out of 6 before this change. The window base class now holds the mapping for the writers. The platform code publishes it once the helper is ready and retires it before unmapping. Retiring clears the published pointer and waits for any writer still using it. Writers count themselves in before loading the pointer, and every step is sequentially consistent, so either a writer sees null or the retiring thread waits for it. The identical writeAudioTelemetry() and notifyParameterChanged() of the three platform windows move to the base class. Verified by testRemoteEditorCloseWhileProcessing in vst3_host_test, with a stand-in for the editor helper: clean in 6 runs under --tsan and under --asan. It catches the race only under --tsan; plain and --asan runs passed before this change as well. Not tested here: the host tests run on macOS only, and the Linux and Windows editors get the same three edits. The Linux file passes a syntax check against the macOS headers; the Windows file was not compiled.
The audio thread calls plugins through raw pointers loaded from a lock-free table. Every unload therefore clears the table, then runs the audio-thread barrier before shutting plugins down. The two unloads at process exit had no barrier: the macOS observer of NSApplicationWillTerminateNotification and the Vst3HostManager static destructor. Both can run while the device still mixes (the Player is never destroyed). A mix that loaded a plugin's pointer just before the table was cleared went on to call into the destroyed plugin. In the test a mix holds the audio mutex and a plugin pointer it looked up. The terminate notification's unload returned while that mix was still paused, so the plugin had been destroyed under it. bindings.cpp now registers vst3AudioThreadBarrier with the host manager when a plugin is loaded. Both exit paths go through the new unloadAllAtExit(), which passes it to unloadAll(), except the static destructor on Windows. That one runs at DLL_PROCESS_DETACH, after ExitProcess has ended every other thread, so no mix is in progress, and the barrier would take locks that one of those threads may have died holding: the exit would hang, or the process would end there. Deinit keeps the plain unloadAll(): it holds the player lifetime lock exclusively, which the barrier takes shared, and the device is stopped by then. unloadAll() also skips the barrier when nothing was loaded, so the exit unload does not wait on an engine a concurrent deinit is tearing down. Verified by testExitUnloadWaitsForAudioThread in vst3_host_test. At 7194182, built without the barrier registration (no such API there), it fails. Not tested here: the host tests run on macOS only, so the Windows destructor, which keeps the unload it had, was not compiled.
Vst3Host.loadEffect() set the plug-in up (setupProcessing) at its sampleRate argument, 48000 by default, while SoLoud.init() defaults to 44100 and the effect filters the engine's global or bus audio. With both defaults everything time-based in the effect (delay times, LFO rates, filter frequencies) was off by 8.8%; only ProcessContext.sampleRate carried the real rate. loadEffect()'s sampleRate now defaults to SoLoud.instance.sampleRate. Instruments keep 48000: the custom source they play through is created at the same rate, and the engine resamples it. Not tested here by a committed test: the package's Dart tests build without VST3. Checked with a temporary test in the example app, which enables VST3. It loads the mock plug-in of vst3_host_test on a 44100 Hz engine: set up at 48000 before, 44100 after.
loadInstrument() wraps the plug-in in a custom audio source, which was created with singleInstance false. Each play() of its audioSource added a voice, and every voice calls processInstrumentBlock() on the same plug-in. With the source played twice, the plug-in rendered two blocks per mix (96 process calls in 500 ms instead of 47 in the check below) and its MIDI went to whichever voice ran first. createCustomAudioSource() now takes singleInstance (default false, passed through on native and web, where the C export already had it), and loadInstrument() sets it. Playing the instrument again restarts its one voice. Not tested here by a committed test: the package's Dart tests build without VST3. Checked with a temporary test in the example app that plays the mock instrument of vst3_host_test twice: 2 voices and 96 process calls in 500 ms before, 1 voice and 47 after (one play: 47).
When the user closes an out-of-process editor, the helper sets helperClosed in the shared memory and then exits. The host's poll timer saw the flag, tried one non-blocking waitpid(), and set mHelperPid to 0 whatever it returned. If the helper had not exited yet it was never reaped: one zombie process per editor closed that way, until the app exits. The poll timer now reaps the helper the way close() does: it waits up to 150 ms for it to exit, then sends SIGTERM and finally SIGKILL. That code moves from close() into reapHelper(), used by both. reapHelper() also stops as soon as waitpid() fails. ECHILD means the helper is no longer a child to reap, because the app reaps children itself (SIGCHLD ignored, or a waitpid(-1) of its own). close() took that for a helper still running: every close stalled for 250 ms and then sent SIGTERM and SIGKILL to a pid that may have been reused by then. A signal now only follows a check that the helper is still an unreaped child, and the poll timer takes ECHILD as the helper having exited. Not tested here: the host tests run on macOS only, and this code is Linux-only. It passes a syntax check against the macOS headers.
The Linux editor shares its state with the helper process through a file that both map. The file was /tmp/soloud_vst3_shm_<pid>_<n>.bin, opened with O_CREAT and O_TRUNC but not O_EXCL or O_NOFOLLOW, so any local user could guess the name and create it first. Where the kernel's fs.protected_symlinks and fs.protected_regular are off, a symlink there to one of the user's files got that file truncated and overwritten with the shared memory header, and a file of their own with mode 0666 let them read and change what the host and the helper exchange. Where those settings are on, such a file only made the open fail, and the editor did not open. The file is now created by mkstemp(): a random name, O_EXCL and mode 0600. It goes in $XDG_RUNTIME_DIR, which only the user can enter, and falls back to $TMPDIR and then /tmp when that is unset or unusable. Not tested here: the host tests run on macOS only, and this code is Linux-only. It passes a syntax check against the macOS headers.
disposeSound() stopped each voice while iterating the sound's own handle vector. stop() can dispatch the voice-ended callback synchronously, and that callback reaches removeHandle(). The handle had just been dropped from mSoundByHandle, so removeHandle() fell back to its linear scan and erased the handle from the vector being iterated, invalidating the loop (AddressSanitizer reports a container-overflow). Iterate a copy instead. The engine lifecycle test now plays one sound on several voices and disposes it, checking every voice ends once and the sound is gone.
After a seek out of its buffered range, a pull stream resets an Opus or Vorbis decoder (prepareForSeek()) and feeds it from the seek's file offset, but the reset restarted the Ogg seek index's byte count at 0 (ogg_seek_index.h), so the pages indexed from then on were recorded at offsets relative to the seek. A later seek read them as file offsets. covers() also checked only the last entry, so a target before the first entry interpolated a negative offset into a uint64_t. With a buffer of about one second, seeking to 5 s and back to 2 s requested nothing (UBSan: -46767.9 converted to unsigned long long) and the stream stayed at [2.00, 2.00]. Seeking to 5 s, playing on and seeking to 6.6 s requested byte 7826 instead of 68593 for Opus (12180 instead of 70341 for Vorbis), and played audio from near the start. prepareForSeek() now also takes the file offset the data resumes from, and the Ogg decoders restart their index there. covers() requires the target to lie between the first and last indexed pages; a target outside them falls back to the stream's estimate from the probed duration. FLAC and WAV take the offset and ignore it: they do not index offsets as they decode. Test: test/pull_ogg_seek_test.cpp (run_pull_ogg_seek_test.sh, plain and --asan) drives Opus and Vorbis pull streams through the exports on the null backend, with a feeder answering the requests as Dart does. Before the fix 6 of 16 assertions fail, with the UBSan report; after it all pass: the seek back to 2 s requests the estimate (24307 for Opus) and plays on, and the seek to 6.6 s requests 68593 (Opus) and 70341 (Vorbis).
A pull stream replaced its whole stream decoder on every MP3 seek out of the buffered range (resetDecoderForSeek()). The new MP3 decoder built its seek table from the data that starts at the seek's offset, counting time and bytes from there, and the next seek read that table as a map of the whole file. At a constant bit rate the two nearly agree; with a variable bit rate they do not. After a seek to 6 s in a VBR file, a seek back to 0.5 s requested byte 14823 instead of 2020. MP3 now resets for a seek like Ogg, FLAC and WAV do: prepareForSeek() restarts the codec and the input at the new offset but keeps the seek table built from the start of the file, with that table's sample rate. A table is never built from data that starts at a seek, and cleanup() discards it. Test: test/pull_mp3_seek_test.cpp (run_pull_mp3_seek_test.sh, plain and --asan; the runner encodes a VBR fixture with ffmpeg). Through the exports on the null backend, it seeks a playing stream to 6 s and back to 0.5 s, twice: each seek back must request an offset within four frames of the whole file's seek map, and play on from there. A direct check covers the table surviving prepareForSeek() and cleanup() discarding it. Before the fix both seeks back request 14823 (2 of 17 assertions fail); after it they request 2020 and all pass.
A pull stream builds the MP3 seek table from the first data it gets, so the table can cover only the start of the file. A seek past the table waited for more data, but once all the data had been delivered (mDataEnded) timeToByteOffset() mapped it to the table's last entry, and canSeekToTime() reported any target as covered. On a 12 s constant bit rate file without a Xing header, played to the end of its data, a seek to 6 s requested byte 31346 instead of about 94876, and playback restarted near 2 s. canSeekToTime() now reports a target as covered only between the table's first and last entries, with or without the end of data, and timeToByteOffset() maps only covered targets. The pull stream estimates the others from the probed duration, as it already did before the end of data. Test: test/pull_mp3_seek_test.cpp (run_pull_mp3_seek_test.sh, plain and --asan; the runner now also encodes the CBR fixture) plays the file to the end of its data, seeks to 6 s and compares the requested offset with the whole file's seek map. Before the fix it requests 31346 (1 of 24 assertions fails); after it, 96000, within four frames.
scanBoxes() (m4a_metadata.cpp) recursed into every nested container box, and each level of nesting costs only 8 bytes of input, so 4 MiB of nested 'moov' boxes overflowed the stack, natively and in WebAssembly. It runs on the first chunk of an M4A stream (StreamDecoder::decode(), so buffer and pull streams), on whatever thread delivers it. The scan stops descending at 32 container levels, far deeper than real metadata nests. Test: test/metadata_parsers_test.cpp (new, run_metadata_parsers_test.sh with --asan and --wasm32): `valid` checks that well-formed M4A, ID3 and OpusTags metadata still parse, and `m4a-nested` parses the nested boxes on a thread with a 1 MiB stack, in a child process. Before the fix the child crashes (SIGBUS plain; ASan: stack-overflow in scanBoxes), and the wasm32 build in node fails with "Maximum call stack size exceeded". After it all 10 assertions pass plain and under ASan, and the 7 of the wasm32 build (no Xiph there) pass in node.
The M4A box, ID3 frame and OpusTags parsers bounded a length read from the stream with `offset + length > size`. With a 32-bit size_t (the web build, armeabi-v7a) a large length wraps the sum, the check passes, and the parser reads past the data or builds a string of nearly 4 GiB. A 'moov' box declaring 2^32 - 16 bytes after a 16-byte 'ftyp' wraps to 0; an ID3v2.3 TIT2 frame declaring 0xFFFFFFF0 bytes wraps to 16. A shared check (metadata_bounds.h) compares the length with what is left after the offset, in 64 bits. The M4A, MP3 and AAC ID3, AAC/AC-3 frame header, and OpusTags/OpusHead parsers use it wherever they bound a length. Test: test/metadata_parsers_test.cpp `m4a-wrap`, `mp3-id3-wrap`, `aac-id3-wrap` and `opus-wrap`, plus `m4a-large-wrap` (an extended box size of UINT64_MAX, coverage for the 64-bit size path: it passes before the fix too). On a 64-bit host the sums do not wrap, so the wasm32 build in node shows the bug: before the fix, each scenario run on its own, `m4a-wrap` traps (memory access out of bounds) and `mp3-id3-wrap` and `aac-id3-wrap` abort. After it the wasm32 suite passes (11 assertions), and the host suite passes plain and under ASan (14). Not tested here on a 32-bit target: `opus-wrap`, since the wasm32 build here has no Xiph libraries. Its sum wraps the same way (a size_t offset plus a uint32_t length, against the packet's long length), and it passes on the host.
The FLAC stream decoder caches the header ("fLaC" and every metadata
block) so that a seek can restart the codec from it. A delivery can end
inside the metadata, as the first one does when the file carries a
large picture or padding block. Three related failures followed, fixed
together here because each fix alone leaves one of the others:
- findFlacHeaderLength() (flac_stream_decoder.cpp:26) stepped over each
block by its declared length without checking that the block had
arrived. It returned a header end past the received bytes, and
decode() (:170) copied that range: a 62-byte first delivery of a file
whose metadata ends at 65582 read 65520 bytes past the input vector.
- decode() waited for the rest of the header only while the codec was
not initialized, but StreamDecoder initializes it before the first
delivery. The partial header went to the codec, the input never
began with "fLaC" again, the header was never cached, and every
later seek produced silence.
- Waiting for the header must end when the data does. A fresh decoder
whose data stopped inside the metadata (a truncated file) stayed
pending forever, and with the wait above an initialized one would
too; the native buffer stream's decode loop polls while
hasPendingData() is true.
The scanner now reports the header complete only once every block has
fully arrived, using metadataHasBytes() so the sum cannot wrap.
decode() waits for the whole header whatever the codec state; the codec
cannot produce samples before it anyway, so no audio is delayed. Once
the data has ended with the header incomplete, decode() drops the
input, marks the decoder drained and reports a codec error.
Test: test/metadata_parsers_test.cpp flac-split-header,
flac-seek-split-header and flac-truncated-header. The fixture's final
metadata block is 64 KiB of padding. It is delivered as its first 44
bytes (inside that block's header) or 62 bytes (inside its payload) and
then the rest, to a fresh decoder and to one initialized first as
StreamDecoder does. It must decode the same 512 samples as when
delivered whole, again after prepareForSeek(0, 65582) and the frame
bytes from 65582, and, when the data ends after the first part, stop
reporting pending data within 8 decodes.
On the parent the seek after a 44-byte split decodes 0 samples instead
of 512 and the fresh decoder's truncated data stays pending (2
failures); under ASan each scenario also reports a heap-buffer-overflow
(a 65582-byte read at flac_stream_decoder.cpp:170). With the scanner
fix alone the 62-byte split fails the same two ways. With this commit
the suite passes plain and under ASan (78 assertions), and the wasm32
build, which leaves the FLAC scenarios out, still passes (11).
Metadata strings leave the native side as UTF-8; M4A, Vorbis and Opus tags are UTF-8 already. The ID3v2 text-frame parser (parseId3TextFrame(), mp3_stream_decoder.cpp:50, and its copy in aac_stream_decoder.cpp:420) did not convert to it: - Latin-1 text (encoding 0) passed through unchanged, so a title "Caf\xe9" came out as those bytes, which are not valid UTF-8. - Each UTF-16 code unit was encoded on its own, so a character outside the Basic Multilingual Plane (U+1F600, or any other emoji) became two three-byte surrogate encodings, "\xed\xa0\xbd\xed\xb8\x80", which are not valid UTF-8 either. Unpaired surrogates were encoded the same way. - A byte-order mark under encoding 2 came out as U+FEFF. Both parsers now use one converter, id3TextToUtf8() in the new audiobuffer/id3_text.h. It maps Latin-1 bytes to UTF-8, combines a surrogate pair into one four-byte character, replaces an unpaired surrogate with U+FFFD, honours a byte-order mark under encodings 1 and 2, and passes UTF-8 (encoding 3) through. Trailing NULs are dropped as before. Test: test/metadata_parsers_test.cpp id3-latin1 and id3-utf16, through the AAC parser and through the title the MP3 decoder reports: "Cafe" with an acute e in Latin-1 and in UTF-8; "A", U+1F600, "B" in UTF-16LE and UTF-16BE, with and without a BOM, under encodings 1 and 2; lone high and low surrogates; and BMP text as a control. On the parent id3-latin1 fails 2 checks (both titles stay "Caf\xe9") and id3-utf16 fails 16 (all but the BMP control). With the fix the suite passes plain and under ASan (133 assertions) and in the wasm32 build (66). This fixes the native side only. The Dart readers of these strings (_arrayToString() in native_metadata_ffi.dart, _readString() in native_metadata_web.dart) take one byte per character and do not decode UTF-8 yet.
The Apple AAC stream decoder queues 3 packets before it starts decoding (aac_stream_decoder_apple.mm:92) and did not consider the end of the data. A whole stream of one or two packets (up to 2048 frames, 46 ms at 44.1 kHz, as in a short sound effect) never reached the threshold: every decode returned nothing and hasPendingData() stayed true. The stream produced no audio, and a buffer stream's background decode thread (audiobuffer.cpp:472) polled every 2 ms until the sound was disposed. Once the data has ended, the decoder now decodes and flushes the queued packets whatever their number. Test: test/native_decoder_test.cpp testShortAacStreamDrains (Apple only: the fixture is made with afconvert). The first two ADTS packets of a 0.25 s stereo tone are decoded, the end of the data is signalled, and the decoder is drained at most 20 times. It must stop reporting pending data and produce samples. test/run_native_decoder_test.sh gains an --asan option. Before the fix the scenario fails, plain and under ASan: still pending after 20 drains, no samples. With the fix the native decoder suite passes plain and under ASan, the scenario draining 4096 samples.
When data decoded for a pull stream completed a pending seek, applyPendingSeek() ran checkBuffering() (pull_buffer_stream.cpp:1006), which calls Player::getPause() (:1147) and setPause(). Both take SoLoud's audio mutex, which is not recursive, and two callers already held what that waits for: - The mixer. getAudio() decodes data a feeder has queued but not yet decoded (tryDecodePendingData(), :135). When that data completed a seek out of the buffered range, the audio thread called getPause() with the audio mutex held and blocked on itself: the mixer stopped, and every later engine call that takes the audio mutex hung. A feeder preempted between queuing its bytes and decoding them was enough. - A delivery decoding under mDecodeMutex. SoLoud seeks with the audio mutex held, and PullBufferStreamInstance::seek() then takes mDecodeMutex (:197). A delivery whose decode completed a seek waited in getPause() for a seeking thread that waited for the delivery's mDecodeMutex. applyPendingSeek() now only records that the seek completed. Every delivery runs the buffering check anyway once it has decoded and released the mutex (addAudioData(), setDataIsEnded()), and an empty delivery now runs it too when its decode completed a seek. Nothing is lost when the mixer completes the seek: it only does that for a voice it is mixing, and the delivery that brought the data runs the check. Test: test/pull_stream_race_test.cpp, run by test/run_pull_stream_race_test.sh (plain, --asan, --tsan), with new barriers pullDataQueued, pullSeekCompleted and pullBufferingCheckEntered, on a playing 16-bit stereo PCM stream: - mixer-seek: after a seek to 300 s, a feeder queues the data there and parks before decoding it, and the mixer completes the seek. The audio mutex must be free again within 3 s (getPause() returns), and the stream then holds the data from 300 s. - deliver-seek: a delivery that completed the seek to 300 s parks in its buffering check while another thread seeks to 600 s. Once it is released, both must return within 3 s. With only the barriers added, both scenarios deadlock (the run reports it and exits with failure). With the fix both pass plain, under ASan and under TSan. TSan still reports one race in deliver-seek, the seek discarding the decoded backlog without mDecodeMutex while the delivery reads its size: seeks, rewinds and resets change the decode state without the mutex, a separate bug. The pull Ogg and MP3 seek tests still pass.
A pull stream's seek, rewind and reset replaced the decoder, cleared the buffers and moved the read offsets without the stream's decode mutex, while a delivery (addAudioData on the feeding thread) or the mixer decoded under it. getBufferTimeRange, getLength and getBufferedLength read the same state unlocked. Seeking a voice to 0 rewinds it, and rewind() replaced the decoder of a compressed stream while a delivery on another thread was still decoding a chunk with it. With an MP3 fed in 32 KB chunks while another thread seeks every 7 ms (every fifth seek to 0), resets every 60 ms and reads the buffered range every 1 ms, ASan reports a heap-use-after-free in the decoder within 1.5 s. Under TSan the same run reports races in rewind(), resetPullBufferStream() and requestMoreDataIfNeeded(), then dies with SIGBUS in a memmove. Parked right before it discards the buffer, a seek or a rewind let a delivery decode a whole chunk meanwhile, which the seek then threw away or which landed at the wrong position. A reset completed while the mixer was in the middle of a decode, which then wrote the old stream's samples into the reset buffer. seek(), rewind() and resetPullBufferStream() now hold the decode mutex for their whole update, and the getters take it too. getLength() and the buffered-range getters get *Locked variants for internal callers, and timeToEncodedByteOffset() becomes the private timeToEncodedByteOffsetLocked(). SoLoud applies seeks and rewinds with its audio mutex held, often on the mixer (a looping voice rewinds at its end and on a short read), and a delivery holds the decode mutex for its whole decode, 60 to 120 ms for a 3.4 MB MP3. So neither the mixer nor anything else holding the audio mutex waits for it: - a seek or a rewind only tries the mutex; one that finds it held is deferred, the voice reports the new position at once, and the mixer's first block that gets the mutex applies it, playing silence until then (on the web's AudioWorklet thread a contended lock aborts); - a rewind of a stream that has played nothing yet keeps its buffer, so a looping voice waiting for its first data, which the mixer rewinds every block, does not discard the delivery it waited for; - metadata and duration found while decoding are collected and reported once the mutex is released; - the request for more data is decided under the mutex and made after it, and the mixer only try-locks for it; - a delivery that completes a pending seek hands the new play position to the mixer's next getAudio() instead of writing the voice's position while the mixer advances it. Verified with pull_stream_race_test, which gains seek, rewind, reset, seek-during-decode, loop-during-decode and stress scenarios. At the parent, with the two new test barriers added: seek 6 assertions, 1 failure; rewind 6, 1; reset 7, 1; stress heap-use-after-free under ASan, races then SIGBUS under TSan. Waiting on the decode mutex instead of deferring, seek-during-decode fails 4 of 11 (the seeks and getPosition() blocked while a delivery is parked mid-decode) and loop-during-decode 3 of 6 (the mixer blocked, then the rewind discarded the delivered data). With the fix: 62 assertions, 0 failures plain, with ASan+UBSan and with TSan. pull_ogg_seek_test and pull_mp3_seek_test still pass.
Player::addPullBufferDataStream() looked the stream up under sounds_mutex, released the lock and then called addAudioData() on the raw pointer. A disposeSound() or disposeAllSound() on another thread could destroy the PullBufferStream in between, and the feeder then decoded into freed memory. getPullBufferTimeRange() and resetPullBufferStream() had the same gap. With a feeder parked right after its lookup, disposeSound() and disposeAllSound() both returned within the 300 ms the test waits. Released, the feeder read the destroyed sound: ASan reports a heap-use-after-free in Player::addPullBufferDataStream(), freed by disposeSound(). The three functions now pin the stream under sounds_mutex and use it after releasing the mutex, and a disposal, having removed the sound under the mutex, waits without it until the calls pinned before have unpinned it. Holding sounds_mutex for the whole call instead would make every other user of it, a voice's end on the mixer's dispatch too, wait for a delivery's decode: 107 to 140 ms for the first megabyte of a 3.4 MB MP3. The buffering check reads the sound's handle list and pauses the voice under sounds_mutex, taken before the audio mutex as everywhere, and does nothing once the stream is disposed. This also ends the TSan report in pull_ogg_seek_test of the check reading the sound's handle list while a voice-ended callback erased from it. A disposed stream no longer asks Dart for data. On the web, where every caller runs on the browser's main thread, a disposal does not wait. Verified with pull_stream_race_test, new dispose and dispose-all scenarios (new pullDataSoundLookedUp barrier): the disposal must not finish while the delivery is parked, the delivery then succeeds, the voice is stopped and a later delivery finds no stream. At the parent both fail (6 assertions, 1 failure each), and a variant that releases the parked delivery anyway gives the use-after-free above. The new lock-during-decode scenario parks a delivery mid-decode and needs sounds_mutex free meanwhile, also while a disposal waits for the delivery; holding the mutex for the whole call fails both (6 assertions, 2 failures). With the fix: 84 assertions, 0 failures plain, with ASan+UBSan and with TSan.
rewind() discards the buffered audio and asks for the stream again from offset 0, but left mDataIsEnded set once the whole file had been delivered. hasEnded() then saw an ended, empty stream: seek(0) on a stream that had finished loading ended its voice, a looping voice went silent at its first wrap, and every later delivery was refused with streamEndedAlready. With a 0.5 s PCM file delivered whole, seek(0) after 150 ms of playback made no request for offset 0, the voice ended, and delivering the file again returned error 22 for both halves. Played looping, the stream asked for nothing at its wrap and refused the refill the same way. rewind() now reopens the feed, as an out-of-buffer seek already does. That covers the mixer's rewind of a looping voice at its end too. Verified with the new replay scenario in pull_stream_race_test: after seek(0) the stream asks for offset 0, its voice waits, the file is accepted again and plays through to its end; looping, the stream asks for offset 0 at the wrap, accepts the refill and wraps again. At the parent it fails 6 of its 16 assertions. With the fix the whole test passes, 100 assertions, 0 failures, plain, with ASan+UBSan and with TSan.
resetPullBufferStream() clears the buffers and zeroes the decoded-sample counters under the decode mutex, but the mixer takes samples from the circular buffer and adds them to mDecodedSamplesRead without that mutex (it must not wait for a delivery that is decoding). A reset landing between the two left the empty, reset stream counting the old read: getPullBufferTimeRange() reported a buffered range with no data, and the window and request calculations started from the wrong count. With the mixer parked between its read and its count, a reset finished at once, and the reset stream then reported [0, 0.011610] (one 512-frame block at 44.1 kHz) instead of [0, 0]. The reset now also holds SoLoud's audio mutex, which the mixer holds for the whole read. It takes it after the decode mutex: a reset waits for a delivery's decode, 107 to 120 ms for a 3.4 MB MP3, and holding the audio mutex meanwhile would stop the mixer and every getPosition() for as long. Nothing holding the audio mutex waits for the decode mutex (the mixer, seeks and rewinds only try it), so this adds no cycle. Verified with two new scenarios in pull_stream_race_test. read-reset (new pullSamplesRead barrier): the reset must wait for the parked mixer and leave [0, 0]; at the parent, with the barrier added, both checks fail (8 assertions, 2 failures). reset-during-decode: with a delivery parked mid-decode, the reset must wait for it while the audio mutex stays free; taking the audio mutex first fails that (9 assertions, 1 failure). With the fix the whole test passes, 117 assertions, 0 failures, plain, with ASan+UBSan and with TSan.
getBufferEndTimeLocked() summed the samples read and the samples available in the circular buffer. The mixer takes samples from the buffer before it adds them to the read count, and does so without the decode mutex, so a range read in between came out one block short. With 0.743 s delivered and the mixer parked between its read and its count, getPullBufferTimeRange() reported [0, 0.731] instead of [0, 0.743], 512 frames short. The end is now the samples written minus the decode backlog not yet in the buffer, the same value when the mixer is between blocks, and both change only under the decode mutex that this function holds. Verified with the new range scenario in pull_stream_race_test: the range must be [0, 0.743] between blocks and stay so while the mixer is parked at the pullSamplesRead barrier. At the parent the second check fails (7 assertions, 1 failure). With the fix the whole test passes, 124 assertions, 0 failures, plain, with ASan+UBSan and with TSan.
A BufferStream is used by a delivering thread (under sounds_mutex), by its background decode thread, which decodes what a compressed stream still holds once its data has ended, by play(), which runs its buffering check, and by the mixer. Several things they share had no common lock: - the buffering check reads and updates the sound's handle list, which a play appends to and a voice's end removes from under sounds_mutex, but the decode thread and play() ran it under check_buffer_mutex only; - the decode thread's check pauses and unpauses voices through the Player, which takes sounds_mutex, while a reset or a disposal holds that mutex and waits for the thread to stop; - the mixer, pausing a voice that ran out of data, read the same list to find the voice's handle for the buffering callback, and wrote its user-paused flag; - the sample and byte counters and the end flag were plain fields that a delivery and the decode thread update, the mixer updates for a RELEASED stream, and the length getters and hasEnded() read; - a compressed stream's first decode set the format the mixer reads under the buffer mutex without taking it. With the decode thread parked before a check that will unpause a voice and a reset waiting for it, the released thread blocked on sounds_mutex in Player::setPause() and the reset never returned. Under TSan, feeding streams in 30 ms bursts while other threads play, stop and read them reports 6 races: the mixer's handle list writes against a delivery's check, the RELEASED counters against a delivery and a reader, the received-bytes count against a reader, and the format against the mixer's read. checkBuffering() now runs holding sounds_mutex, taken before check_buffer_mutex as a delivery holds them, through a new private Player::runWithSoundsLock(). The decode thread only tries the mutex, and gives up once it is being stopped; a disposal marks the stream destroyed under the mutex, and the thread checks that again once it holds it. The mixer finds the voice it pauses in SoLoud's voice table, under the audio mutex it already holds, and leaves the handle list alone. The user-paused flag it cleared there can still be set on a playing voice: setPause() on a voice group finds no voice of a sound for the group's handle, so a voice paused by the user and unpaused through its group keeps it, and the check would never resume that voice once it runs dry. So the mixer marks the voice instead, and the next check clears the flag when it takes the mark; a pause or unpause by the user discards the mark, so a user pause made while the voice buffers still holds. The counters and the end flag are atomic, and the format is set under the buffer mutex. Verified with the new test/buffer_stream_race_test.cpp: decode-reset (new bufferStreamBackgroundDecoded barrier) deadlocks at the parent (9 assertions, 1 failure) and passes with the fix; stress reports the 6 races at the parent under TSan and none with the fix. group-unpause plays a voice unpaused through its group until it runs dry: leaving the flag alone, the next delivery never resumes it (12 assertions, 3 failures); without discarding the mark on a user pause, a delivery resumes the voice the user paused while it buffered (1 failure). With the fix: 40 assertions, 0 failures plain, with ASan+UBSan and with TSan.
resetBufferStream() empties an AUTO buffer stream so that a new source can be delivered, but a voice still playing it kept the format it had adopted from the first source: BufferStreamInstance::getAudio() reads the detected rate and channels only while samplerateAlreadySet is false, and nothing cleared it on a reset. When a voice did adopt a format, it set its mixing rate to the bare base rate, dropping its relative play speed. A 44.1 kHz source replaced by a 22.05 kHz one kept the 44100 Hz rate: a 1 kHz tone gave 233 zero crossings in 5120 engine frames instead of about 116, twice the pitch at half the duration. A voice set to 2x before its first chunk arrived played at 1x from the next mix block on: 128 crossings instead of about 233. A reset now advances a format generation on the stream. A voice remembers the generation it adopted its format at and re-reads the format when that changed, so seeks and loop restarts keep it; a voice snapshot keeps the generation, so restoring one taken before a reset still re-reads it. Adopting a format sets the mixing rate to the base rate times the voice's overall relative play speed, as updateVoiceRelativePlaySpeed_internal() does. Every voice now reads the detected format after a reset, so the reset clears it, and advances the generation, under SoLoud's audio mutex and then the buffer mutex, the order the mixer holds them in; nothing takes the audio mutex while it holds the buffer mutex. A new voice reads the generation and the format under the buffer mutex. The background decode thread is still joined before either lock is taken. Verified with the new test/buffer_stream_format_test.cpp, which mixes the engine by hand. At the parent: 39 assertions, 5 failures (reset: rate 44100 and 233 crossings; play-before-data: 128 crossings; reset-mixing, 50 resets while another thread mixes: rate 44100 and 232 crossings). With the fix: 0 failures plain, with ASan+UBSan and with TSan; speed-seek passes in both. Without the reset's locks, reset-mixing gets 2 TSan reports: the mixer's reads of the generation and of the rate against the reset's writes.
A stream turns each AudioMetadata it reports into the struct Dart reads (convertMetadataToFFI() in audiobuffer.cpp and pull_buffer_stream.cpp): it maps the type with a switch and copies the Vorbis, Opus and FLAC fields, whatever the format. AudioMetadata left the type and those fields uninitialized, so a default-constructed one held whatever its storage did. The decoders set the type but fill only their own format's fields, so an MP3, AAC or WAV stream handed Dart indeterminate Ogg fields; and soloudTestInvokeStreamCallbacks(), the test export that dispatches a stream's callbacks, passes one as it is. Built with UBSan, engine_lifecycle_test reports a load of value 1040047929, "not a valid value for type 'DetectedType'", at the switch in PullBufferStream::convertMetadataToFFI(). The type now defaults to BUFFER_UNKNOWN, which the streams convert to UNKNOWN, and the three Ogg info structs are value-initialized. Verified with the new metadata-defaults scenario in test/metadata_parsers_test.cpp, which builds an AudioMetadata over bytes of 0xa5. Without the fix it fails all 4 checks (type 2779096485, and every Ogg field left at 0xa5 bytes), and UBSan reports the invalid enum load. With the fix the suite passes plain and under ASan+UBSan (137 assertions) and in the wasm32 build (70), and engine_lifecycle_test under ASan+UBSan reports nothing (173 assertions, 0 failures; run with ASan's container-overflow check off, for an unrelated report in Player::disposeSound()).
The web binding tests replace Module_soloud with a stand-in whose HEAPU8 was a Dart Uint8List converted with .toJS. On dart2js that is a cast, so the JS side and the Dart side share one buffer. On dart2wasm a Dart typed list lives in Wasm GC memory and .toJS clones it, so the stand-in's JS exports wrote to a copy that the Dart getValue and heap never saw, and Dart's writes never reached the HEAPU8 the bindings read. Handles, packet bytes and string arguments all read back as zeros. The heaps are now JS Uint8Arrays, as on the real module, and the tests read and write them through a ByteData view of that array, which shares its memory on both compilers. The bindings are unchanged: against the real module HEAPU8, getValue and the exports all use the one JS heap.
|
Hi Colton, This is a huge and awesome piece of work. Thank you so much for putting in the effort! I've successfully run the tests on both Windows and Linux, and everything looks good. I think we're good to merge this! Regarding your "Not addressed here" section, would you like some more time to work on those items before merging in the main branch, or are you happy to leave them for a future PR? Thanks again for the great work! |
It's no problem; I enjoy doing it. I run Opus 5.5 and Sol-6.1 to find, prove, and work together to fix issues. Then I do a round of extended stress testing on a number of devices to prove stability. A few manual reviews to ensure no regressions were introduced. One day, we will have 100% bug free software.
I will introduce them in a future PR. The |
hoping the same! :) This plugin is going to be very complete, I was thinking this is the latest huge milestone I dreamed since the beginning, but... who know! Anyway in few days I'll merge rtAudioMixer and update on pub.dev. Than we will have all the time to make fixes or little additions. Thanks again! |
Description
This is a second hardening pass on
rtAudioMixer, after #600. It comes from an audit of the branch, area by area, and every fix has a test or a repro that fails before it and passes after.The branch sits on top of the current
rtAudioMixer(9c371686, the head of #593) and merges without conflicts. Each commit message explains its fix in detail; the summary below is grouped by area.Render-ahead ring and mixer core
getOutputLatencyread the ring depth without the lock; the worst reading was 27 hours. It reads a coherent depth now.play()while the device reads no longer leaves the voice silent (UBSan: "-8 is outside the range").disposeSound. It stopped each voice while walking the sound's own handle list, and the synchronous voice-ended callback erased from that list (ASan container-overflow). It walks a copy now.Filters and synthesis
fast_log2andfast_exp2didn't build without SIMD (DISABLE_SIMD: 30 of 76 translation units failed). The scalar code now builds and matches SSE2.filterNotFound, as voice and bus filters already did.playOnEngine(). The bus's handle was uninitialized, so setting a bus filter parameter could change the global filter in the same slot, or index past the voice table (UBSan index 3773 of 1024). Those calls now returnsoundHandleNotFound.disposeAllSources(). It detached the global filters from the engine but kept them in the player's list, so they could not be added again (error 15). It forgets them now; the doc and the compressor and limiter examples are updated.getFilterParamNamesnames go into an array that fits them, via the newgetFilterParamNamesBounded.readSamplescopies samples out of the heap before freeing them (NaN before).Loading and disk streams
loadMemownership.loadMemkept the caller's buffer and freed the FFI copy twice (double-free abort, ASan use-after-free, +384 MB Dart RSS). The caller keeps its buffer and the copy is freed once.fileNotFound.play().DR_FLAC_NO_CRCremoved, dr_flac uses its binary-search seek. Seeks go from 42–83 ms to 0.03–0.06 ms, sample-exact against a linear decode.Buffer and pull streams
resetPullBufferStreamtakes the decode mutex first.getPosition()was blocked up to 120 ms; now at most 0.5 ms.sounds_mutex. The lock was held 107–140 ms; now about 0.Mixer capture (
MixerOutput)MixerOutput::startfreed the ring). This uses the new exportstakeMixerOutputPacketandgetMixerCaptureId.stopMixerCaptureAndDrain.Realtime streams and VST3
DLL_PROCESS_DETACH.createCustomAudioSource(singleInstance:).mkstempand mode 0600, and the helper is reaped after it closes its window.Platform and bindings
AudioTrack::restoreTrack_l. Streams are now stopped and the code waits a burst before closing them, as Oboe does.listPlaybackDevices()before init takes 92–127 ms instead of about 35 ms.listPlaybackDevicesis bounded to the caller's arrays (ASan heap write overflow with 60 devices), with the newlistPlaybackDevicesBounded.speechTextstring is freed (+193 MB over 12 calls before).getHardwareOutputLatencybroke builds that leave a backend out. These are the first two commits:miniaudio_getHardwareOutputLatency()outsideWITH_MINIAUDIO, so everyWITH_NULLhost test (render_ring,checkpoint,retroactive_remixand the others) failed to link. Without miniaudio it now returns 0.MA_SUPPORT_COREAUDIO, so a build without the CoreAudio backend failed with 15 errors. It is now underMA_HAS_COREAUDIO, as the WASAPI branch is underMA_HAS_WASAPI.Validation
dart analyze --fatal-infos --fatal-warnings libis clean.dart format -o none --set-exit-if-changed .is clean, withpub getrun in the custom_dsp example as CI does.flutter pub publish --dry-rungives 0 warnings.--wasm, a debug APK, and macOS with VST3.Not addressed here
web/*.wasmand*.jsare not rebuilt in this PR. The C++ fixes reach the web after a rebuild. The new exports (takeMixerOutputPacket,getMixerCaptureId,stopMixerCaptureAndDrain,getFilterParamNamesBounded,listPlaybackDevicesBounded) are feature-detected, so the current wasm keeps working.play()and the filter exports use a sound looked up by hash aftersounds_mutexis released, so a concurrentdisposeSoundcan free it.createBus/destroyBuschange the bus map without a lock whileplay()reads it.playerwithout thePlayerLeasepin.disposeSound/disposeAllSoundwait on the lock thatstart/stopAudioDeviceandchangeDevicehold for the whole device call.play()reading its format (TSan, pre-existing).getHardwareOutputLatencyon Android (new in this base; only its build guards change here). It callsAAudioStream_getTimestampongDevice.aaudio.pStreamPlaybackwithout takingrerouteLockorgAAudioStreamGate. A reroute on miniaudio's job thread (ma_device_reinit__aaudiocloses the streams first) can close that stream while the call runs.setBusArrangements' result is ignored (reject or adapt non-stereo plugins).discardPacket()doesn't re-arm the notification; that only matters with no live consumer.noErrorand 1.0.fast_log2doc claims an error under 2e-5; the real error is about 2.2e-4.latestwith no checksums. Pinning to the package version with a SHA-256 manifest is the maintainer's call.Type of Change