Skip to content

fix: rtAudioMixer hardening, round 2 (mixer core, filters, streams, capture, VST3, platform) - #612

Merged
alnitak merged 93 commits into
alnitak:rtAudioMixerfrom
Colton127:fix/rtAudioMixer-hardening-2
Nov 11, 2026
Merged

alnitak merged 93 commits into
alnitak:rtAudioMixerfrom
Colton127:fix/rtAudioMixer-hardening-2

Conversation

@Colton127

Copy link
Copy Markdown
Contributor

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

  • Ring writers. The device thread's top-up and the render worker checked the ring's free space before taking the audio mutex. When both appended a quantum, the second was silently truncated: an audible skip, and a mix clock ahead of the ring for the rest of the session. Both re-check under the mutex now, and the mixer refuses a quantum that doesn't fit. (bufferSize 2048, devicePeriod 256, renderAhead 512: 6 of 10 trials truncated before, 0 after.)
  • Callbacks off the device thread. On the ring path the device callback's top-up ran the voice-ended and voice-inactive callbacks, which wait on Player locks; device reads took up to 35 ms against a 5.8 ms period. They now run on the render worker or at the next API unlock (longest read 2.9 ms, the same as with no sounds).
  • Device callbacks longer than the ring. A device callback asking for more frames than the ring holds was served with the same frames repeated (8192 of 16384 frames from the ring, 14332 sawtooth jumps). It is now served in parts with top-ups between them.
  • Output latency. getOutputLatency read the ring depth without the lock; the worst reading was 27 hours. It reads a coherent depth now.
  • Group fades. A fade on a voice group started every voice from the first voice's value (an 8 dB step in the test case). Each voice starts from its own value now, on the direct and render-ahead paths.
  • Retroactive fades and speed.
    • A retroactive fade starts from the voice's value at the playhead.
    • A fade to a value the voice has only at the write head now fades.
    • A retroactive play() while the device reads no longer leaves the voice silent (UBSan: "-8 is outside the range").
    • A speed change at the playhead no longer skips up to 1023 source frames. It lands at the next quantum boundary instead (up to 23 ms at 1024 frames), which is the trade-off.
  • Catmull-Rom tail. A one-frame mix at a voice's end stopped a Catmull-Rom voice before its last sample was output.
  • 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

  • Fourier lookups. Square and saw lookups read past their tables at some frequencies (SIGSEGV, UBSan index 4097).
  • Scalar fallback. fast_log2 and fast_exp2 didn't build without SIMD (DISABLE_SIMD: 30 of 76 translation units failed). The scalar code now builds and matches SSE2.
  • Waveforms.
    • Scale and detune were passed in swapped order.
    • Parameters are now published to the mixer atomically (TSan: 4 races before, clean after).
    • A negative frequency plays the waveform backwards, where it used to drift out of range or hold at DC.
    • A superwave harmonic above one period per sample now wraps.
  • Filter getters.
    • A getter resolves its voice handle under the audio mutex (3 TSan races).
    • A parameter of 0 reads back.
    • A real parameter of 9999 is no longer taken for "filter not found".
    • A global filter that is not active throws filterNotFound, as voice and bus filters already did.
  • Bus filters before 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 return soundHandleNotFound.
  • 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.
  • Flanger. Stock SoLoud advanced the LFO once per channel per frame. Stereo before: a 10 Hz LFO ran at 20.03 Hz, right led left by 9.6–76.8°, and the delay jumped 20–149 samples at block starts. After: 10.01 Hz, both channels equal, no jumps. Mono is unchanged. Stereo users hear half the sweep rate they had (in the CHANGELOG).
  • Delay lines and compressor.
    • A delay line restored from a snapshot that had none is emptied.
    • The compressor's envelope starts at silence (it was -4 dB for the first 3644 samples).
  • Buffers across the bindings.
    • getFilterParamNames names go into an array that fits them, via the new getFilterParamNamesBounded.
    • The FFI waveform readers free their native buffers (+320 MiB over the test before).
    • The web readSamples copies samples out of the heap before freeing them (NaN before).

Loading and disk streams

  • loadMem ownership. loadMem kept 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.
  • Missing files.
    • A disk-streamed voice whose file is gone ends (144 failures and a SIGSEGV before).
    • Playing a disk-streamed sound whose file is gone reports fileNotFound.
    • A WAV, MP3 or FLAC disk voice whose file is truncated mid-play ends instead of playing forever.
  • Chained Ogg.
    • Every Opus link decodes at the stream's channel count (SIGFPE before).
    • A Vorbis link reads only the channels it has (ASan heap overflow before), and is downmixed like Opus.
    • The Opus length counts every link (a 3 s chain reported 1 s), with saturation and an "unknown" result for links without a usable granule.
    • The length scan runs once per sound, not once per play().
  • FLAC seeks. With DR_FLAC_NO_CRC removed, 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.
  • M4A gapless. M4A gapless info is kept when bytes follow the last box, and a file cut just past a box boundary is no longer taken as complete.
  • Metadata strings. Metadata is decoded as UTF-8 on FFI and web, so "Café" no longer reads as "Caf".

Buffer and pull streams

  • Pull-stream locking.
    • The buffered range stays stable while the mixer reads.
    • A reset stays out of the mixer's read accounting.
    • The decode state is guarded by its decode mutex (ASan use-after-free, TSan races, and SIGBUS under stress before).
  • Mixer stalls.
    • The buffering check runs off the audio thread (a mixer-seek/deliver-seek deadlock before).
    • Seeks and rewinds no longer block the mixer on a running decode. They are applied at the next block; the mixer was blocked 62–124 ms during a delivery before, and now 0.
    • resetPullBufferStream takes the decode mutex first. getPosition() was blocked up to 120 ms; now at most 0.5 ms.
  • Pull-stream lifetime.
    • A pull stream is kept alive while data is delivered to it, and the decode runs outside sounds_mutex. The lock was held 107–140 ms; now about 0.
    • A fully delivered pull stream rewinds and loops.
  • Buffer-stream races. A buffer stream's threads no longer race on its handles and counters (a decode-reset deadlock and 6 TSan races before). A voice paused for buffering resumes after a group unpause.
  • AUTO streams. An AUTO stream re-reads its format after a reset and keeps its speed (it played a 22.05 kHz stream at 44.1 kHz after a reset before).
  • Seeking.
    • MP3 seeks past the seek table map by duration, also after EOF.
    • The MP3 seek map survives pull-stream seeks.
    • Ogg seek-index offsets stay absolute.
  • Parsers.
    • An AAC stream shorter than the prebuffer decodes (0 samples before).
    • ID3 Latin-1 and UTF-16 text is converted to UTF-8.
    • A FLAC header split across deliveries is cached.
    • Metadata lengths are checked without 32-bit overflow (traps on wasm32 before).
    • M4A metadata recursion is bounded (stack overflow before).
    • Stream metadata starts zeroed with an unknown type (UBSan invalid enum before).

Mixer capture (MixerOutput)

  • Chunks. Every buffered fixed-size chunk is emitted, and the ring keeps its full state and bounds large writes (ASan write overflow before).
  • Formats. A capture format the engine does not produce is refused (ASan read of 65536 past 16384 bytes on the CoreAudio thread before).
  • Opus. Opus captures write RFC 7845 granule positions and pre-skip, and carry the right frame across resampler blocks (errors up to 2.0 before).
  • Owned packets. Listeners get owned packets instead of pointers into the ring (ASan use-after-free when MixerOutput::start freed the ring). This uses the new exports takeMixerOutputPacket and getMixerCaptureId.
  • Isolates.
    • Packets go to the starting isolate's port (VM abort "Callback invoked after it has been deleted" before).
    • An isolate stops only the capture it started.
  • Stopping.
    • A stopped capture delivers its queued chunks and tail intact, through the new stopMixerCaptureAndDrain.
    • Deinit ends the output stream.
    • A stream listener can stop the stream.
  • Shipped wasm. The web side detects the new exports and falls back to the old path with the wasm that ships today.

Realtime streams and VST3

  • Fast voices. Re-mixing a fast realtime voice no longer replays other audio (24 re-mixed windows replayed other audio before).
  • Scheduled pushes. These are accepted after 256 gaps, and a reset marker survives pruning (256 of 400 accepted before).
  • Underruns. A realtime voice's filters stay on time across an underrun (an echo 513 frames early before).
  • Parameter changes. Every VST3 parameter change reaches the processor (128 of 200 before).
  • Failed blocks. An instrument that fails a block outputs silence, not stale audio.
  • Editor lifetime.
    • An editor window stays alive while the audio thread uses it (TSan races and an invalid vptr before).
    • Editor memory is unmapped only after the audio thread is out (TSan race).
    • The exit-time unload waits for the mixer. On Windows it skips the barrier, because other threads are already gone at DLL_PROCESS_DETACH.
  • Sample rate. A VST3 effect is set up at the engine's sample rate by default (48000 before).
  • Shared instruments. A VST3 instrument played again renders once per mix (2 voices and 96 process calls per 500 ms before, 1 and 47 after), via createCustomAudioSource(singleInstance:).
  • Linux editor. The editor's shared memory file is created with mkstemp and mode 0600, and the helper is reaped after it closes its window.

Platform and bindings

  • Android 11 AAudio. A route change could reach an AAudio stream that was already closed, crashing in AudioTrack::restoreTrack_l. Streams are now stopped and the code waits a burst before closing them, as Oboe does.
    • Android 11 emulator, close-race test at 10 threads: the base crashed 2 of 2 times, at about 10 s and 90 s. The tip ran 180 s (10,099 closes) and 120 s at 6 threads (9,864 closes, 3,095 reroutes) clean.
    • Cost on API 30: a device uninit takes 23.3 ms instead of 0.9 ms. listPlaybackDevices() before init takes 92–127 ms instead of about 35 ms.
  • Android teardown. The native library is loaded at teardown only when Dart has loaded it, so there is no main-thread load of a library Dart never opened.
  • Device lists.
    • listPlaybackDevices is bounded to the caller's arrays (ASan heap write overflow with 60 devices), with the new listPlaybackDevicesBounded.
    • Devices with short names ("DAC", "HDMI") are kept.
  • Web.
    • Strings are passed to the exports as NUL-terminated UTF-8.
    • Voice handles are read as unsigned (handles with bit 31 set came back negative).
  • FFI. The speechText string is freed (+193 MB over 12 calls before).
  • Prebuilds.
    • Prebuilt archives are installed whole or not at all, safe against two concurrent builds and with mode 0755.
    • A stalled download in the HttpClient fallback times out.
  • Builds without a backend. The base's new getHardwareOutputLatency broke builds that leave a backend out. These are the first two commits:
    • The core called miniaudio_getHardwareOutputLatency() outside WITH_MINIAUDIO, so every WITH_NULL host test (render_ring, checkpoint, retroactive_remix and the others) failed to link. Without miniaudio it now returns 0.
    • The CoreAudio branch was under MA_SUPPORT_COREAUDIO, so a build without the CoreAudio backend failed with 15 errors. It is now under MA_HAS_COREAUDIO, as the WASAPI branch is under MA_HAS_WASAPI.
    • The plugin builds the same code as before.

Validation

  • Native suites (macOS). All host test scripts pass on this branch, with TSan and ASan+UBSan variants where the runners have them and no sanitizer reports: 76 runs (39 suites plain, 15 under TSan, 22 under ASan+UBSan), VST3 host test included. For the core changes, a byte-for-byte mix comparison over 60 scenes matched the base.
  • Web. The changed C++ compiles with em++ for the ST, MT and no-Xiph builds.
  • Dart.
    • dart analyze --fatal-infos --fatal-warnings lib is clean.
    • dart format -o none --set-exit-if-changed . is clean, with pub get run in the custom_dsp example as CI does.
    • flutter pub publish --dry-run gives 0 warnings.
  • Flutter tests. 122 VM tests pass. The 36 browser tests pass under both dart2js and dart2wasm.
  • Builds. The example builds for web, web --wasm, a debug APK, and macOS with VST3.
  • Android. The AAudio close race was run on an Android 11 x86 emulator, as above.

Not addressed here

  • Wasm rebuild. The shipped web/*.wasm and *.js are 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.
  • Player lifetime and locking (found by reading, not reproduced). These need calls from more than one thread at once, such as a worker isolate and the main isolate. They touch most exports and the Player lock paths, so they're left for a focused follow-up:
    • play() and the filter exports use a sound looked up by hash after sounds_mutex is released, so a concurrent disposeSound can free it.
    • createBus/destroyBus change the bus map without a lock while play() reads it.
    • Most exports read player without the PlayerLease pin.
    • On the direct mix path, the voice-ended dispatch and the interruption check take blocking locks on the mixing thread. The ring path already moved those callbacks off the device thread.
    • On the web, a stale session's voice-ended event can remove the new session's handle with the same number.
    • disposeSound/disposeAllSound wait on the lock that start/stopAudioDevice and changeDevice hold for the whole device call.
    • The first decode of a pull or buffer stream races play() reading its format (TSan, pre-existing).
  • getHardwareOutputLatency on Android (new in this base; only its build guards change here). It calls AAudioStream_getTimestamp on gDevice.aaudio.pStreamPlayback without taking rerouteLock or gAAudioStreamGate. A reroute on miniaudio's job thread (ma_device_reinit__aaudio closes the streams first) can close that stream while the call runs.
  • Seeks under the audio mutex. A seek or loop restart on an MP3 or Opus disk stream decodes inside the audio mutex. A 20-minute MP3 seek near the end holds it about 485 ms, and the worst looping mix call is 507 ms. FLAC is fixed here. The vendored dr_mp3 0.7.4 seek table isn't sample-exact (47–1199 frames off), and Opus needs granule bisection.
  • Command queue. Control calls wait on the mix. A command queue applied at the next block is a design change.
  • Stall detection. There is no report of a device that keeps saying "started" but stops calling back.
  • Realtime and VST3 follow-ups.
    • setBusArrangements' result is ignored (reject or adapt non-stereo plugins).
    • Linux uses one X11 window per instance.
    • RT-1 refuses re-mixes it could do at 1.5x speed and above on a partly filled ring (it falls back to going-forward).
    • More than 256 gaps in one window are accepted but bunched.
  • Capture edges (low).
    • A threshold-packet duplicate window, which shipped Dart can't reach.
    • discardPacket() doesn't re-arm the notification; that only matters with no live consumer.
  • Pull streams (low). A looping pull stream with a slow feeder requests offset 0 on every underrun block, and stale offset-0 deliveries are appended.
  • Smaller DSP items.
    • A stopped bus keeps its old handle, so a get returns noError and 1.0.
    • The flanger glitches for one frame when an LFO peak lands on a block start.
    • The fast_log2 doc claims an error under 2e-5; the real error is about 2.2e-4.
  • Speed and fades. A speed set followed by a fade within one quantum on the render-ahead path loses the set; the later one wins.
  • Prebuild pinning. The prebuild tag defaults to latest with no checksums. Pinning to the package version with a SHA-256 manifest is the maintainer's call.
  • Untested platforms.
    • The Linux and Windows VST3 editor changes were syntax-checked against macOS headers, or read, but not run.
    • The Android teardown fix ran in JVM and Dart tests, not on a device.

Type of Change

  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • 📝 Documentation

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.
@alnitak

alnitak commented Nov 11, 2026

Copy link
Copy Markdown
Owner

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!

@alnitak
alnitak merged commit e311cfa into alnitak:rtAudioMixer Nov 11, 2026
@Colton127

Copy link
Copy Markdown
Contributor Author

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.

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?

I will introduce them in a future PR. The getHardwareOutputLatency fix specifically needs to be merged before the next release. The Player lifetime and locking issues could not be reproduced, they exist in the codebase but appear unreachable in Flutter/Dart's single threaded isolate.

@alnitak

alnitak commented Nov 11, 2026

Copy link
Copy Markdown
Owner

One day, we will have 100% bug free software.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants