Repository navigation
Conversation
…y setBuffer AudioBufferSourceNodeHostObject::setBuffer() deep-copied the entire AudioBuffer on every `.buffer = x` reassignment, even when reassigning the exact same underlying buffer. That's exactly what seeking is. A live AudioBufferSourceNode can't be repositioned, so the standard pattern is to stop/disconnect it and create a fresh source node wrapping the already-decoded buffer at a new offset. Every seek therefore triggered a full, unnecessary copy of the whole track's PCM data. On a 4-minute stereo buffer this is a real full-size copy (~85MB) per seek. The old copy isn't freed until its replacement's scheduled audio event actually runs, so repeated rapid seeking accumulates full-size buffers faster than the audio thread can free the previous ones. The growing native heap eventually crashes the app via a Hermes GC OOM once it can no longer find room to grow the JS heap. This caches the defensive copy on the JS-visible AudioBufferHostObject and reuses it across repeated reassignments of the same buffer. The cache is invalidated only when the buffer's data could actually have been mutated: copyToChannel, or a live getChannelData() view having escaped to JS, since JS could write through that view at any later time. This keeps the exact copy-on-first-touch semantics the original code needed for pitch-correction and mutation safety, while making the "same buffer, many source nodes" pattern free after the first copy instead of paying for a fresh copy on every single reassignment. The caching logic lives in a new, standalone ImmutableBufferCache utility rather than directly in AudioBufferHostObject, because HostObjects/*.cpp is excluded from this project's C++ test suite (see common/cpp/test/CMakeLists.txt) and a plain utility class can be unit tested without a jsi::Runtime. Also documents this in the best-practices guide. The existing "reuse the same AudioBuffer across nodes" guidance was already correct, but silently expensive before this fix; it is now actually cheap as advertised. Verified: - The library's own C++ test suite still passes in full, plus 4 new unit tests for ImmutableBufferCache covering reuse, distinct-copy identity, and both invalidation paths (420/420 total). - A minimal repro app (creating a fresh AudioBufferSourceNode/GainNode pair wrapping the same AudioBuffer every ~150ms, simulating rapid seeking) went from ~71MB leaked per seek (visibly crashing within ~60 iterations) to no measurable per-seek growth across three consecutive 60-iteration runs, measured via `adb shell dumpsys meminfo` before/after/+30s-settled. - A real app using this pattern for playback seeking held native heap flat (Android, Samsung Galaxy S24+) across ~200 rapid seeks that previously crashed within a similar span. Fixes #1263
WPT non-regression comparisonPASS — no regressions · 1 improved section(s) · overall 3427 → 3429 (+2)
Unchanged sections (27)
Baseline: Workflow run · this comment is updated on every push. |
| void AudioBufferSourceNode::setBuffer( | ||
| const std::shared_ptr<AudioBuffer> &buffer, | ||
| const std::shared_ptr<DSPAudioBuffer> &audioBuffer) { | ||
| if (!swapBuffers(buffer, audioBuffer)) { | ||
| return; | ||
| } | ||
|
|
||
| loopEnd_ = buffer_ == nullptr ? 0 : buffer_->getDuration(); | ||
| } | ||
|
|
||
| void AudioBufferSourceNode::replaceBufferContent( | ||
| const std::shared_ptr<AudioBuffer> &buffer, | ||
| const std::shared_ptr<DSPAudioBuffer> &audioBuffer) { | ||
| swapBuffers(buffer, audioBuffer); | ||
| } |
There was a problem hiding this comment.
What's the actual difference between setBuffer() and replaceBufferContent()? Why doesn't the latter update channelCount_ and loopEnd_? Is it safe in general? If not, should this method be public?
There was a problem hiding this comment.
update of loopEnd_ is needed because with new buffer stale old value would be in the incorrect state. Whereas, raw replaceBufferContent function was needed, because if we returned channelData to JS, during acquiring content we copy only the buffer content. But right now looking at it, I think it is completely safe to also update loopEnd_ in the same function, which would leave us with only one function setBuffer that does everything
There was a problem hiding this comment.
This host object seems to have gained new responsibilities:
- Preparation of buffers.
- Operation of "acquiring the buffer".
- Probably more...
I see that all those responsibilities at the first glance depend on host objects and JS runtime, but my intuition is that they shouldn't. I don't think that host objects should do the business logic.
There was a problem hiding this comment.
Similarly to AudioBufferSourceNodeHostObject, this host object seems to gained too many responsibilities too. Can't they be moved downwards? In particular, host object shouldn't:
- Copy-on-write, handle versioning, etc.
|
I ran the reproduction from #1340 against this branch, in case the numbers are useful while it is still in review. Tested: PR head 51c8f02 against its base on main (ba0d65b), plus the 0.13.6 release as a reference. iOS 27 simulator, debug build, Hermes. Input is 131 s of stereo 48 kHz audio, raw float32 PCM 47.97 MiB. Each variant was launched twice and gave identical
Process RSS, MB:
So the real saving is large: RSS in the loop drops to about a third, and the decode is now reported at its actual size. One thing that still stands out: every For a player that creates a new source on every play or seek, Hermes would still see that figure grow per source until the nodes are collected, even with flat real memory. After dropping all references 96.3 MiB (2.0x) remained reported, unchanged 5 s later.
Repro: https://github.com/VikalpP/audio-api-external-memory-repro |
Direct continuation of the work from #1281, different mental model, still addresses #1263
Introduced changes
Screens from https://github.com/WentTheFox/AudioApiLeakRepro

before:
after:

getChannelDatagets you the copy of the new data, which could affect the absn, but only after the start we will respect possible changesby implementing this approach usual use case of the audiobuffersourcenode and creating new node with already present buffer allocates only memory for the metadata of the node
Checklist