Repository navigation
Conversation
ObrImpl::Process() allocated and freed heap memory on every call (#28): a temporary AudioBuffer for each processing group's output, the two vectors PeakLimiter::Process() filled for the block, and, with loudspeaker layout or object elements, Eigen temporaries in AmbisonicEncoder::ProcessPlanarAudioData() for every output frame (the product assigned to a frame built a temporary vector, and with a moving source each frame also built a new encoding matrix and an interpolated column per source), the spherical harmonic and Legendre vectors of each source, and the source index lists of each processing group. Process() runs on the render thread of a live host. - AmbisonicEncoder keeps its working storage as members sized in the constructor (the start and target column of each source, the per-frame encoding matrix and interpolated column, the spherical harmonic coefficients and the Legendre values) and writes the per-frame products with noalias(). Its documentation states that the output buffer must not be the input buffer, which every caller already ensures. The block's static encoding matrix is built in encoding_matrix_, which held a copy of it before. - AssociatedLegendrePolynomialsGenerator::Generate(x, &values_out) writes into a caller's vector; the returning overload calls it. - ProcessingGroup fills its world-locked and head-locked source index lists into members whose capacity UpdateAmbisonicEncoder() reserves, and UpdateAmbisonicEncoder() counts the sources instead of building their index list. - ObrImpl keeps the per-group output buffer as a member, allocated in the constructor. - PeakLimiter::Process() makes one pass per frame (the maximum sample across channels, the envelope, the gain applied to every channel) instead of filling two vectors. The arithmetic and its order are unchanged, and each frame reads its input before writing its output, so ObrImpl's in-place call gives the same result. Output is bit-identical to main for the scenarios in the pull request.
obr_process() copied the planar input into a vector of vectors, then into a new input AudioBuffer, built a new output AudioBuffer, called Process() and copied the output back, so every call through the C API allocated and freed several buffers (#28). It also took ObrImpl's mutex twice per call: once to read the input channel count, and again in Process(). ObrImpl::ProcessPlanar(input, output, num_frames) copies flat planar arrays into an input buffer ObrImpl keeps, renders it as Process() does and copies the two binaural channels out, under mutex_ taken once. AddAudioElement() and RemoveLastAudioElement() size the input buffer in the same critical section that changes the element list, and the output buffer is sized in the constructor, so ProcessPlanar() checks that the channel counts match instead of resizing. Process() checks its arguments, takes the lock and runs the shared body, ProcessLocked(), so its callers are unchanged. obr_process() calls ProcessPlanar() and keeps no buffers of its own. ObrImplTest.TestProcessPlanarMatchesProcess renders the same input through Process() on one renderer and ProcessPlanar() on another with the same elements, and requires identical output with one element, after a second is added and after it is removed.
trsonic
requested review from
felicialim,
jingbo-marquis,
trevorknight,
yeroro and
yilun-zhangs
October 13, 2026 14:23
trevorknight
removed their request for review
October 13, 2026 17:50
yeroro
approved these changes
Oct 14, 2026
| planar.ProcessPlanar(input.data(), output.data(), kNumFrames); | ||
| for (size_t ch = 0; ch < 2; ++ch) { | ||
| for (size_t frame = 0; frame < kNumFrames; ++frame) { | ||
| ASSERT_EQ(output[ch * kNumFrames + frame], output_buffer[ch][frame]) |
Contributor
There was a problem hiding this comment.
Nit: This line should be EXPECT_EQ.
Collaborator
Author
There was a problem hiding this comment.
Changed in 387f63f: each block's channel is now compared with one EXPECT_THAT(actual, Pointwise(Eq(), expected)). A plain EXPECT_EQ per sample would log up to 1536 failures on a mismatch, and EXPECT_EQ on the two vectors prints only their first 32 elements; Pointwise reports the index and both values of the first differing sample. The ASSERT_EQ on the channel count at the top of the lambda stays, because the input buffers are sized from it.
Collaborator
Author
There was a problem hiding this comment.
Does this look fine?
yilun-zhangs
approved these changes
Oct 15, 2026
jingbo-marquis
approved these changes
Oct 15, 2026
TestProcessPlanarMatchesProcess compared the output of ProcessPlanar() with the output of Process() with one ASSERT_EQ per sample. It now compares each channel of each block with one EXPECT_THAT(actual, Pointwise(Eq(), expected)), a non-fatal check like the rest of the file uses. A mismatch is reported once per channel, with the index and both values of the first differing sample, and the test goes on to the next channel and block. The ASSERT_EQ on the input channel count stays, because the input buffers are sized from it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ObrImpl::Process()andobr_process()allocate and free heap memory on every call (#28). Both run on a host's render thread. After this change neither allocates once the renderer is configured, and the output is bit-identical tomain.Process OBR audio without allocating
#28 measured 4
mallocand 4freeperProcess()call with one 3OA element: the per-group outputAudioBufferandPeakLimiter::Process(). With loudspeaker layout or object elements there are more, inAmbisonicEncoder::ProcessPlanarAudioData(): the product assigned to each output frame builds a temporary Eigen vector, and with a moving source each frame also builds a new encoding matrix and an interpolated column per source, plus the spherical harmonic and Legendre vectors of each source and the source index lists of each processing group.AmbisonicEncoderkeeps its working storage as members sized in the constructor (the start and target column of each source, the per-frame encoding matrix and interpolated column, the spherical harmonic coefficients and the Legendre values) and writes the per-frame products withnoalias(). Its documentation states that the output buffer must not be the input buffer, which every caller already ensures. The block's static encoding matrix is built inencoding_matrix_, which held a copy of it before.AssociatedLegendrePolynomialsGenerator::Generate(x, &values_out)writes into a caller's vector; the returning overload calls it.ProcessingGroupfills its world-locked and head-locked source index lists into members whose capacityUpdateAmbisonicEncoder()reserves, andUpdateAmbisonicEncoder()counts the sources instead of building their index list.ObrImplkeeps the per-group output buffer as a member, allocated in the constructor.PeakLimiter::Process()makes one pass per frame (the maximum sample across channels, the envelope, the gain applied to every channel) instead of filling two vectors. The arithmetic and its order are unchanged, and each frame reads its input before writing its output, soObrImpl's in-place call gives the same result.Render obr_process() through buffers ObrImpl keeps
obr_process()copied the planar input into a vector of vectors, then into a new inputAudioBuffer, built a new outputAudioBuffer, calledProcess()and copied the output back: several allocations per call through the C API. It also tookObrImpl's mutex twice per call, once to read the input channel count and again inProcess().ObrImpl::ProcessPlanar(input, output, num_frames)copies flat planar arrays into an input bufferObrImplkeeps, renders it asProcess()does and copies the two binaural channels out, undermutex_taken once.AddAudioElement()andRemoveLastAudioElement()size that input buffer in the same critical section that changes the element list, and the output buffer is sized in the constructor, soProcessPlanar()checks that the channel counts match instead of resizing.Process()checks its arguments, takes the lock and runs the shared body,ProcessLocked(), so its callers are unchanged.obr_process()callsProcessPlanar()and keeps no buffers of its own.ObrImplTest.TestProcessPlanarMatchesProcess: the same input throughProcess()on one renderer andProcessPlanar()on another gives identical output with one element, after a second is added and after it is removed.Verification
On macOS arm64 (Apple clang 21):
cmake -S . -B build -DOAR_BUILD_EXAMPLES=ON && cmake --build build && ctest --test-dir build -L 'oar|obr' --output-on-failure: 31 of 31 pass, in Release and with-fsanitize=address.Standalone obr with Bazel 8.5.1,
bazel test //...: 26 of 26 pass. As in Pin Bazel 8.5.1 and build the standalone obr module without WORKSPACE #47, the macOS run used a localapple_supporttoolchain dependency that is not part of this change.RealtimeSanitizer, reproducing ObrImpl::Process() allocates and frees on the audio thread on every render call #28's measurement (Homebrew LLVM 22,
-fsanitize=realtime, the render call inside a[[clang::nonblocking]]function, 128 frames, after one warm-up call,halt_on_error=false; distinct reports):mainObrImpl::Process(), one 3OA element, limiter onmalloc, 4freeObrImpl::Process(), one moving mono objectmalloc, 20freeobr_process(), 3OA and a moving mono objectmalloc, 27freeOutput: a scratch program renders 40 blocks of 256 frames through
ObrImpl::Process()and throughobr_process(), built once againstmainand once against each commit of this branch, and compares an FNV-1a digest of every output sample. All 8 scenarios match: a 3OA element with the limiter on and off; a 7.1.4 layout; two moving mono objects; 3OA, 7.1.4, a moving dual object and a moving mono object together with head tracking and a changing head rotation, at an input level that engages the limiter, with the limiter on and off; and through the C API, a 3OA element and a moving object with a 7.1.4 layout added after 10 blocks and removed after 25, with the limiter on and off.Not run here: the CMake and Bazel builds on Linux and Windows, which the CI workflows cover, and the oar root Bazel build.
ObrImplstill takesmutex_inProcess(), andSetHeadRotation(),EnableHeadTracking()andEnableLimiter()still take it too; #46 tracks that separately.Closes #28