Skip to content

Refactor/reuse buffers - #1283

Open
mdydek wants to merge 4 commits into
mainfrom
refactor/reuse-buffers
Open

mdydek wants to merge 4 commits into
mainfrom
refactor/reuse-buffers

Conversation

@mdydek

@mdydek mdydek commented Sep 11, 2026

Copy link
Copy Markdown
Member

Direct continuation of the work from #1281, different mental model, still addresses #1263

Introduced changes

Screens from https://github.com/WentTheFox/AudioApiLeakRepro
before:
image

after:
image

  • to best understand new approach we will split the buffer lifetime to 3 parts: before setting him in absn, after setting but before starting, after start
  1. before setting: audiobuffer is a one place both for the js and native, each calls and data is shared between
  2. after setting: each getChannelData gets you the copy of the new data, which could affect the absn, but only after the start we will respect possible changes
  3. after start: acquire the content, invalidate older buffers so the js cannot change the data played in between and the node is repointed to the possibly changed buffers

by 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

  • Linked relevant issue
  • Updated relevant documentation
  • Added/Conducted relevant tests
  • Performed self-review of the code
  • Updated Web Audio API coverage
  • Added support for web
  • Updated old arch android spec file

WentTheFox and others added 3 commits September 10, 2026 13:06
…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
@mdydek mdydek added the refactor Code restructuring without altering external behavior or fixing bugs label Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

WPT non-regression comparison

PASS — no regressions · 1 improved section(s) · overall 2665 → 2667 (+2)

Spec section Base pass Head pass Delta
AudioBuffer 140 142 +2
Unchanged sections (27)
Spec section Base pass Head pass Delta
Processing model 0 0 0
Other 52 52 0
AnalyserNode 138 138 0
AudioBufferSourceNode 208 208 0
AudioContext 58 58 0
AudioNode 261 261 0
AudioParam 599 599 0
BiquadFilterNode 275 275 0
ChannelMergerNode 30 30 0
ChannelSplitterNode 7 7 0
ConstantSourceNode 59 59 0
ConvolverNode 203 203 0
DelayNode 104 104 0
DestinationNode 0 0 0
DynamicsCompressorNode 4 4 0
GainNode 15 15 0
IIRFilterNode 87 87 0
MediaElementAudioSourceNode 0 0 0
MediaStreamAudioDestinationNode 1 1 0
MediaStreamAudioSourceNode 0 0 0
OfflineAudioContext 35 35 0
OscillatorNode 104 104 0
PannerNode 75 75 0
PeriodicWave 33 33 0
ScriptProcessorNode 0 0 0
StereoPannerNode 102 102 0
WaveShaperNode 75 75 0

Baseline: 4c63814364f9d4428194d2538044bb28f942c0d2 · Candidate: 017dcf8bc867bc7368a2602f94fa6d7471962dc4

Workflow run · this comment is updated on every push.

@mdydek
mdydek marked this pull request as draft September 11, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor Code restructuring without altering external behavior or fixing bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants