Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/renderer/obr/obr_capi/obr/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ set(SourceFiles
obr/audio_buffer/simd_utils.cc
obr/audio_buffer/simd_utils.h
obr/common/ambisonic_utils.h
obr/common/atomic_rotation.h
obr/common/constants.h
obr/common/status_macros.h
obr/common/misc_math.h
Expand Down Expand Up @@ -315,6 +316,10 @@ add_executable(ambisonic_utils_test obr/common/tests/ambisonic_utils_test.cc)
target_link_libraries(ambisonic_utils_test PRIVATE obr GTest::gtest_main)
add_test(NAME ambisonic_utils_test COMMAND ambisonic_utils_test)

add_executable(atomic_rotation_test obr/common/tests/atomic_rotation_test.cc)
target_link_libraries(atomic_rotation_test PRIVATE obr GTest::gtest_main)
add_test(NAME atomic_rotation_test COMMAND atomic_rotation_test)

add_executable(misc_math_test obr/common/tests/misc_math_test.cc)
target_link_libraries(misc_math_test PRIVATE obr GTest::gtest_main)
add_test(NAME misc_math_test COMMAND misc_math_test)
Expand Down Expand Up @@ -347,6 +352,7 @@ set_tests_properties(
ambisonic_rotator_test
spherical_angle_test
ambisonic_utils_test
atomic_rotation_test
misc_math_test
test_util_test
PROPERTIES LABELS "obr"
Expand Down
1 change: 1 addition & 0 deletions src/renderer/obr/obr_capi/obr/obr/common/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ cc_library(
name = "common",
hdrs = [
"ambisonic_utils.h",
"atomic_rotation.h",
"constants.h",
"misc_math.h",
"status_macros.h",
Expand Down
116 changes: 116 additions & 0 deletions src/renderer/obr/obr_capi/obr/obr/common/atomic_rotation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2026, Alliance for Open Media. All rights reserved
*
* This source code is subject to the terms of the BSD 3-Clause Clear License
* and the Alliance for Open Media Patent License 1.0. If the BSD 3-Clause Clear
* License was not distributed with this source code in the LICENSE file, you
* can obtain it at www.aomedia.org/license/software-license/bsd-3-c-c. If the
* Alliance for Open Media Patent License 1.0 was not distributed with this
* source code in the PATENTS file, you can obtain it at
* www.aomedia.org/license/patent.
*/

#ifndef OBR_COMMON_ATOMIC_ROTATION_H_
#define OBR_COMMON_ATOMIC_ROTATION_H_

#include <atomic>
#include <cstdint>

#include "obr/common/misc_math.h"

namespace obr {

/*!\brief A `WorldRotation` written by one or more threads and read by
* another without a lock.
*
* The four quaternion components are atomic floats behind a sequence
* counter that is odd while a write is in progress. `std::atomic` of the
* whole 16-byte quaternion is not guaranteed to be lock-free on every
* platform OBR targets.
* `Store()` waits for a concurrent `Store()` to finish; `Load()` never waits:
* after `kMaxLoadAttempts` attempts that overlap a write it returns the
* caller's previous value.
*/
class AtomicWorldRotation {
public:
/*!\brief The number of times `Load()` reads the components before it
* returns the fallback.
*
* `Load()` runs on the render thread, so it must not wait for a writer: a
* writer preempted while the sequence is odd would keep an unbounded retry
* loop spinning until that writer runs again. An attempt fails only when a
* write overlaps it, so with 4 the fallback needs writes to overlap four
* consecutive attempts. `ObrImpl` passes the rotation it rendered the
* previous block with as the fallback. Any small bound behaves the same
* way; 4 is not tuned against a measurement.
*/
static constexpr int kMaxLoadAttempts = 4;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A quick question: why choose 4 times?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Load() runs on the render thread once per block, so it must not wait for a writer. A writer that is preempted while the sequence is odd would keep an unbounded retry loop spinning until it runs again. The bound caps the render thread's work at four reads; after that, Process() uses the previous block's rotation, so a new rotation applies one block later.

A Store() is four relaxed float stores between two sequence updates, so a retry after an overlapping write normally succeeds on the next attempt. With 4, a fallback needs writes to overlap four consecutive reads. Any small bound would behave the same way; 4 is not tuned against a measurement.

Added to the kMaxLoadAttempts comment in 340dc49.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, thanks!


/*!\brief Constructs an identity rotation. */
AtomicWorldRotation() : AtomicWorldRotation(WorldRotation()) {}

/*!\brief Constructs a rotation holding `rotation`. */
explicit AtomicWorldRotation(const WorldRotation& rotation)
: w_(rotation.w()),
x_(rotation.x()),
y_(rotation.y()),
z_(rotation.z()) {}

AtomicWorldRotation(const AtomicWorldRotation&) = delete;
AtomicWorldRotation& operator=(const AtomicWorldRotation&) = delete;

/*!\brief Publishes `rotation`. Safe to call from several threads. */
void Store(const WorldRotation& rotation) {
// Make the sequence odd. Another writer holding it odd is storing four
// floats, so the wait is short while that writer runs; it spins for as
// long as that writer is preempted. The acquire on success orders this
// store after the previous writer's, whose final sequence store it reads.
uint32_t sequence = sequence_.load(std::memory_order_relaxed);
do {
while (sequence & 1u) {
sequence = sequence_.load(std::memory_order_relaxed);
}
} while (!sequence_.compare_exchange_weak(sequence, sequence + 1,
std::memory_order_acquire,
std::memory_order_relaxed));
// Orders the odd sequence before the component stores, so a reader that
// sees a new component also sees a changed sequence.
std::atomic_thread_fence(std::memory_order_release);
w_.store(rotation.w(), std::memory_order_relaxed);
x_.store(rotation.x(), std::memory_order_relaxed);
y_.store(rotation.y(), std::memory_order_relaxed);
z_.store(rotation.z(), std::memory_order_relaxed);
sequence_.store(sequence + 2, std::memory_order_release);
}

/*!\brief Returns the last stored rotation, or `fallback` when every one of
* `kMaxLoadAttempts` reads overlapped a `Store()`.
*/
WorldRotation Load(const WorldRotation& fallback) const {
for (int attempt = 0; attempt < kMaxLoadAttempts; ++attempt) {
const uint32_t before = sequence_.load(std::memory_order_acquire);
if (before & 1u) continue;
const float w = w_.load(std::memory_order_relaxed);
const float x = x_.load(std::memory_order_relaxed);
const float y = y_.load(std::memory_order_relaxed);
const float z = z_.load(std::memory_order_relaxed);
// Orders the component loads before the second sequence load.
std::atomic_thread_fence(std::memory_order_acquire);
if (sequence_.load(std::memory_order_relaxed) == before) {
return WorldRotation(w, x, y, z);
}
}
return fallback;
}

private:
std::atomic<uint32_t> sequence_{0};
std::atomic<float> w_;
std::atomic<float> x_;
std::atomic<float> y_;
std::atomic<float> z_;
};

} // namespace obr

#endif // OBR_COMMON_ATOMIC_ROTATION_H_
9 changes: 9 additions & 0 deletions src/renderer/obr/obr_capi/obr/obr/common/tests/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ cc_test(
],
)

cc_test(
name = "atomic_rotation_test",
srcs = ["atomic_rotation_test.cc"],
deps = [
"//obr/common",
"@com_google_googletest//:gtest_main",
],
)

cc_test(
name = "misc_math_test",
srcs = ["misc_math_test.cc"],
Expand Down
131 changes: 131 additions & 0 deletions src/renderer/obr/obr_capi/obr/obr/common/tests/atomic_rotation_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2026, Alliance for Open Media. All rights reserved
*
* This source code is subject to the terms of the BSD 3-Clause Clear License
* and the Alliance for Open Media Patent License 1.0. If the BSD 3-Clause Clear
* License was not distributed with this source code in the LICENSE file, you
* can obtain it at www.aomedia.org/license/software-license/bsd-3-c-c. If the
* Alliance for Open Media Patent License 1.0 was not distributed with this
* source code in the PATENTS file, you can obtain it at
* www.aomedia.org/license/patent.
*/

#include "obr/common/atomic_rotation.h"

#include <atomic>
#include <thread>
#include <vector>

#include "gtest/gtest.h"
#include "obr/common/misc_math.h"

namespace obr {
namespace {

// A rotation whose components are `k`, `k + 1`, `k + 2` and `k + 3`, so a
// load that mixes two stores shows as unequal steps. Integers below 2^24 are
// exact in a float.
WorldRotation Numbered(int k) {
return WorldRotation(static_cast<float>(k), static_cast<float>(k + 1),
static_cast<float>(k + 2), static_cast<float>(k + 3));
}

bool IsNumbered(const WorldRotation& r) {
return r.x() - r.w() == 1.0f && r.y() - r.w() == 2.0f &&
r.z() - r.w() == 3.0f;
}

const WorldRotation kFallback(-7.0f, -7.0f, -7.0f, -7.0f);

bool IsFallback(const WorldRotation& r) {
return r.w() == kFallback.w() && r.x() == kFallback.x() &&
r.y() == kFallback.y() && r.z() == kFallback.z();
}

TEST(AtomicWorldRotationTest, DefaultIsIdentity) {
const AtomicWorldRotation rotation;
const WorldRotation loaded = rotation.Load(kFallback);
EXPECT_EQ(loaded.w(), 1.0f);
EXPECT_EQ(loaded.x(), 0.0f);
EXPECT_EQ(loaded.y(), 0.0f);
EXPECT_EQ(loaded.z(), 0.0f);
}

TEST(AtomicWorldRotationTest, LoadReturnsTheLastStore) {
AtomicWorldRotation rotation;
rotation.Store(WorldRotation(0.5f, -0.5f, 0.25f, 0.75f));
rotation.Store(Numbered(10));
const WorldRotation loaded = rotation.Load(kFallback);
EXPECT_EQ(loaded.w(), 10.0f);
EXPECT_EQ(loaded.x(), 11.0f);
EXPECT_EQ(loaded.y(), 12.0f);
EXPECT_EQ(loaded.z(), 13.0f);
}

// One writer stores increasing numbered rotations while the test thread
// loads. Every load returns a whole store or the fallback, and the stores it
// returns never go back in time.
TEST(AtomicWorldRotationTest, NoTornLoadsUnderAConcurrentWriter) {
AtomicWorldRotation rotation(Numbered(0));
std::atomic<bool> stop{false};
std::thread writer([&] {
for (int k = 1; !stop.load(std::memory_order_relaxed); ++k) {
rotation.Store(Numbered(k & ((1 << 20) - 1)));
}
});

float last_w = 0.0f;
int torn = 0;
int backwards = 0;
for (int i = 0; i < 200000; ++i) {
const WorldRotation loaded = rotation.Load(kFallback);
if (IsFallback(loaded)) continue;
if (!IsNumbered(loaded)) {
++torn;
continue;
}
// The writer wraps at 2^20; a smaller value after a large one is a wrap.
if (loaded.w() < last_w && last_w - loaded.w() < (1 << 19)) ++backwards;
last_w = loaded.w();
}
stop.store(true, std::memory_order_relaxed);
writer.join();

EXPECT_EQ(torn, 0);
EXPECT_EQ(backwards, 0);
}

// Several writers store as fast as they can. `Load()` returns after at most
// `kMaxLoadAttempts` attempts each time, with a whole store or the fallback.
TEST(AtomicWorldRotationTest, LoadIsBoundedUnderAWriteStorm) {
AtomicWorldRotation rotation(Numbered(0));
std::atomic<bool> stop{false};
std::vector<std::thread> writers;
for (int t = 0; t < 4; ++t) {
writers.emplace_back([&rotation, &stop, t] {
for (int k = t; !stop.load(std::memory_order_relaxed); k += 4) {
rotation.Store(Numbered(k & ((1 << 20) - 1)));
}
});
}

int torn = 0;
int fallbacks = 0;
for (int i = 0; i < 100000; ++i) {
const WorldRotation loaded = rotation.Load(kFallback);
if (IsFallback(loaded)) {
++fallbacks;
} else if (!IsNumbered(loaded)) {
++torn;
}
}
stop.store(true, std::memory_order_relaxed);
for (auto& writer : writers) writer.join();

EXPECT_EQ(torn, 0);
// Whether a load fell back depends on scheduling; record it.
RecordProperty("fallbacks", fallbacks);
}

} // namespace
} // namespace obr
37 changes: 19 additions & 18 deletions src/renderer/obr/obr_capi/obr/obr/renderer/obr_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include "obr/renderer/obr_impl.h"

#include <algorithm>
#include <atomic>
#include <cstddef>
#include <map>
#include <memory>
Expand Down Expand Up @@ -41,7 +42,6 @@ ObrImpl::ObrImpl(int buffer_size_per_channel, int sampling_rate)
sampling_rate_(sampling_rate),
head_tracking_enabled_(false),
limiter_enabled_(true),
world_rotation_(WorldRotation()),
fft_manager_(buffer_size_per_channel_),
group_output_(kNumBinauralChannels, buffer_size_per_channel_),
planar_input_(0, buffer_size_per_channel_),
Expand Down Expand Up @@ -179,6 +179,13 @@ void ObrImpl::ProcessPlanar(const float* input, float* output,

void ObrImpl::ProcessLocked(const AudioBuffer& input_buffer,
AudioBuffer* output_buffer) {
// Read the control state once for the whole block; other threads set it
// without the lock.
const bool head_tracking_enabled =
head_tracking_enabled_.load(std::memory_order_relaxed);
const bool limiter_enabled = limiter_enabled_.load(std::memory_order_relaxed);
latched_world_rotation_ = world_rotation_.Load(latched_world_rotation_);

// Clear the output buffer.
output_buffer->Clear();

Expand Down Expand Up @@ -229,7 +236,7 @@ void ObrImpl::ProcessLocked(const AudioBuffer& input_buffer,
// (May still have passthrough elements processed above.)
if (processing_groups_.empty()) {
// Apply peak limiter even for passthrough-only configurations.
if (limiter_enabled_) {
if (limiter_enabled) {
peak_limiter_->Process(*output_buffer, output_buffer);
}
return;
Expand All @@ -238,16 +245,16 @@ void ObrImpl::ProcessLocked(const AudioBuffer& input_buffer,
// Process each processing group.
for (auto& group : processing_groups_) {
group_output_.Clear();
group.Process(input_buffer, audio_elements_, head_tracking_enabled_,
world_rotation_, &group_output_);
group.Process(input_buffer, audio_elements_, head_tracking_enabled,
latched_world_rotation_, &group_output_);

// Add this group's binaural output to the main output buffer.
(*output_buffer)[0] += group_output_[0];
(*output_buffer)[1] += group_output_[1];
}

// Peak limit the output if enabled.
if (limiter_enabled_) {
if (limiter_enabled) {
peak_limiter_->Process(*output_buffer, output_buffer);
}
}
Expand Down Expand Up @@ -460,26 +467,20 @@ absl::Status ObrImpl::UpdateObjectChannelPosition(size_t audio_element_index,
}

void ObrImpl::EnableHeadTracking(bool enable_head_tracking) {
// Acquire mutex: `head_tracking_enabled_` is read by `Process()` on the
// audio thread.
absl::MutexLock lock(&mutex_);
head_tracking_enabled_ = enable_head_tracking;
// No lock: `Process()` reads the flag once per block on the audio thread.
head_tracking_enabled_.store(enable_head_tracking, std::memory_order_relaxed);
}

void ObrImpl::EnableLimiter(bool enable_limiter) {
// Acquire mutex: `limiter_enabled_` is read by `Process()` on the audio
// thread.
absl::MutexLock lock(&mutex_);
limiter_enabled_ = enable_limiter;
// No lock: `Process()` reads the flag once per block on the audio thread.
limiter_enabled_.store(enable_limiter, std::memory_order_relaxed);
}

absl::Status ObrImpl::SetHeadRotation(float w, float x, float y, float z) {
// Acquire mutex: `world_rotation_` is read by `Process()` on the audio
// thread, and head rotation updates typically arrive from a sensor thread.
absl::MutexLock lock(&mutex_);

// No lock: head rotation typically arrives from a sensor thread on every
// block, and `Process()` reads it once per block on the audio thread.
// Apply counter-rotation for head tracking.
world_rotation_ = WorldRotation(w, -x, -y, -z);
world_rotation_.Store(WorldRotation(w, -x, -y, -z));

return absl::OkStatus();
}
Expand Down
Loading
Loading