Repository navigation
Conversation
oar_render() allocated a block descriptor per audio group and (group count + 1) zeroed buffers of output channels x samples per channel on every call, and freed them before returning. A host calls oar_render() on its render thread for every block, where allocation is not bounded in time. The oar_t now keeps these blocks. oar_add_audio_group(), which runs on the configuring thread, sizes them for the new group count, and oar_render() zeroes them on each call. oar_render() does not allocate them: if they were smaller than the group count needs, which the API does not allow, it returns ck_oar_error_inval. A configuration with zero output channels or zero samples per channel has no scratch; oar_render() returns ck_oar_error_inval for it before touching the scratch. Before, it allocated zero-length buffers with calloc(), returned ck_oar_error_nomem where calloc(0) returns NULL, and otherwise, with an element holding data, returned ck_oar_ok while rendering nothing. oar.h no longer lists ck_oar_error_nomem as a result of oar_render(), and says that output->samples_per_channel is set on success. test_render_scratch.c: a block of silent input rendered after a block of noise gives exact zeros; a group added between renders mixes in as the exact sum of the groups rendered alone; a zero-length configuration holding an element with data returns ck_oar_error_inval.
Every gain or position push copied the metadata with metadata_clone() and queued the copy with queue_push(), which allocated a node: two heap allocations per push. A host that pushes an element gain, an object position and a group output gain for every block from its render thread made two allocations per push on that thread, and freed them when the metadata elapsed. A queue now keeps the nodes it pops and reuses them for the next push; queue_reserve() adds spare nodes ahead of time. A metadata item (an element's gains and object positions, a group's output gain) is set up by metadata_item_init(), which reserves four queue nodes and four metadata copies. metadata_item_push() copies the pushed metadata into a spare, and metadata_item_elapse() returns elapsed copies to the spares. A push beyond the spares, or of a ck_param_multiple gain whose gain array is copied, allocates as before, and a copy that owns a gain array is freed when it elapses instead of kept. An element's gain item is still created on the first gain push for its id. A host that pushes from its render thread can push a zero-duration gain for each element after adding it; such a gain covers no sample and elapses at the first render in which an element has data. test_metadata_reuse.c renders the same input through two oar_t instances that receive the same metadata at different times and requires bit-identical output: element gains, group gains and positions pushed eight blocks ahead (beyond the four spares), per-sample gain arrays alternating with animated gains three blocks ahead, and a zero-duration gain and position before the first block.
For each sub-frame of an object element, _sub_frames_apply_positions() allocated a constant-position copy of the position metadata, an input sub-block and an output sub-block, and freed all three before returning; the binaural element renderer allocated the same position copy for each object element on every render. The constant positions are written into a stack oar_metadata_t by metadata_constant_polar_positions_fill(), which replaces metadata_constant_polar_positions_create(); the renderer libraries' metadata_update() copy the fields they use and keep no pointer. An object element renderer allocates its sub-frame input and output blocks when it is created, sized for a whole block of its channels and of the output layout's, and renders into them. A block whose sub-frames do not fit returns ck_oar_error_inval before the first position reaches the library. Output is unchanged. test_object_based_rendering and test_metadata_unit_processing, which renders sub-frames shorter than a block, pass.
OLR made 17 to 35 heap allocations per object per block, depending on the layout and the position: a copy of each metadata block and a deque node, the processing block and its interpolation table, the gain array of each block, the output of each channel, and inside the gain calculation the layer gains, the VBAP, DBAP and 2D gain arrays, the argsort pairs and VBAP's per-region gains. A track also created its processing channel on its first metadata block, which is the first render. Every metadata block pushed to a track stayed in a vector that was never trimmed, so OLR's memory grew with playback time, about 94 blocks per second per object at 512 frames. A deque now keeps the nodes it pops for its next push, and deque_reserve() adds spares ahead of time. Each track keeps its pending metadata blocks by value in a ring of eight that doubles when a caller queues more. The track builds its processing block in a processing_block_storage_t, whose interpolation table is sized for max_block_samples, a new argument of object_audio_renderer_create() that OLR passes as the configuration's samples per frame; a longer block grows the table. The interpreter calculates each block's gains into the older of two arrays allocated at creation; the processing block that used it has been dropped by then, because a track holds one processing block at a time. object_audio_renderer_add_tracks() creates the track channels when OLR opens, and the renderer allocates its per-channel output at creation. A track frees its queued processing block before the interpreter that holds the block's storage. The layerwise, VBAP and DBAP panners and the custom gain calculator use stack arrays bounded by def_max_speakers (24) instead of heap arrays and vectors of heap pointers, and nc_argsort() sorts up to 64 values in a stack buffer. interp_gains_create() and fixed_gains_create() still allocate, for callers outside a track. test_olr_block_storage.c queues twenty metadata blocks before the first render, so the ring grows from 8 to 32, and requires the output of one block queued per render; it requires interpolated and fixed gains built in storage, including blocks longer than its table, to process as the allocated blocks do; and it destroys a renderer whose track still holds a processing block.
The binaural element renderer stages every element's input in one block. add_element() and remove_element() only marked it out of date, and the next add_data() call, which a host makes on its render thread, allocated it (calloc) or grew it (realloc). _update_staging_block() now sizes the block in add_element() and remove_element(), on the configuring thread, keeping the data of the existing channels and zeroing the added ones; add_data() calls it only if that failed. While the block is not sized for the current elements, render() and apply_gains() return ck_oar_error_nomem instead of reading or writing past its end. The resize compares the block's own channel count with the elements' instead of the channel count of the block being added. test_binaural_add_remove.c renders the block after an element is added (with the existing element's input fed before the add) and after an element is removed, and requires it to equal the first block of a new oar_t with the same elements.
trsonic
requested review from
felicialim,
jingbo-marquis,
trevorknight,
yeroro and
yilun-zhangs
October 13, 2026 14:23
metadata_item_elapse() warned "negative duration" whenever the samples elapsed exceeded the queued duration. A queue holding only zero-duration blocks covers no samples, like an empty queue, for which the function returns without a warning, but it warned for each such item on the next render with data. A host that pushes a zero-duration gain per element after adding it, so that the element's gain item exists before rendering starts, logged the warning from its render thread through fprintf(stderr) and localtime() whenever an element's queue held only that block at a render: for example when it renders a new instance's first blocks with silent input and no metadata, or renders while one element receives neither data nor a gain. The item now resets silently when its queue covered no samples; a queue whose blocks cover part of the rendered samples still warns. Output is unchanged. The warning prints its uint32_t fields with %u. test_metadata_reuse.c captures stderr around a render in render_capturing_stderr(). TC4 renders blocks after only zero-duration metadata, requires the output of blocks rendered without it, and requires no warning; it fails without the change (four warnings: the group gain, both element gains and the object positions). TC5 renders a block after metadata covering half of it and requires the warning, so TC4 cannot pass because the capture or the warning is missing; it fails if the warning is never logged.
yilun-zhangs
approved these changes
Oct 15, 2026
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.
For #57: the calls a real-time host makes for every block (gain and position pushes,
oar_update_audio_element_data(),oar_render()) allocate and free on every block onmain. After these commits they make none outside OBR, from the first block after the elements are added, and the rendered output is bit-identical tomain.oar_render()scratch in theoar_t. The per-group blocks and the renderer output block are kept in theoar_t, sized byoar_add_audio_group()on the configuring thread and zeroed per call;oar_render()returnsck_oar_error_invalinstead of allocating if they were too small, which the API does not allow. A configuration with zero output channels or zero samples per channel returnsck_oar_error_invalbefore touching the scratch; onmainit allocated zero-length buffers and, with an element holding data, returnedck_oar_okwhile rendering nothing.oar.hno longer listsck_oar_error_nomemforoar_render().queue_reserve()adds spares). A metadata item (an element's gains and positions, a group's output gain) reserves four nodes and four metadata copies atmetadata_item_init()and reuses elapsed copies. A push beyond the spares, or of ack_param_multiplegain array, allocates as before. An element's gain item is still created on the first gain push for its id, so a host that pushes from its render thread pushes one zero-duration gain per element after adding it (it covers no sample, see Question: 0 duration metadata updates #20).oar_metadata_t(metadata_constant_polar_positions_fill()replaces_create(); the libraries'metadata_update()copy the fields they use), and an object element renderer allocates its sub-frame blocks at creation.max_block_samplesargument ofobject_audio_renderer_create()), and calculates gains into the older of two arrays allocated at creation; the tracks are created when OLR opens, a deque reuses its nodes, the per-render channel output is allocated at creation, and the panners and the custom gain calculator use stack arrays bounded bydef_max_speakers. OLR no longer keeps every metadata block it received.add_element()andremove_element()instead of the nextadd_data(), andrender()andapply_gains()returnck_oar_error_nomemwhile it is not sized for the current elements.metadata_item_elapse()resets a metadata item whose queue held only zero-duration blocks without logging "negative duration": such a queue covers no samples, like an empty queue, for which it already logged nothing. A host that pushes a zero-duration gain per element after adding it (point 2) logged the warning from its render thread, throughfprintf(stderr)andlocaltime(), at a render before that element's next gain push. A queue whose blocks cover part of the rendered samples still warns.Measured with a C program that runs the per-block sequence through
oar.h(48 kHz, 512 samples per channel, one group, a zero-duration gain per element after adding it) and counts allocations on the calling thread with__sanitizer_install_malloc_and_free_hooks()in ASan builds ofmainand of this branch:mainmainThe binaural counts that remain are inside OBR's
ObrImpl::Process()and its C API (#28), which this PR does not change. Counts are the same with the limiter on and off.Over 100,000 blocks of 8 one-object elements on 7.1.4 (Release builds,
/usr/bin/time -l), the maximum resident set size is 68.5 MB onmainand 7.2 MB on this branch (peak footprint 63.9 MB and 2.6 MB); the run took 17.8 s and 13.9 s (one run each).Output is bit-identical: an FNV-1a digest of the first 64 rendered blocks matches between
mainand this branch for all 24 combinations of the outputs and element sets above with the limiter on and off, in Release and in ASan builds.Tests: new
test_render_scratch(silent input after noise renders exact zeros; a group added between renders mixes in exactly; a zero-length configuration returnsck_oar_error_inval),test_metadata_reuse(gains and positions pushed eight blocks ahead, per-sample gain arrays and zero-duration metadata render bit-identically to just-in-time pushes; zero-duration metadata alone renders as without it and logs nothing to stderr, and metadata covering half a block logs the warning),test_olr_block_storage(the metadata ring growing from 8 to 32, storage-backed processing blocks against allocated ones, a renderer destroyed while a track holds a processing block) andtest_binaural_add_remove(the block after an element is added or removed equals the first block of a newoar_t).cmake -S . -B build -DOAR_BUILD_EXAMPLES=ON && cmake --build build && ctest --test-dir build -L 'oar|obr': 35 of 35 pass in Release and with AddressSanitizer on macOS arm64 (Apple clang). Each changed file is formatted with the repository's.clang-format.Not tested: Windows, Linux and the Bazel build (the CI workflows cover them).
max_block_sampleschanges the signature ofobject_audio_renderer_create(), an internal OLR function; nothing outsidesrc/renderer/olrcalls it.Closes #57