diff --git a/.github/workflows/ci-cmake.yml b/.github/workflows/ci-cmake.yml index c075919..7220261 100644 --- a/.github/workflows/ci-cmake.yml +++ b/.github/workflows/ci-cmake.yml @@ -36,14 +36,12 @@ jobs: - name: Build run: cmake --build build --parallel - - name: Run smoke tests + # PATH must include the build dir so the examples find oar.dll in the + # shared-library configuration on Windows. The label filter selects the + # oar examples and obr unit tests while skipping the tests that + # FetchContent'd dependencies (Eigen) register but never build. + - name: Run tests shell: bash run: | export PATH="$PWD/build:$PATH" - ./build/tests/examples/test_audio_element_types - ./build/tests/examples/test_channel_based_rendering - ./build/tests/examples/test_scene_based_rendering - ./build/tests/examples/test_object_based_rendering - - - name: Run obr unit tests - run: ctest --test-dir build/src/renderer/obr/obr_capi/obr -L obr --output-on-failure + ctest --test-dir build -L 'oar|obr' --output-on-failure diff --git a/.gitignore b/.gitignore index 567609b..ea87ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ build/ + +# macOS +.DS_Store + +# Bazel build artifacts (standalone builds in this repo) +/bazel-* +/MODULE.bazel.lock diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 0000000..88c9d92 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,151 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +package(default_visibility = ["//visibility:public"]) + +# ───────────────────────────────────────────────────────────────────────────── +# OAR Core — Open Loudspeaker Renderer (OLR) + framework +# Everything under src/ except the OBR bridge and oar.c itself +# (those are compiled with -D__binauralizer__ below). +# ───────────────────────────────────────────────────────────────────────────── + +cc_library( + name = "oar_core", + srcs = glob( + ["src/**/*.c"], + exclude = [ + "src/renderer/obr/**", + "src/oar.c", + ], + ), + hdrs = glob(["include/*.h"]), + copts = ["-std=c11"], + includes = [ + "include", + "src", + "src/common", + "src/renderer", + "src/renderer/ear", + "src/renderer/olr", + "src/renderer/olr/object_audio_renderer", + "src/utility", + ], + textual_hdrs = glob( + ["src/**/*.h"], + exclude = ["src/renderer/obr/**"], + ), +) + +# ───────────────────────────────────────────────────────────────────────────── +# OBR — Open Binaural Renderer (nested google/obr subtree) +# Sources live at src/renderer/obr/obr_capi/obr/obr/**. +# Include root is src/renderer/obr/obr_capi/obr/ so callers use +# "obr/..." paths. +# ───────────────────────────────────────────────────────────────────────────── + +OBR_PREFIX = "src/renderer/obr/obr_capi/obr" + +cc_library( + name = "obr_lib", + srcs = glob( + [OBR_PREFIX + "/obr/**/*.cc"], + exclude = [ + OBR_PREFIX + "/obr/**/test*/**", + OBR_PREFIX + "/obr/**/tests/**", + OBR_PREFIX + "/obr/cli/**", + ], + ), + hdrs = glob( + [OBR_PREFIX + "/obr/**/*.h"], + exclude = [ + OBR_PREFIX + "/obr/**/test*/**", + OBR_PREFIX + "/obr/**/tests/**", + OBR_PREFIX + "/obr/cli/**", + ], + ), + copts = ["-std=c++20"], + includes = [OBR_PREFIX], + deps = [ + "@abseil-cpp//absl/base:no_destructor", + "@abseil-cpp//absl/container:btree", + "@abseil-cpp//absl/container:flat_hash_map", + "@abseil-cpp//absl/log:absl_check", + "@abseil-cpp//absl/log:absl_log", + "@abseil-cpp//absl/log:die_if_null", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@abseil-cpp//absl/synchronization", + "@eigen", + "@pffft", + ], +) + +# ───────────────────────────────────────────────────────────────────────────── +# OBR C API wrapper (obr_capi.cpp wraps obr::ObrImpl in a C interface). +# ───────────────────────────────────────────────────────────────────────────── + +cc_library( + name = "obr_capi", + srcs = ["src/renderer/obr/obr_capi/obr_capi.cpp"], + hdrs = ["src/renderer/obr/obr_capi/obr_capi.h"], + copts = ["-std=c++20"], + includes = ["src/renderer/obr/obr_capi"], + deps = [":obr_lib"], +) + +# ───────────────────────────────────────────────────────────────────────────── +# OBR bridge (C glue between OAR core and OBR). +# ───────────────────────────────────────────────────────────────────────────── + +cc_library( + name = "obr_bridge", + srcs = ["src/renderer/obr/obr.c"], + hdrs = ["src/renderer/obr/obr.h"], + copts = [ + "-std=c11", + "-D__binauralizer__", + ], + includes = [ + "include", + "src", + "src/common", + "src/renderer", + "src/renderer/obr", + "src/utility", + ], + textual_hdrs = glob( + ["src/**/*.h"], + exclude = ["src/renderer/obr/obr_capi/**"], + ), + deps = [ + ":oar_core", + ":obr_capi", + ], +) + +# ───────────────────────────────────────────────────────────────────────────── +# Full OAR — OLR + OBR combined. +# oar.c is recompiled here WITH __binauralizer__ so it registers the OBR +# renderer library at init time. +# ───────────────────────────────────────────────────────────────────────────── + +cc_library( + name = "oar", + srcs = ["src/oar.c"], + copts = [ + "-std=c11", + "-D__binauralizer__", + ], + includes = [ + "include", + "src", + "src/common", + "src/renderer", + "src/utility", + ], + textual_hdrs = glob(["src/**/*.h"]), + deps = [ + ":oar_core", + ":obr_bridge", + ], +) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..272c77f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md — fork notes + +This repo is a fork of the AOM Open Audio Renderer. + +- `origin` → `eclipsa-audio/oar` (this fork, read/write) +- `upstream` → `AOMediaCodec/oar` (fetch-only; push URL set to `no_push`) + +The public `AOMediaCodec/oar` repo is a fresh cut (no shared history with the old `oar-private` staging repo) with the code moved from `liboar/` to the repository root. This fork's `main` was rebuilt on top of it in July 2026; the pre-rebuild history is archived on the `archive/oar-private-main` branch. + +## Why the fork exists + +Eclipsa's engine consumes OAR+OBR as a Bazel module. Upstream ships a CMake-only build, so this fork adds a `MODULE.bazel` plus the BUILD files needed for `bazel_dep`-style consumption. Fork-local changes are limited to what's needed for that integration. + +## Fork-specific changes (on top of upstream) + +- `MODULE.bazel`, `BUILD.bazel`, `extensions.bzl`, `third_party/pffft.BUILD` — Bazel module build. +- `oar_get_limiter_env()` — public API exposing per-frame limiter envelope (used by the engine for metering). +- Nested Bazel package markers inside the OBR subtree (`src/renderer/obr/obr_capi/obr/`) deleted so the top-level glob traverses. + +### Bug fixes pending upstreaming + +These are merged into our `main` and kept as `fix/*` branches on this fork (not yet submitted upstream). Once upstream merges them, a sync should reduce them to no-ops. + +- `fix/olr-azimuth-wrapping` — `src/renderer/olr/`: wraps out-of-range object azimuths and uses circular closest-speaker distance in OLR; with test `tests/examples/test_object_azimuth_wrapping.c`. +- `fix/register-example-tests-ctest` — registers the liboar example tests with ctest (`enable_testing()` at the root, `add_test()` entries, CI runs them via `ctest -L`). +- `fix/ear-dangling-layout-pointer` — `src/renderer/ear/ear.c`: clears a stack-local output-layout pointer before `_open` returns so it can't dangle. + +Previously listed here and since merged upstream (now plain upstream history): the ARM NEON matrix-render include fix, the OBR resampler/`sh_hrir_creator` DSP fixes, and the LFE filter sample-rate fix. + +## Syncing with upstream + +```sh +git fetch upstream +git log --oneline HEAD..upstream/main # what's new upstream +git log --oneline upstream/main..HEAD # fork-only commits +``` + +When rebasing onto upstream, the fork-specific commits above are the ones to preserve. Anything else on `main` should match upstream. + +## pffft + +pffft isn't on BCR. It's pulled via the module extension in `extensions.bzl` with a local BUILD shim at `third_party/pffft.BUILD`. See the comment in `MODULE.bazel`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 57f0a05..c155e51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,10 @@ cmake_minimum_required(VERSION 3.28) set(PROJECT_N oar) project(${PROJECT_N} VERSION 1.0.0) +# Register tests from every subtree (obr unit tests, liboar examples) so a +# single `ctest --test-dir build` covers them all. +enable_testing() + # Export all symbols when building oar as a DLL so an import library is # generated for the examples to link against. set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 0000000..d2a0400 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,13 @@ +module( + name = "oar", + version = "0.1.0", +) + +bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "abseil-cpp", version = "20260107.1") +bazel_dep(name = "eigen", version = "3.4.0.bcr.3") + +# pffft — small FFT library needed by OBR. Not on BCR; pulled from upstream +# bitbucket with a local BUILD shim. +non_module_deps = use_extension("//:extensions.bzl", "non_module_deps") +use_repo(non_module_deps, "pffft") diff --git a/extensions.bzl b/extensions.bzl new file mode 100644 index 0000000..983e87a --- /dev/null +++ b/extensions.bzl @@ -0,0 +1,13 @@ +"""Module extension for non-bzlmod dependencies of OAR.""" + +load("@bazel_tools//tools/build_defs/repo:git.bzl", "new_git_repository") + +def _non_module_deps_impl(_ctx): + new_git_repository( + name = "pffft", + build_file = "@oar//third_party:pffft.BUILD", + commit = "d7a4c0206a29423478776d6b23a37bbb308f21d5", + remote = "https://bitbucket.org/jpommier/pffft.git", + ) + +non_module_deps = module_extension(implementation = _non_module_deps_impl) diff --git a/include/oar.h b/include/oar.h index 06e48fc..05b6d8f 100644 --- a/include/oar.h +++ b/include/oar.h @@ -270,6 +270,17 @@ int oar_set_loudness(oar_t *oar, uint32_t gid, float loudness, */ int oar_enable_limiter(oar_t *oar, int enable); +/** + * @brief Get the current limiter envelope value. + * + * Returns the limiter gain multiplier after the most recent oar_render() call. + * 1.0 means no gain reduction; values below 1.0 indicate active limiting. + * + * @param [in] oar : OAR object + * @return Limiter envelope (0..1], or 1.0 if limiter is NULL/disabled. + */ +double oar_get_limiter_env(const oar_t *oar); + /** * @brief Enable or disable head tracking for all audio elements. * diff --git a/src/limiter/oar_limiter.c b/src/limiter/oar_limiter.c index 1be074c..9270f49 100644 --- a/src/limiter/oar_limiter.c +++ b/src/limiter/oar_limiter.c @@ -20,12 +20,22 @@ #include "definitions.h" oar_limiter_t* oar_limiter_create(int sampling_rate, double release_ms, - double ceiling_db) { - if (sampling_rate <= 0 || release_ms <= 0) return 0; + double ceiling_db, uint32_t max_frames) { + if (sampling_rate <= 0 || release_ms <= 0 || max_frames == 0) return 0; oar_limiter_t* limiter = def_mallocz(oar_limiter_t, 1); if (!limiter) return 0; + limiter->max_samples = (float*)calloc(max_frames, sizeof(float)); + limiter->limiter_env = (float*)calloc(max_frames, sizeof(float)); + if (!limiter->max_samples || !limiter->limiter_env) { + free(limiter->max_samples); + free(limiter->limiter_env); + def_free(limiter); + return 0; + } + limiter->capacity = max_frames; + limiter->sampling_rate = sampling_rate; limiter->ceiling = pow(10.0, ceiling_db / 20.0); limiter->release_time_constant = @@ -35,11 +45,17 @@ oar_limiter_t* oar_limiter_create(int sampling_rate, double release_ms, return limiter; } -void oar_limiter_destroy(oar_limiter_t* limiter) { def_free(limiter); } +void oar_limiter_destroy(oar_limiter_t* limiter) { + if (limiter) { + free(limiter->max_samples); + free(limiter->limiter_env); + } + def_free(limiter); +} static double GetMaximumRequiredGain(const oar_limiter_t* limiter, double sample) { - if (!limiter) return 1.0; // Should not happen if called from Process + if (!limiter) return 1.0; return fabs(sample) > limiter->ceiling ? limiter->ceiling / fabs(sample) : 1.0; } @@ -51,15 +67,12 @@ int oar_limiter_process(oar_limiter_t* limiter, oar_audio_block_t* block) { uint32_t num_frames = block->samples_per_channel; if (num_channels == 0 || num_frames == 0) return ck_oar_error_inval; + if (num_frames > limiter->capacity) return ck_oar_error_inval; - float* max_samples = def_mallocz(float, num_frames); - float* limiter_env = def_mallocz(float, num_frames); + float* max_samples = limiter->max_samples; + float* limiter_env = limiter->limiter_env; - if (!max_samples || !limiter_env) { - def_free(max_samples); - def_free(limiter_env); - return ck_oar_error_nomem; - } + memset(max_samples, 0, sizeof(float) * num_frames); for (uint32_t c = 0; c < num_channels; ++c) { const float* channel_data = block->data + (c * num_frames); @@ -90,7 +103,5 @@ int oar_limiter_process(oar_limiter_t* limiter, oar_audio_block_t* block) { } } - free(max_samples); - free(limiter_env); return ck_oar_ok; } diff --git a/src/limiter/oar_limiter.h b/src/limiter/oar_limiter.h index aba18ef..3fd9330 100644 --- a/src/limiter/oar_limiter.h +++ b/src/limiter/oar_limiter.h @@ -22,6 +22,8 @@ #ifndef _OAR_LIMITER_H_ #define _OAR_LIMITER_H_ +#include + #include "oar_base.h" // For oar_audio_block_t #ifdef __cplusplus @@ -34,6 +36,9 @@ typedef struct OarLimiter { double ceiling; double release_time_constant; double env; + float* max_samples; /**< Pre-allocated per-frame peak buffer. */ + float* limiter_env; /**< Pre-allocated per-frame envelope buffer. */ + uint32_t capacity; /**< Max frames the buffers can hold. */ } oar_limiter_t; /*!\brief Constructor for oar_limiter_t. @@ -41,11 +46,12 @@ typedef struct OarLimiter { * \param sampling_rate Sampling rate of the audio data. * \param release_ms Release time in milliseconds. * \param ceiling_db Ceiling level in decibels. + * \param max_frames Maximum number of frames per process call. * \return A pointer to the newly created oar_limiter_t instance, or NULL on * failure. */ oar_limiter_t* oar_limiter_create(int sampling_rate, double release_ms, - double ceiling_db); + double ceiling_db, uint32_t max_frames); /*!\brief Destructor for oar_limiter_t. * diff --git a/src/oar.c b/src/oar.c index b8f0b19..7643a3c 100644 --- a/src/oar.c +++ b/src/oar.c @@ -151,7 +151,8 @@ oar_t *oar_create(const oar_config_t *config) { oar->limiter = oar_limiter_create(config->sampling_rate, def_limiter_release_ms, - def_limiter_threshold_dbfs); + def_limiter_threshold_dbfs, + config->samples_per_channel); if (!oar->limiter) { oar_destroy(oar); return 0; @@ -592,6 +593,11 @@ int oar_enable_limiter(oar_t *oar, int enable) { return ck_oar_ok; } +double oar_get_limiter_env(const oar_t *oar) { + if (!oar || !oar->limiter) return 1.0; + return oar->limiter->env; +} + int oar_enable_head_tracking(oar_t *oar, int enable) { int i, j; diff --git a/src/renderer/ear/ear.c b/src/renderer/ear/ear.c index 17de3ac..6c19708 100644 --- a/src/renderer/ear/ear.c +++ b/src/renderer/ear/ear.c @@ -279,6 +279,10 @@ static int _open(renderer_library_context_t *ctx) { } #endif } + // pout is stack-local; the matrix lookups above are its only consumers, so + // clear the pointer (as the input-layout paths do) rather than let it + // dangle for the renderer's lifetime. + ear_renderer->out_sp_layout.sp_layout.predefined_sp = 0; return 0; } diff --git a/src/renderer/obr/obr_capi/obr/BUILD b/src/renderer/obr/obr_capi/obr/BUILD deleted file mode 100644 index b312c05..0000000 --- a/src/renderer/obr/obr_capi/obr/BUILD +++ /dev/null @@ -1,10 +0,0 @@ -# [internal] licensing conditions. -# Open Binaural Renderer (obr). - -# [internal] load license.bzl - -# [internal] license - -licenses(["by_exception_only"]) - -exports_files(["LICENSE"]) diff --git a/src/renderer/obr/obr_capi/obr/MODULE.bazel b/src/renderer/obr/obr_capi/obr/MODULE.bazel deleted file mode 100644 index 1290999..0000000 --- a/src/renderer/obr/obr_capi/obr/MODULE.bazel +++ /dev/null @@ -1,36 +0,0 @@ -############################################################################### -# Bazel now uses Bzlmod by default to manage external dependencies. -# Please consider migrating your external dependencies from WORKSPACE to MODULE.bazel. -# -# For more details, please check https://github.com/bazelbuild/bazel/issues/18958 -############################################################################### - -module( - name = "obr", - version = "1.0.0", -) - -bazel_dep(name = "rules_cc", version = "0.1.2") -bazel_dep( - name = "abseil-cpp", - version = "20250512.1", -) -bazel_dep( - name = "googletest", - version = "1.17.0", - repo_name = "com_google_googletest", -) -bazel_dep( - name = "protobuf", - version = "32.0", - repo_name = "com_google_protobuf", -) -bazel_dep( - name = "google_benchmark", - version = "1.9.4", - repo_name = "com_google_benchmark", -) -bazel_dep( - name = "eigen", - version = "3.4.0.bcr.3", -) diff --git a/src/renderer/obr/obr_capi/obr/WORKSPACE b/src/renderer/obr/obr_capi/obr/WORKSPACE deleted file mode 100644 index a254478..0000000 --- a/src/renderer/obr/obr_capi/obr/WORKSPACE +++ /dev/null @@ -1,26 +0,0 @@ -################################################################################ -# This project now manages dependencies with both bazelmod and WORKSPACE, -# with the goal of moving all dependencies to bazelmod. -# -# For more details, please check https://github.com/bazelbuild/bazel/issues/18958. -################################################################################ - -load( - "@bazel_tools//tools/build_defs/repo:git.bzl", - "git_repository", -) - -# Google Audio-to-Tactile Lib -git_repository( - name = "com_google_audio_to_tactile", - commit = "d3f449fdfd8cfe4a845d0ae244fce2a0bca34a15", - remote = "https://github.com/google/audio-to-tactile.git", -) - -# PFFFT -git_repository( - name = "pffft", - build_file = "pffft.BUILD", - commit = "d7a4c0206a29423478776d6b23a37bbb308f21d5", - remote = "https://bitbucket.org/jpommier/pffft.git", -) diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/BUILD b/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/BUILD deleted file mode 100644 index c31ee16..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/BUILD +++ /dev/null @@ -1,127 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "ambisonic_binaural_decoder", - srcs = [ - "ambisonic_binaural_decoder.cc", - ], - hdrs = ["ambisonic_binaural_decoder.h"], - deps = [ - ":fft_manager", - ":partitioned_fft_filter", - "//obr/audio_buffer", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "dsp_utils", - srcs = ["dsp_utils.cc"], - hdrs = ["dsp_utils.h"], - deps = [ - "//obr/audio_buffer", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "fft_manager", - srcs = ["fft_manager.cc"], - hdrs = ["fft_manager.h"], - deps = [ - "//obr/audio_buffer", - "//obr/audio_buffer:simd_utils", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - "@pffft", - ], -) - -cc_library( - name = "partitioned_fft_filter", - srcs = ["partitioned_fft_filter.cc"], - hdrs = ["partitioned_fft_filter.h"], - deps = [ - ":dsp_utils", - ":fft_manager", - "//obr/audio_buffer", - "//obr/audio_buffer:simd_utils", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "planar_interleaved_conversion", - srcs = ["planar_interleaved_conversion.cc"], - hdrs = ["planar_interleaved_conversion.h"], - deps = [ - ":sample_type_conversion", - "//obr/audio_buffer", - "//obr/audio_buffer:simd_utils", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "resampler", - srcs = ["resampler.cc"], - hdrs = ["resampler.h"], - deps = [ - ":dsp_utils", - "//obr/audio_buffer", - "//obr/audio_buffer:simd_utils", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "sample_type_conversion", - srcs = ["sample_type_conversion.cc"], - hdrs = ["sample_type_conversion.h"], - deps = [ - "//obr/audio_buffer:simd_utils", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "sh_hrir_creator", - srcs = ["sh_hrir_creator.cc"], - hdrs = ["sh_hrir_creator.h"], - deps = [ - ":planar_interleaved_conversion", - ":resampler", - ":wav", - "//obr/ambisonic_binaural_decoder/binaural_filters:binaural_filters_wrapper", - "//obr/audio_buffer", - "//obr/audio_buffer:simd_utils", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - "@abseil-cpp//absl/log:absl_log", - "@abseil-cpp//absl/log:die_if_null", - ], -) - -cc_library( - name = "wav", - srcs = ["wav.cc"], - hdrs = ["wav.h"], - deps = [":wav_reader"], -) - -cc_library( - name = "wav_reader", - srcs = ["wav_reader.cc"], - hdrs = ["wav_reader.h"], - deps = [ - "@abseil-cpp//absl/log:absl_check", - "@abseil-cpp//absl/log:die_if_null", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/binaural_filters/BUILD b/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/binaural_filters/BUILD deleted file mode 100644 index a29d3bd..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/binaural_filters/BUILD +++ /dev/null @@ -1,64 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "binaural_filters_wrapper", - srcs = [ - "binaural_filters_1_oa_ambient_l.cc", - "binaural_filters_1_oa_ambient_r.cc", - "binaural_filters_1_oa_direct_l.cc", - "binaural_filters_1_oa_direct_r.cc", - "binaural_filters_1_oa_reverberant_l.cc", - "binaural_filters_1_oa_reverberant_r.cc", - "binaural_filters_2_oa_ambient_l.cc", - "binaural_filters_2_oa_ambient_r.cc", - "binaural_filters_2_oa_direct_l.cc", - "binaural_filters_2_oa_direct_r.cc", - "binaural_filters_2_oa_reverberant_l.cc", - "binaural_filters_2_oa_reverberant_r.cc", - "binaural_filters_3_oa_ambient_l.cc", - "binaural_filters_3_oa_ambient_r.cc", - "binaural_filters_3_oa_direct_l.cc", - "binaural_filters_3_oa_direct_r.cc", - "binaural_filters_3_oa_reverberant_l.cc", - "binaural_filters_3_oa_reverberant_r.cc", - "binaural_filters_4_oa_ambient_l.cc", - "binaural_filters_4_oa_ambient_r.cc", - "binaural_filters_4_oa_direct_l.cc", - "binaural_filters_4_oa_direct_r.cc", - "binaural_filters_4_oa_reverberant_l.cc", - "binaural_filters_4_oa_reverberant_r.cc", - "binaural_filters_wrapper.cc", - ], - hdrs = [ - "binaural_filters_1_oa_ambient_l.h", - "binaural_filters_1_oa_ambient_r.h", - "binaural_filters_1_oa_direct_l.h", - "binaural_filters_1_oa_direct_r.h", - "binaural_filters_1_oa_reverberant_l.h", - "binaural_filters_1_oa_reverberant_r.h", - "binaural_filters_2_oa_ambient_l.h", - "binaural_filters_2_oa_ambient_r.h", - "binaural_filters_2_oa_direct_l.h", - "binaural_filters_2_oa_direct_r.h", - "binaural_filters_2_oa_reverberant_l.h", - "binaural_filters_2_oa_reverberant_r.h", - "binaural_filters_3_oa_ambient_l.h", - "binaural_filters_3_oa_ambient_r.h", - "binaural_filters_3_oa_direct_l.h", - "binaural_filters_3_oa_direct_r.h", - "binaural_filters_3_oa_reverberant_l.h", - "binaural_filters_3_oa_reverberant_r.h", - "binaural_filters_4_oa_ambient_l.h", - "binaural_filters_4_oa_ambient_r.h", - "binaural_filters_4_oa_direct_l.h", - "binaural_filters_4_oa_direct_r.h", - "binaural_filters_4_oa_reverberant_l.h", - "binaural_filters_4_oa_reverberant_r.h", - "binaural_filters_wrapper.h", - ], - deps = [ - "@abseil-cpp//absl/types:span", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/tests/BUILD b/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/tests/BUILD deleted file mode 100644 index 82177ea..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_binaural_decoder/tests/BUILD +++ /dev/null @@ -1,95 +0,0 @@ -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -cc_test( - name = "ambisonic_binaural_decoder_test", - srcs = [ - "ambisonic_binaural_decoder_test.cc", - ], - deps = [ - "//obr/ambisonic_binaural_decoder", - "//obr/ambisonic_binaural_decoder:fft_manager", - "//obr/audio_buffer", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "dsp_utils_test", - srcs = ["dsp_utils_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:dsp_utils", - "//obr/audio_buffer", - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "fft_manager_test", - srcs = ["fft_manager_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:fft_manager", - "//obr/audio_buffer", - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "partitioned_fft_filter_test", - srcs = ["partitioned_fft_filter_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:fft_manager", - "//obr/ambisonic_binaural_decoder:partitioned_fft_filter", - "//obr/audio_buffer", - "//obr/common", - "//obr/common:test_util", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "planar_interleaved_conversion_test", - srcs = ["planar_interleaved_conversion_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:planar_interleaved_conversion", - "//obr/audio_buffer", - "@abseil-cpp//absl/log:absl_check", - "@abseil-cpp//absl/log:absl_log", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "resampler_test", - srcs = ["resampler_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:resampler", - "//obr/audio_buffer", - "//obr/common", - "//obr/common:test_util", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "sample_type_conversion_test", - srcs = ["sample_type_conversion_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:sample_type_conversion", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "sh_hrir_creator_test", - srcs = ["sh_hrir_creator_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:resampler", - "//obr/ambisonic_binaural_decoder:sh_hrir_creator", - "//obr/audio_buffer", - "@com_google_googletest//:gtest_main", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/BUILD b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/BUILD deleted file mode 100644 index 181eb6d..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/BUILD +++ /dev/null @@ -1,35 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "ambisonic_encoder", - srcs = [ - "ambisonic_encoder.cc", - ], - hdrs = ["ambisonic_encoder.h"], - visibility = ["//visibility:public"], - deps = [ - ":associated_legendre_polynomials_generator", - "//obr/audio_buffer", - "//obr/common", - "@abseil-cpp//absl/container:flat_hash_map", - "@abseil-cpp//absl/log:absl_check", - "@eigen", - ], -) - -cc_library( - name = "associated_legendre_polynomials_generator", - srcs = [ - "associated_legendre_polynomials_generator.cc", - ], - hdrs = [ - "associated_legendre_polynomials_generator.h", - ], - visibility = ["//visibility:public"], - deps = [ - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.cc b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.cc index 0dac898..853e50a 100644 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.cc +++ b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.cc @@ -77,6 +77,15 @@ void AmbisonicEncoder::SetSource(size_t input_channel, float gain, source_properties.target.elevation = elevation; source_properties.target.distance = distance; + // Before any audio has been rendered there is no previously audible + // position to ramp from, so snap to the new parameters. This makes + // metadata applied before the first processed block take effect + // immediately (matching loudspeaker rendering) instead of gliding in + // from the initial position over the first block. + if (!has_processed_) { + source_properties.current = source_properties.target; + } + // If target gain indicates silence, mute the static encoding matrix column // as a quick early-out for any code that may still use it. if (gain < kNegative120dbInAmplitude) { @@ -304,6 +313,10 @@ void AmbisonicEncoder::ProcessPlanarAudioData(const AudioBuffer& input_buffer, // Update the stored encoding matrix to the last frame's matrix so code that // expects a static encoding matrix still has a reasonable value. encoding_matrix_ = last_encoding; + + // Audio has now been rendered; subsequent `SetSource()` updates ramp from + // the previously audible parameters. + has_processed_ = true; } void AmbisonicEncoder::GetShCoeffs(float azimuth, float elevation, diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.h b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.h index b4c2f0d..077b1ab 100644 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.h +++ b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/ambisonic_encoder.h @@ -129,6 +129,12 @@ class AmbisonicEncoder { // Map of structs containing the properties of each source. absl::flat_hash_map sources_; + // False until the first call to `ProcessPlanarAudioData()`. While false, + // `SetSource()` snaps `current` to `target` instead of scheduling a ramp: + // no audio has been rendered yet, so there is no previously audible + // position to interpolate from. + bool has_processed_ = false; + AssociatedLegendrePolynomialsGenerator alp_generator_; Eigen::MatrixXf encoding_matrix_; }; diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/tests/BUILD b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/tests/BUILD deleted file mode 100644 index 94194fa..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/tests/BUILD +++ /dev/null @@ -1,41 +0,0 @@ -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -# keep-sorted start block=yes prefix_order=cc_test newline_separated=yes -# Benchmark with -# `bazel run -c opt :ambisonic_encoder_benchmark -- --benchmark_filter=.` -cc_test( - name = "ambisonic_encoder_benchmark", - srcs = ["ambisonic_encoder_benchmark.cc"], - deps = [ - "//obr/ambisonic_encoder", - "//obr/audio_buffer", - "//obr/common", - "@com_google_benchmark//:benchmark_main", - ], -) - -cc_test( - name = "ambisonic_encoder_test", - srcs = ["ambisonic_encoder_test.cc"], - deps = [ - "//obr/ambisonic_encoder", - "//obr/audio_buffer", - "//obr/common", - "@abseil-cpp//absl/container:flat_hash_map", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "associated_legendre_polynomials_generator_test", - srcs = [ - "associated_legendre_polynomials_generator_test.cc", - ], - deps = [ - "//obr/ambisonic_encoder:associated_legendre_polynomials_generator", - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -# keep-sorted end diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/tests/ambisonic_encoder_test.cc b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/tests/ambisonic_encoder_test.cc index d08be4a..e3a6179 100644 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/tests/ambisonic_encoder_test.cc +++ b/src/renderer/obr/obr_capi/obr/obr/ambisonic_encoder/tests/ambisonic_encoder_test.cc @@ -99,5 +99,83 @@ TEST(AmbisonicEncoderTest, TestOneSampleBufferOneSource) { } } +// A source position set after the source was created but before any audio +// has been processed must take effect from the very first frame, with no +// ramp from the previously set (e.g. default) position. +TEST(AmbisonicEncoderTest, PreRenderPositionUpdateTakesEffectImmediately) { + const size_t buffer_size = 64; + const int number_of_input_channels = 1; + const int ambisonic_order = 1; + + const float kEpsilon = 1e-6; + + AmbisonicEncoder encoder(number_of_input_channels, ambisonic_order); + + // Source is first registered at the default front position, then moved to + // hard left before any processing (mirrors an element being added and its + // metadata updated before the first render call). + encoder.SetSource(0, 1.0f, 0.0f, 0.0f, 1.0f); + encoder.SetSource(0, 1.0f, 90.0f, 0.0f, 1.0f); + + AudioBuffer input_buffer(number_of_input_channels, buffer_size); + for (float& sample : input_buffer[0]) { + sample = 1.0f; + } + + AudioBuffer output_buffer(GetNumPeriphonicComponents(ambisonic_order), + buffer_size); + encoder.ProcessPlanarAudioData(input_buffer, &output_buffer); + + // First-order ACN/SN3D coefficients for azimuth 90, elevation 0: + // ACN0 = 1, ACN1 = 1, ACN2 = 0, ACN3 = 0. Every frame of the block, + // including the first, must carry these coefficients. + const std::vector expected_coefficients = {1.0f, 1.0f, 0.0f, 0.0f}; + for (auto ch = 0; ch < output_buffer.num_channels(); ch++) { + for (float sample : output_buffer[ch]) { + EXPECT_NEAR(sample, expected_coefficients[ch], kEpsilon); + } + } +} + +// Once audio has been processed, position updates must still ramp across the +// next block to avoid clicks: the block starts at the previously audible +// position and ends at the new one. +TEST(AmbisonicEncoderTest, PostRenderPositionUpdateRampsAcrossBlock) { + const size_t buffer_size = 64; + const int number_of_input_channels = 1; + const int ambisonic_order = 1; + + const float kEpsilon = 1e-6; + + AmbisonicEncoder encoder(number_of_input_channels, ambisonic_order); + encoder.SetSource(0, 1.0f, 0.0f, 0.0f, 1.0f); + + AudioBuffer input_buffer(number_of_input_channels, buffer_size); + for (float& sample : input_buffer[0]) { + sample = 1.0f; + } + + AudioBuffer output_buffer(GetNumPeriphonicComponents(ambisonic_order), + buffer_size); + + // First block renders the source at the front. + encoder.ProcessPlanarAudioData(input_buffer, &output_buffer); + + // Move the source to hard left and render another block. + encoder.SetSource(0, 1.0f, 90.0f, 0.0f, 1.0f); + encoder.ProcessPlanarAudioData(input_buffer, &output_buffer); + + // First-order ACN/SN3D coefficients (ACN0..ACN3): front is {1, 0, 0, 1}, + // hard left is {1, 1, 0, 0}. The block must start at the front and end + // hard left. + const std::vector front_coefficients = {1.0f, 0.0f, 0.0f, 1.0f}; + const std::vector left_coefficients = {1.0f, 1.0f, 0.0f, 0.0f}; + for (auto ch = 0; ch < output_buffer.num_channels(); ch++) { + EXPECT_NEAR(output_buffer[ch][0], front_coefficients[ch], kEpsilon); + EXPECT_NEAR(output_buffer[ch][buffer_size - 1], left_coefficients[ch], + kEpsilon); + } +} + } // namespace } // namespace obr diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/BUILD b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/BUILD deleted file mode 100644 index 697af94..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/BUILD +++ /dev/null @@ -1,18 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//src:__subpackages__"]) - -cc_library( - name = "ambisonic_rotator", - srcs = [ - "ambisonic_rotator.cc", - ], - hdrs = ["ambisonic_rotator.h"], - visibility = ["//visibility:public"], - deps = [ - "//obr/audio_buffer", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - "@eigen", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.cc b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.cc index ea4332e..c8b8c07 100644 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.cc +++ b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.cc @@ -203,6 +203,19 @@ bool AmbisonicRotator::Process(const WorldRotation& target_rotation, static const WorldRotation kIdentityRotation; + // Before any audio has been rendered there is no previously audible + // rotation to slerp from, so snap to the target: a head pose set ahead of + // the first processed block takes effect from the first frame instead of + // gliding in from the initial identity rotation. + if (!has_processed_) { + has_processed_ = true; + if (current_rotation_.AngularDifferenceRad(target_rotation) >= + kRotationQuantizationRad) { + current_rotation_ = target_rotation; + UpdateRotationMatrix(current_rotation_); + } + } + if (current_rotation_.AngularDifferenceRad(kIdentityRotation) < kRotationQuantizationRad && target_rotation.AngularDifferenceRad(kIdentityRotation) < diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.h b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.h index a377766..d743d35 100644 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.h +++ b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/ambisonic_rotator.h @@ -44,6 +44,15 @@ class AmbisonicRotator { bool Process(const WorldRotation& target_rotation, const AudioBuffer& input, AudioBuffer* output); + /*!\brief Records that a block of audio was rendered without this rotator + * (e.g. with head tracking disabled). + * + * The unrotated scene was audible, so a later rotation change slerps from + * `current_rotation_` instead of being snapped as on the first processed + * block. + */ + void MarkAudioRendered() { has_processed_ = true; } + private: /*!\brief Updates the rotation matrix with using supplied WorldRotation. * @@ -58,6 +67,12 @@ class AmbisonicRotator { // compute new rotation matrix. Initialized with an identity rotation. WorldRotation current_rotation_; + // False until the first call to `Process()`. While false, the target + // rotation is applied from the first frame instead of slerping from + // `current_rotation_`: no audio has been rendered yet, so there is no + // previously audible rotation to interpolate from. + bool has_processed_ = false; + // Spherical harmonics rotation sub-matrices for each order. std::vector rotation_matrices_; diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/tests/BUILD b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/tests/BUILD deleted file mode 100644 index b7a6e06..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/tests/BUILD +++ /dev/null @@ -1,43 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -package(default_visibility = ["//src:__subpackages__"]) - -cc_test( - name = "ambisonic_rotator_test", - srcs = [ - "ambisonic_rotator_test.cc", - ], - deps = [ - ":spherical_angle", - "//obr/ambisonic_binaural_decoder:planar_interleaved_conversion", - "//obr/ambisonic_encoder", - "//obr/ambisonic_rotator", - "//obr/audio_buffer", - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "spherical_angle_test", - srcs = [ - "spherical_angle_test.cc", - ], - deps = [ - ":spherical_angle", - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -cc_library( - name = "spherical_angle", - srcs = [ - "spherical_angle.cc", - ], - hdrs = ["spherical_angle.h"], - deps = [ - "//obr/common", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/tests/ambisonic_rotator_test.cc b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/tests/ambisonic_rotator_test.cc index 5cca0ed..aa508ff 100644 --- a/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/tests/ambisonic_rotator_test.cc +++ b/src/renderer/obr/obr_capi/obr/obr/ambisonic_rotator/tests/ambisonic_rotator_test.cc @@ -13,6 +13,7 @@ #include "obr/ambisonic_rotator/ambisonic_rotator.h" #include +#include #include #include #include @@ -208,6 +209,165 @@ INSTANTIATE_TEST_SUITE_P( TestParams({0.0f, 1.0f, 0.0f}, kYrotatedSourceAngle), TestParams({0.0f, 0.0f, 1.0f}, kZrotatedSourceAngle))); +// A rotation set before any audio has been processed must be applied in full +// from the very first frame, with no slerp from the initial identity rotation. +TEST_F(AmbisonicRotatorTest, FirstProcessedBlockAppliesTargetRotationInFull) { + const size_t kFramesPerBuffer = 2 * kSlerpFrameInterval; + const size_t kNumThirdOrderAmbisonicChannels = 16; + const std::vector kInputData(kFramesPerBuffer, 1.0f); + AudioBuffer input_buffer(1, kFramesPerBuffer); + FillAudioBuffer(kInputData, 1, &input_buffer); + + // Soundfield with a source at the initial angle, to be rotated. + AmbisonicEncoder source_mono_codec(1, kAmbisonicOrder); + source_mono_codec.SetSource( + 0, 1.0f, kInitialSourceAngle.azimuth() * kDegreesFromRadians, + kInitialSourceAngle.elevation() * kDegreesFromRadians, 1.0f); + AudioBuffer encoded_buffer(kNumThirdOrderAmbisonicChannels, kFramesPerBuffer); + source_mono_codec.ProcessPlanarAudioData(input_buffer, &encoded_buffer); + + // Reference soundfield with the source already at the rotated angle. + AmbisonicEncoder reference_mono_codec(1, kAmbisonicOrder); + reference_mono_codec.SetSource( + 0, 1.0f, kZrotatedSourceAngle.azimuth() * kDegreesFromRadians, + kZrotatedSourceAngle.elevation() * kDegreesFromRadians, 1.0f); + AudioBuffer reference_buffer(kNumThirdOrderAmbisonicChannels, + kFramesPerBuffer); + reference_mono_codec.ProcessPlanarAudioData(input_buffer, &reference_buffer); + + const WorldRotation rotation = WorldRotation(AngleAxisf( + kAngleDegrees * kRadiansFromDegrees, WorldPosition(0.0f, 0.0f, 1.0f))); + + hoa_rotator_ = std::make_unique(kAmbisonicOrder); + EXPECT_TRUE( + hoa_rotator_->Process(rotation, encoded_buffer, &encoded_buffer)); + + // Every frame of the first processed block, including the first slerp + // interval, must match the fully rotated reference. + for (size_t channel = 0; channel < encoded_buffer.num_channels(); + ++channel) { + for (size_t frame = 0; frame < kFramesPerBuffer; ++frame) { + EXPECT_NEAR(encoded_buffer[channel][frame], + reference_buffer[channel][frame], kEpsilonFloat); + } + } +} + +// If audio has been rendered without the rotator (head tracking disabled), +// the unrotated scene was audible, so a later rotation must still slerp from +// identity across the block instead of being applied in full. +TEST_F(AmbisonicRotatorTest, RotationAfterBypassedBlocksStillSlerps) { + const size_t kFramesPerBuffer = 2 * kSlerpFrameInterval; + const size_t kNumThirdOrderAmbisonicChannels = 16; + const std::vector kInputData(kFramesPerBuffer, 1.0f); + AudioBuffer input_buffer(1, kFramesPerBuffer); + FillAudioBuffer(kInputData, 1, &input_buffer); + + AmbisonicEncoder source_mono_codec(1, kAmbisonicOrder); + source_mono_codec.SetSource( + 0, 1.0f, kInitialSourceAngle.azimuth() * kDegreesFromRadians, + kInitialSourceAngle.elevation() * kDegreesFromRadians, 1.0f); + AudioBuffer encoded_buffer(kNumThirdOrderAmbisonicChannels, kFramesPerBuffer); + source_mono_codec.ProcessPlanarAudioData(input_buffer, &encoded_buffer); + + // Reference soundfield with the source already at the rotated angle. + AmbisonicEncoder reference_mono_codec(1, kAmbisonicOrder); + reference_mono_codec.SetSource( + 0, 1.0f, kZrotatedSourceAngle.azimuth() * kDegreesFromRadians, + kZrotatedSourceAngle.elevation() * kDegreesFromRadians, 1.0f); + AudioBuffer reference_buffer(kNumThirdOrderAmbisonicChannels, + kFramesPerBuffer); + reference_mono_codec.ProcessPlanarAudioData(input_buffer, &reference_buffer); + + const WorldRotation rotation = WorldRotation(AngleAxisf( + kAngleDegrees * kRadiansFromDegrees, WorldPosition(0.0f, 0.0f, 1.0f))); + + hoa_rotator_ = std::make_unique(kAmbisonicOrder); + + // Blocks were rendered with the rotator bypassed (head tracking disabled). + hoa_rotator_->MarkAudioRendered(); + + AudioBuffer rotated_buffer(kNumThirdOrderAmbisonicChannels, kFramesPerBuffer); + EXPECT_TRUE( + hoa_rotator_->Process(rotation, encoded_buffer, &rotated_buffer)); + + float max_first_interval_difference = 0.0f; + for (size_t channel = 0; channel < rotated_buffer.num_channels(); + ++channel) { + // The last slerp interval has undergone the full rotation. + for (size_t frame = kFramesPerBuffer - kSlerpFrameInterval; + frame < kFramesPerBuffer; ++frame) { + EXPECT_NEAR(rotated_buffer[channel][frame], + reference_buffer[channel][frame], kEpsilonFloat); + } + // Track how far the first slerp interval is from the full rotation. + for (size_t frame = 0; frame < kSlerpFrameInterval; ++frame) { + max_first_interval_difference = std::max( + max_first_interval_difference, + std::abs(rotated_buffer[channel][frame] - + reference_buffer[channel][frame])); + } + } + // The first slerp interval must still be part-way through the rotation. + EXPECT_GT(max_first_interval_difference, 0.01f); +} + +// Once audio has been processed, rotation changes must still be smoothed +// across the next block: the block ends at the new rotation but does not +// start there. +TEST_F(AmbisonicRotatorTest, RotationChangeAfterFirstBlockStillSlerps) { + const size_t kFramesPerBuffer = 2 * kSlerpFrameInterval; + const size_t kNumThirdOrderAmbisonicChannels = 16; + const std::vector kInputData(kFramesPerBuffer, 1.0f); + AudioBuffer input_buffer(1, kFramesPerBuffer); + FillAudioBuffer(kInputData, 1, &input_buffer); + + AmbisonicEncoder source_mono_codec(1, kAmbisonicOrder); + source_mono_codec.SetSource( + 0, 1.0f, kInitialSourceAngle.azimuth() * kDegreesFromRadians, + kInitialSourceAngle.elevation() * kDegreesFromRadians, 1.0f); + AudioBuffer encoded_buffer(kNumThirdOrderAmbisonicChannels, kFramesPerBuffer); + source_mono_codec.ProcessPlanarAudioData(input_buffer, &encoded_buffer); + + const WorldRotation rotation = WorldRotation(AngleAxisf( + kAngleDegrees * kRadiansFromDegrees, WorldPosition(0.0f, 0.0f, 1.0f))); + + hoa_rotator_ = std::make_unique(kAmbisonicOrder); + + // First block: rotation applied in full (snap). + AudioBuffer rotated_buffer(kNumThirdOrderAmbisonicChannels, kFramesPerBuffer); + EXPECT_TRUE( + hoa_rotator_->Process(rotation, encoded_buffer, &rotated_buffer)); + + // Second block: return to identity. The output must end as the unrotated + // input (slerp completed) but must not start there (still mid-slerp). + AudioBuffer transition_buffer(kNumThirdOrderAmbisonicChannels, + kFramesPerBuffer); + EXPECT_TRUE(hoa_rotator_->Process(WorldRotation(), encoded_buffer, + &transition_buffer)); + + float max_first_interval_difference = 0.0f; + for (size_t channel = 0; channel < transition_buffer.num_channels(); + ++channel) { + // The last slerp interval has undergone the full rotation back to + // identity, so it must match the unrotated input. + for (size_t frame = kFramesPerBuffer - kSlerpFrameInterval; + frame < kFramesPerBuffer; ++frame) { + EXPECT_NEAR(transition_buffer[channel][frame], + encoded_buffer[channel][frame], kEpsilonFloat); + } + // Track how far the first slerp interval is from the unrotated input. + for (size_t frame = 0; frame < kSlerpFrameInterval; ++frame) { + max_first_interval_difference = std::max( + max_first_interval_difference, + std::abs(transition_buffer[channel][frame] - + encoded_buffer[channel][frame])); + } + } + // The first slerp interval must still be part-way through the rotation. + EXPECT_GT(max_first_interval_difference, 0.01f); +} + } // namespace } // namespace obr diff --git a/src/renderer/obr/obr_capi/obr/obr/audio_buffer/BUILD b/src/renderer/obr/obr_capi/obr/obr/audio_buffer/BUILD deleted file mode 100644 index d1496e0..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/audio_buffer/BUILD +++ /dev/null @@ -1,48 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "audio_buffer", - srcs = [ - "audio_buffer.cc", - ], - hdrs = [ - "aligned_allocator.h", - "audio_buffer.h", - ], - visibility = ["//visibility:public"], - deps = [ - ":channel_view", - ":simd_utils", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "channel_view", - srcs = ["channel_view.cc"], - hdrs = ["channel_view.h"], - deps = [ - ":simd_utils", - "@abseil-cpp//absl/log:absl_check", - ], -) - -cc_library( - name = "simd_macros", - hdrs = ["simd_macros.h"], - deps = ["//obr/common"], -) - -cc_library( - name = "simd_utils", - srcs = ["simd_utils.cc"], - hdrs = ["simd_utils.h"], - deps = [ - ":simd_macros", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/audio_buffer/tests/BUILD b/src/renderer/obr/obr_capi/obr/obr/audio_buffer/tests/BUILD deleted file mode 100644 index 82d5780..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/audio_buffer/tests/BUILD +++ /dev/null @@ -1,42 +0,0 @@ -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -cc_test( - name = "audio_buffer_test", - srcs = ["audio_buffer_test.cc"], - deps = [ - "//obr/audio_buffer", - "//obr/common", - "//obr/common:test_util", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "channel_view_test", - srcs = ["channel_view_test.cc"], - deps = [ - "//obr/audio_buffer", - "//obr/audio_buffer:channel_view", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "simd_utils_test", - srcs = ["simd_utils_test.cc"], - deps = [ - "//obr/audio_buffer", - "//obr/audio_buffer:simd_utils", - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "aligned_allocator_test", - srcs = ["aligned_allocator_test.cc"], - deps = [ - "//obr/audio_buffer", - "@com_google_googletest//:gtest_main", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/cli/BUILD b/src/renderer/obr/obr_capi/obr/obr/cli/BUILD deleted file mode 100644 index 46889b7..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/cli/BUILD +++ /dev/null @@ -1,40 +0,0 @@ -load("@rules_cc//cc:cc_binary.bzl", "cc_binary") -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "obr_cli_lib", - srcs = ["obr_cli_lib.cc"], - hdrs = ["obr_cli_lib.h"], - deps = [ - "//obr/audio_buffer", - "//obr/cli/proto:oba_metadata_cc_proto", - "//obr/renderer:audio_element_config", - "//obr/renderer:audio_element_type", - "//obr/renderer:obr_impl", - "@abseil-cpp//absl/log:absl_check", - "@abseil-cpp//absl/log:absl_log", - "@abseil-cpp//absl/status", - "@abseil-cpp//absl/strings", - "@com_google_audio_to_tactile//:dsp", - "@com_google_protobuf//:protobuf", - ], -) - -cc_binary( - name = "obr_cli", - srcs = ["obr_cli.cc"], - deps = [ - ":obr_cli_lib", - "//obr/renderer:audio_element_config", - "//obr/renderer:audio_element_type", - "@abseil-cpp//absl/flags:flag", - "@abseil-cpp//absl/flags:parse", - "@abseil-cpp//absl/flags:usage", - "@abseil-cpp//absl/log:absl_log", - "@abseil-cpp//absl/log:flags", - "@abseil-cpp//absl/status", - "@abseil-cpp//absl/strings", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/cli/proto/BUILD b/src/renderer/obr/obr_capi/obr/obr/cli/proto/BUILD deleted file mode 100644 index 7801f07..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/cli/proto/BUILD +++ /dev/null @@ -1,15 +0,0 @@ -# [internal] load cc_proto_library.bzl -load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") - -package(default_visibility = ["//obr/cli:__subpackages__"]) - -# Proto for obr_cli OBA input metadata. -proto_library( - name = "oba_metadata_proto", - srcs = ["oba_metadata.proto"], -) - -cc_proto_library( - name = "oba_metadata_cc_proto", - deps = [":oba_metadata_proto"], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/cli/testdata/BUILD b/src/renderer/obr/obr_capi/obr/obr/cli/testdata/BUILD deleted file mode 100644 index fe2985d..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/cli/testdata/BUILD +++ /dev/null @@ -1,14 +0,0 @@ -package(default_visibility = ["//obr:__subpackages__"]) - -# keep-sorted start block=yes prefix_order=filegroup newline_separated=yes -filegroup( - name = "input_wav_files", - srcs = glob(["*.wav"]), -) - -filegroup( - name = "textprotos", - srcs = glob(["*.textproto"]), -) - -# keep-sorted end diff --git a/src/renderer/obr/obr_capi/obr/obr/cli/tests/BUILD b/src/renderer/obr/obr_capi/obr/obr/cli/tests/BUILD deleted file mode 100644 index a0698e1..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/cli/tests/BUILD +++ /dev/null @@ -1,20 +0,0 @@ -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -package(default_visibility = ["//obr/cli/tests:__subpackages__"]) - -cc_test( - name = "obr_cli_lib_test", - srcs = ["obr_cli_lib_test.cc"], - data = [ - "//obr/cli/testdata:input_wav_files", - "//obr/cli/testdata:textprotos", - ], - shard_count = 4, - deps = [ - "//obr/cli:obr_cli_lib", - "//obr/renderer:audio_element_config", - "//obr/renderer:audio_element_type", - "@abseil-cpp//absl/strings", - "@com_google_googletest//:gtest_main", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/common/BUILD b/src/renderer/obr/obr_capi/obr/obr/common/BUILD deleted file mode 100644 index b20ef17..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/common/BUILD +++ /dev/null @@ -1,35 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "common", - hdrs = [ - "ambisonic_utils.h", - "constants.h", - "misc_math.h", - "status_macros.h", - ], - deps = [ - "@abseil-cpp//absl/log:absl_check", - "@eigen", - ], -) - -cc_library( - name = "test_util", - testonly = True, - srcs = [ - "test_util.cc", - ], - hdrs = [ - "test_util.h", - ], - deps = [ - ":common", - "//obr/ambisonic_encoder", - "//obr/audio_buffer", - "@abseil-cpp//absl/log:absl_check", - "@com_google_googletest//:gtest", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/common/tests/BUILD b/src/renderer/obr/obr_capi/obr/obr/common/tests/BUILD deleted file mode 100644 index 1bc40de..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/common/tests/BUILD +++ /dev/null @@ -1,30 +0,0 @@ -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -cc_test( - name = "ambisonic_utils_test", - srcs = ["ambisonic_utils_test.cc"], - deps = [ - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "misc_math_test", - srcs = ["misc_math_test.cc"], - deps = [ - "//obr/common", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "test_util_test", - srcs = ["test_util_test.cc"], - deps = [ - "//obr/audio_buffer", - "//obr/common", - "//obr/common:test_util", - "@com_google_googletest//:gtest_main", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/peak_limiter/BUILD b/src/renderer/obr/obr_capi/obr/obr/peak_limiter/BUILD deleted file mode 100644 index ae5dbd4..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/peak_limiter/BUILD +++ /dev/null @@ -1,14 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "peak_limiter", - srcs = ["peak_limiter.cc"], - hdrs = ["peak_limiter.h"], - visibility = ["//visibility:public"], - deps = [ - "//obr/audio_buffer", - "@abseil-cpp//absl/log:absl_check", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/renderer/BUILD b/src/renderer/obr/obr_capi/obr/obr/renderer/BUILD deleted file mode 100644 index 9d46883..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/renderer/BUILD +++ /dev/null @@ -1,108 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") - -package(default_visibility = ["//obr:__subpackages__"]) - -cc_library( - name = "audio_element_config", - srcs = ["audio_element_config.cc"], - hdrs = ["audio_element_config.h"], - deps = [ - ":audio_element_type", - ":input_channel_config", - ":loudspeaker_layouts", - "//obr/common", - "@abseil-cpp//absl/container:btree", - "@abseil-cpp//absl/log:absl_check", - "@abseil-cpp//absl/log:absl_log", - "@abseil-cpp//absl/status", - "@abseil-cpp//absl/status:statusor", - "@abseil-cpp//absl/strings", - ], -) - -cc_library( - name = "audio_element_type", - hdrs = ["audio_element_type.h"], - visibility = ["//visibility:public"], - deps = [ - "@abseil-cpp//absl/base:no_destructor", - "@abseil-cpp//absl/container:btree", - "@abseil-cpp//absl/status", - "@abseil-cpp//absl/status:statusor", - "@abseil-cpp//absl/strings", - ], -) - -cc_library( - name = "audio_element_table_formatter", - srcs = ["audio_element_table_formatter.cc"], - hdrs = ["audio_element_table_formatter.h"], - deps = [ - ":audio_element_config", - ":audio_element_type", - "@abseil-cpp//absl/log:absl_log", - ], -) - -cc_library( - name = "processing_group", - srcs = ["processing_group.cc"], - hdrs = ["processing_group.h"], - deps = [ - ":audio_element_config", - ":audio_element_type", - "//obr/ambisonic_binaural_decoder", - "//obr/ambisonic_binaural_decoder:fft_manager", - "//obr/ambisonic_binaural_decoder:resampler", - "//obr/ambisonic_binaural_decoder:sh_hrir_creator", - "//obr/ambisonic_encoder", - "//obr/ambisonic_rotator", - "//obr/audio_buffer", - "//obr/common", - "@abseil-cpp//absl/log:absl_check", - "@abseil-cpp//absl/log:absl_log", - "@abseil-cpp//absl/status", - "@abseil-cpp//absl/strings", - ], -) - -cc_library( - name = "obr_impl", - srcs = [ - "obr_impl.cc", - ], - hdrs = ["obr_impl.h"], - visibility = ["//visibility:public"], - deps = [ - ":audio_element_config", - ":audio_element_table_formatter", - ":audio_element_type", - ":processing_group", - "//obr/ambisonic_binaural_decoder:fft_manager", - "//obr/ambisonic_binaural_decoder:resampler", - "//obr/audio_buffer", - "//obr/common", - "//obr/peak_limiter", - "@abseil-cpp//absl/log:absl_check", - "@abseil-cpp//absl/log:absl_log", - "@abseil-cpp//absl/status", - "@abseil-cpp//absl/synchronization", - ], -) - -cc_library( - name = "input_channel_config", - hdrs = ["input_channel_config.h"], - deps = [], -) - -cc_library( - name = "loudspeaker_layouts", - hdrs = ["loudspeaker_layouts.h"], - deps = [ - ":audio_element_type", - ":input_channel_config", - "@abseil-cpp//absl/container:flat_hash_map", - "@abseil-cpp//absl/log:absl_log", - ], -) diff --git a/src/renderer/obr/obr_capi/obr/obr/renderer/processing_group.cc b/src/renderer/obr/obr_capi/obr/obr/renderer/processing_group.cc index b432715..da3ea3a 100644 --- a/src/renderer/obr/obr_capi/obr/obr/renderer/processing_group.cc +++ b/src/renderer/obr/obr_capi/obr/obr/renderer/processing_group.cc @@ -182,6 +182,11 @@ void ProcessingGroup::Process( // Pass world-locked Ambisonic mix bed through Ambisonic Rotator. ambisonic_rotator_->Process(world_rotation, ambisonic_mix_bed_, &ambisonic_mix_bed_); + } else { + // The block is rendered unrotated; let the rotator know so a later + // rotation change slerps from the audible identity rotation instead of + // snapping. + ambisonic_rotator_->MarkAudioRendered(); } // Sum both beds: combine world-locked (possibly rotated) and head-locked. diff --git a/src/renderer/obr/obr_capi/obr/obr/renderer/tests/BUILD b/src/renderer/obr/obr_capi/obr/obr/renderer/tests/BUILD deleted file mode 100644 index 390d20d..0000000 --- a/src/renderer/obr/obr_capi/obr/obr/renderer/tests/BUILD +++ /dev/null @@ -1,63 +0,0 @@ -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -cc_test( - name = "audio_element_config_test", - srcs = ["audio_element_config_test.cc"], - deps = [ - "//obr/renderer:audio_element_config", - "//obr/renderer:audio_element_type", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "audio_element_type_test", - srcs = ["audio_element_type_test.cc"], - deps = [ - "//obr/renderer:audio_element_type", - "@abseil-cpp//absl/status:status_matchers", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "obr_impl_test", - srcs = ["obr_impl_test.cc"], - deps = [ - "//obr/audio_buffer", - "//obr/common:test_util", - "//obr/renderer:audio_element_config", - "//obr/renderer:audio_element_type", - "//obr/renderer:obr_impl", - "@abseil-cpp//absl/status", - "@abseil-cpp//absl/status:status_matchers", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "processing_group_test", - srcs = ["processing_group_test.cc"], - deps = [ - "//obr/ambisonic_binaural_decoder:fft_manager", - "//obr/ambisonic_binaural_decoder:resampler", - "//obr/audio_buffer", - "//obr/common", - "//obr/common:test_util", - "//obr/renderer:audio_element_config", - "//obr/renderer:audio_element_type", - "//obr/renderer:processing_group", - "@abseil-cpp//absl/status:status_matchers", - "@com_google_googletest//:gtest_main", - ], -) - -cc_test( - name = "loudspeaker_layouts_test", - srcs = ["loudspeaker_layouts_test.cc"], - deps = [ - "//obr/renderer:audio_element_type", - "//obr/renderer:loudspeaker_layouts", - "@com_google_googletest//:gtest_main", - ], -) diff --git a/src/renderer/olr/object_audio_renderer/gain_calculator/vbap_panner_2d.c b/src/renderer/olr/object_audio_renderer/gain_calculator/vbap_panner_2d.c index c8edba4..9eade39 100644 --- a/src/renderer/olr/object_audio_renderer/gain_calculator/vbap_panner_2d.c +++ b/src/renderer/olr/object_audio_renderer/gain_calculator/vbap_panner_2d.c @@ -334,7 +334,11 @@ static int _vbap_panner_2d_calculate_gains(vbap_panner_2d_t *self, for (int j = 0; j < r; ++j) { speaker_position_t *sp = def_value_wrap_ptr(vector_at(self->target_speakers, j)); - az_diff = nc_abs(sp->azimuth - azimuth); + // Circular distance: a linear difference is wrong whenever the true + // angular separation crosses the +/-180 seam (e.g. -170 vs +135 is + // 55 degrees apart, not 305). + az_diff = nc_fmod(nc_abs(sp->azimuth - azimuth), 360.0); + if (az_diff > 180.0) az_diff = 360.0 - az_diff; if (az_diff < min_diff) { min_diff = az_diff; closest_spk_idx = j; diff --git a/src/renderer/olr/object_audio_renderer/numc.h b/src/renderer/olr/object_audio_renderer/numc.h index ba09914..fe1da86 100644 --- a/src/renderer/olr/object_audio_renderer/numc.h +++ b/src/renderer/olr/object_audio_renderer/numc.h @@ -24,6 +24,7 @@ #define nc_arctan2 atan2 #define nc_cos cos #define nc_sin sin +#define nc_fmod fmod #else #define nc_abs fabsf #define nc_tan tanf @@ -31,6 +32,7 @@ #define nc_arctan2 atan2f #define nc_cos cosf #define nc_sin sinf +#define nc_fmod fmodf #endif auto_float_t nc_radians(auto_float_t deg); diff --git a/src/renderer/olr/olr.c b/src/renderer/olr/olr.c index d38b667..5b646dd 100644 --- a/src/renderer/olr/olr.c +++ b/src/renderer/olr/olr.c @@ -12,6 +12,7 @@ #include "olr.h" +#include #include #include "clog.h" @@ -158,6 +159,19 @@ static int _set_attribute(renderer_library_context_t *ctx, return ck_oar_ok; } +// Wrap an azimuth into the canonical (-180, 180] range so downstream gain +// calculators never see aliased angles (e.g. +270 for -90): the front-back +// fold and the closest-speaker fallback only handle canonical values. +static float __wrap_azimuth(float azimuth) { + azimuth = fmodf(azimuth, 360.f); + if (azimuth > 180.f) { + azimuth -= 360.f; + } else if (azimuth <= -180.f) { + azimuth += 360.f; + } + return azimuth; +} + static int _metadata_update(renderer_library_context_t *ctx, uint32_t index, const oar_metadata_t *metadata) { olr_context_t *olr = ctx->renderer; @@ -186,8 +200,8 @@ static int _metadata_update(renderer_library_context_t *ctx, uint32_t index, metadata->duration > 0 ? metadata->duration : 0; olr->metadata_block_durations[i] += olr->metadata_blocks[i].duration; - olr->metadata_blocks[i].azimuth = - metadata->object_positions.polar_positions[i].azimuth; + olr->metadata_blocks[i].azimuth = __wrap_azimuth( + metadata->object_positions.polar_positions[i].azimuth); olr->metadata_blocks[i].elevation = metadata->object_positions.polar_positions[i].elevation; olr->metadata_blocks[i].distance = diff --git a/tests/examples/CMakeLists.txt b/tests/examples/CMakeLists.txt index 01e1881..0d26327 100644 --- a/tests/examples/CMakeLists.txt +++ b/tests/examples/CMakeLists.txt @@ -12,6 +12,9 @@ add_executable(test_scene_based_rendering test_scene_based_rendering.c) # Add the test for object-based rendering add_executable(test_object_based_rendering test_object_based_rendering.c) +# Add the regression test for out-of-range object azimuths +add_executable(test_object_azimuth_wrapping test_object_azimuth_wrapping.c) + # Link the tests against the main oar library. # The 'oar' target is defined in the parent CMakeLists.txt, # so it's accessible here. @@ -19,6 +22,7 @@ target_link_libraries(test_audio_element_types PRIVATE oar) target_link_libraries(test_channel_based_rendering PRIVATE oar) target_link_libraries(test_scene_based_rendering PRIVATE oar) target_link_libraries(test_object_based_rendering PRIVATE oar) +target_link_libraries(test_object_azimuth_wrapping PRIVATE oar) # Include directories are inherited from the parent project, # so oar.h and other headers should be found automatically. @@ -28,10 +32,12 @@ set_property(TARGET test_audio_element_types PROPERTY C_STANDARD 99) set_property(TARGET test_channel_based_rendering PROPERTY C_STANDARD 99) set_property(TARGET test_scene_based_rendering PROPERTY C_STANDARD 99) set_property(TARGET test_object_based_rendering PROPERTY C_STANDARD 99) +set_property(TARGET test_object_azimuth_wrapping PROPERTY C_STANDARD 99) if(NOT MSVC) target_link_libraries(test_channel_based_rendering PRIVATE m) target_link_libraries(test_scene_based_rendering PRIVATE m) target_link_libraries(test_object_based_rendering PRIVATE m) + target_link_libraries(test_object_azimuth_wrapping PRIVATE m) endif() # The HOA LFE test exercises the 120 Hz LFE low-pass, which is only compiled @@ -44,3 +50,13 @@ if(OAR_ENABLE_HOA_LFE) endif() set_property(TARGET test_hoa_lfe_rendering PROPERTY C_STANDARD 99) endif() + +# Register every example in this directory with ctest under the "oar" label +# (mirroring the "obr" label in the obr subtree). Derived from the directory's +# target list rather than a hardcoded list so new examples cannot be silently +# left out of `ctest`. +get_directory_property(OAR_EXAMPLE_TESTS BUILDSYSTEM_TARGETS) +foreach(example_test IN LISTS OAR_EXAMPLE_TESTS) + add_test(NAME ${example_test} COMMAND ${example_test}) + set_tests_properties(${example_test} PROPERTIES LABELS oar) +endforeach() diff --git a/tests/examples/test_object_azimuth_wrapping.c b/tests/examples/test_object_azimuth_wrapping.c new file mode 100644 index 0000000..be5ca39 --- /dev/null +++ b/tests/examples/test_object_azimuth_wrapping.c @@ -0,0 +1,232 @@ +/* + * 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. + */ + +/* + * Regression test for out-of-range object azimuths in the OLR. + * + * An azimuth outside (-180, 180] is an alias of a canonical angle (+270 is + * -90). The renderer used to pass such angles through unfolded, and the + * closest-speaker fallback compared angles linearly, so on a stereo layout a + * source at +270 collapsed onto the LEFT speaker even though -90 is on the + * right. This test renders a sine at aliased azimuth pairs and requires + * bit-identical output, plus checks that lateral sources land on opposite + * output channels. + */ + +#include +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +#include "oar.h" + +#define SAMPLES_PER_CHANNEL 256 +#define SAMPLE_RATE 48000 + +// Renders one 440 Hz object at the given azimuth to a stereo output block. +// Returns 0 on success; the caller owns out->data. +static int render_object_at_azimuth(float azimuth, oar_audio_block_t* out) { + oar_config_t cfg; + cfg.target_layout = ck_oar_layout_stereo; + cfg.samples_per_channel = SAMPLES_PER_CHANNEL; + cfg.sampling_rate = SAMPLE_RATE; + + oar_t* oar = oar_create(&cfg); + if (!oar) { + fprintf(stderr, "oar_create failed\n"); + return -1; + } + + int group_id = oar_add_audio_group(oar); + if (group_id < 0) { + fprintf(stderr, "oar_add_audio_group failed: %d\n", group_id); + oar_destroy(oar); + return -1; + } + + oar_audio_element_config_t element_cfg; + memset(&element_cfg, 0, sizeof(element_cfg)); + element_cfg.type = ck_object_based; + element_cfg.obc.num_objects = 1; + + int ret = oar_add_audio_element(oar, group_id, 1, &element_cfg); + if (ret != 0) { + fprintf(stderr, "oar_add_audio_element failed: %d\n", ret); + oar_destroy(oar); + return -1; + } + + float input[SAMPLES_PER_CHANNEL]; + for (int i = 0; i < SAMPLES_PER_CHANNEL; ++i) { + input[i] = (float)sin(2.0 * M_PI * 440.0 * i / SAMPLE_RATE); + } + + oar_audio_block_t input_block; + input_block.channels = 1; + input_block.samples_per_channel = SAMPLES_PER_CHANNEL; + input_block.data = input; + ret = oar_update_audio_element_data(oar, 1, &input_block); + if (ret != 0) { + fprintf(stderr, "oar_update_audio_element_data failed: %d\n", ret); + oar_destroy(oar); + return -1; + } + + oar_metadata_t metadata; + memset(&metadata, 0, sizeof(metadata)); + metadata.type = ck_metadata_object_positions; + metadata.duration = SAMPLES_PER_CHANNEL; + metadata.object_positions.param_type = ck_param_constant; + metadata.object_positions.position_type = ck_polar; + metadata.object_positions.num_objects = 1; + metadata.object_positions.polar_positions[0].azimuth = azimuth; + metadata.object_positions.polar_positions[0].elevation = 0.f; + metadata.object_positions.polar_positions[0].distance = 1.f; + ret = oar_update_audio_element_metadata(oar, 1, &metadata); + if (ret != 0) { + fprintf(stderr, "oar_update_audio_element_metadata failed: %d\n", ret); + oar_destroy(oar); + return -1; + } + + out->channels = oar_get_number_of_output_channels(oar); + out->samples_per_channel = oar_get_samples_per_channel(oar); + out->data = (float*)calloc(out->channels * out->samples_per_channel, + sizeof(float)); + if (!out->data) { + oar_destroy(oar); + return -1; + } + + ret = oar_render(oar, out); + oar_destroy(oar); + if (ret != 0) { + fprintf(stderr, "oar_render failed: %d\n", ret); + free(out->data); + out->data = NULL; + return -1; + } + return 0; +} + +static double channel_energy(const oar_audio_block_t* block, uint32_t channel) { + double energy = 0.0; + const float* p = block->data + channel * block->samples_per_channel; + for (uint32_t i = 0; i < block->samples_per_channel; ++i) { + energy += (double)p[i] * p[i]; + } + return energy; +} + +static double max_abs_difference(const oar_audio_block_t* a, + const oar_audio_block_t* b) { + double max_diff = 0.0; + uint32_t n = a->channels * a->samples_per_channel; + for (uint32_t i = 0; i < n; ++i) { + double diff = fabs((double)a->data[i] - b->data[i]); + if (diff > max_diff) max_diff = diff; + } + return max_diff; +} + +// Renders `azimuth` and its alias `azimuth + turns*360` and requires the two +// outputs to be identical. Returns 0 on pass. +static int check_alias_pair(float azimuth, int turns) { + float alias = azimuth + 360.f * turns; + oar_audio_block_t ref = {0}, aliased = {0}; + int failed = 0; + + if (render_object_at_azimuth(azimuth, &ref) != 0 || + render_object_at_azimuth(alias, &aliased) != 0) { + free(ref.data); + free(aliased.data); + return 1; + } + + if (channel_energy(&ref, 0) + channel_energy(&ref, 1) < 1e-6) { + fprintf(stderr, "FAIL: azimuth %.1f rendered silence\n", azimuth); + failed = 1; + } + + double diff = max_abs_difference(&ref, &aliased); + if (diff > 1e-6) { + fprintf(stderr, + "FAIL: azimuth %.1f and alias %.1f differ (max abs diff %g)\n", + azimuth, alias, diff); + failed = 1; + } else { + printf("PASS: azimuth %.1f == alias %.1f (max abs diff %g)\n", azimuth, + alias, diff); + } + + free(ref.data); + free(aliased.data); + return failed; +} + +// Renders two laterally opposed azimuths and requires their dominant output +// channels to be opposite (each side collapses onto its closest speaker). +static int check_opposite_sides(float azimuth_a, float azimuth_b) { + oar_audio_block_t a = {0}, b = {0}; + int failed = 0; + + if (render_object_at_azimuth(azimuth_a, &a) != 0 || + render_object_at_azimuth(azimuth_b, &b) != 0) { + free(a.data); + free(b.data); + return 1; + } + + int dominant_a = channel_energy(&a, 0) > channel_energy(&a, 1) ? 0 : 1; + int dominant_b = channel_energy(&b, 0) > channel_energy(&b, 1) ? 0 : 1; + if (dominant_a == dominant_b) { + fprintf(stderr, + "FAIL: azimuths %.1f and %.1f both favor output channel %d\n", + azimuth_a, azimuth_b, dominant_a); + failed = 1; + } else { + printf("PASS: azimuth %.1f favors channel %d, azimuth %.1f favors %d\n", + azimuth_a, dominant_a, azimuth_b, dominant_b); + } + + free(a.data); + free(b.data); + return failed; +} + +int main(void) { + int failures = 0; + + printf("Object azimuth wrapping test\n"); + + // Aliased angles must render identically to their canonical forms. + failures += check_alias_pair(-90.f, 1); // +270 == -90 + failures += check_alias_pair(90.f, 1); // +450 == +90 + failures += check_alias_pair(-170.f, 1); // +190 == -170 + failures += check_alias_pair(30.f, -2); // -690 == +30 + + // Lateral sources must collapse onto opposite stereo speakers; before the + // fix, +270 landed on the same (left) speaker as +90. + failures += check_opposite_sides(90.f, -90.f); + failures += check_opposite_sides(90.f, 270.f); + + if (failures) { + fprintf(stderr, "\n%d azimuth wrapping check(s) FAILED\n", failures); + return 1; + } + printf("\nAll azimuth wrapping checks passed.\n"); + return 0; +} diff --git a/third_party/BUILD.bazel b/third_party/BUILD.bazel new file mode 100644 index 0000000..0c44d4d --- /dev/null +++ b/third_party/BUILD.bazel @@ -0,0 +1,4 @@ +# Marks this directory as a Bazel package so pffft.BUILD can be referenced +# by `@oar//third_party:pffft.BUILD` from the module extension. (Bazel 9 +# reserves the top-level `external` package name, so the shim lives here.) +exports_files(["pffft.BUILD"]) diff --git a/third_party/pffft.BUILD b/third_party/pffft.BUILD new file mode 100644 index 0000000..30ae693 --- /dev/null +++ b/third_party/pffft.BUILD @@ -0,0 +1,8 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +cc_library( + name = "pffft", + srcs = ["pffft.c"], + hdrs = ["pffft.h"], + visibility = ["//visibility:public"], +)