diff --git a/.clang-format-ignore b/.clang-format-ignore index cc45386..60f8a03 100644 --- a/.clang-format-ignore +++ b/.clang-format-ignore @@ -2,3 +2,4 @@ include/ambitap/math/binaural/hrtf_data.h include/ambitap/math/geometry/tdesigns.h third_party/* +bench/compare/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e445fc..4911113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: clang-format-18 --dry-run --Werror \ $(git ls-files '*.h' '*.cpp' \ ':!:third_party/**' \ + ':!:bench/compare/**' \ ':!:include/ambitap/math/binaural/hrtf_data.h' \ ':!:include/ambitap/math/geometry/tdesigns.h') @@ -124,6 +125,40 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure + fuzz: + # The SOFA reader is the library's only untrusted-file surface. Build the + # libFuzzer harness (Clang + ASan/UBSan) and run it for a bounded time + # over the checked-in seed corpus — a regression gate that replays known + # crash reproducers and does light exploratory fuzzing on every push. + # For deep campaigns, run tests/fuzz/fuzz_sofa_reader locally for hours. + name: fuzz (sofa reader smoke) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install Clang + sanitizer/fuzzer runtimes + run: > + sudo apt-get update && + sudo apt-get install -y clang-18 libclang-rt-18-dev + + - name: Configure (Clang + SOFA + fuzzers) + run: > + cmake -B build -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 + -DAMBITAP_ENABLE_SOFA=ON -DAMBITAP_BUILD_FUZZERS=ON + -DAMBITAP_BUILD_TESTS=OFF -DAMBITAP_INSTALL=OFF + + - name: Build the fuzzer + run: cmake --build build --target fuzz_sofa_reader --parallel + + - name: Fuzz over the seed corpus (bounded) + run: > + ASAN_OPTIONS=detect_leaks=0 + ./build/tests/fuzz/fuzz_sofa_reader + -runs=100000 -max_total_time=180 -rss_limit_mb=4096 -close_fd_mask=3 + tests/fuzz/corpus + embedded: # The embedded real-time profile must stay bare-metal-clean: the RT # subset (tools/embedded/rt_core_check.cpp documents it) cross-compiles diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ab72fc1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,54 @@ +name: Docs + +# Build the Doxygen API site and publish it to GitHub Pages on every push to +# main. On pull requests, build only (no deploy) so doc breakage is caught in +# review without touching the live site. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; a newer push supersedes an in-flight one. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: build docs + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install Doxygen + Graphviz + run: sudo apt-get update && sudo apt-get install -y doxygen graphviz + + - name: Build the API site + run: doxygen docs/Doxyfile + + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: docs/html + + deploy: + name: deploy to Pages + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 077843c..8d5f294 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ CMakeUserPresets.json .DS_Store __pycache__/ *.wav + +# Generated documentation site (built by docs/Doxyfile, deployed by CI) +docs/html/ + +# Cross-library benchmark checkouts (run.sh clones libspatialaudio + SAF here) +bench/compare/_work/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 393a699..631eb0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,7 @@ option(AMBITAP_BUILD_TESTS "Build AmbiTap unit tests" ${_ambitap_dev_default}) option(AMBITAP_BUILD_EXAMPLES "Build AmbiTap example programs" ${_ambitap_dev_default}) option(AMBITAP_BUILD_BENCH "Build AmbiTap benchmarks" OFF) option(AMBITAP_BUILD_CAPI "Build the C ABI shared library (language bindings, notebooks)" OFF) +option(AMBITAP_BUILD_FUZZERS "Build libFuzzer harnesses (needs Clang + AMBITAP_ENABLE_SOFA)" OFF) # ------------------------------------------------------------------------------ # Warnings (applied to this project's own executables — tests, examples, @@ -208,3 +209,6 @@ endif() if(AMBITAP_BUILD_CAPI) add_subdirectory(tools/capi) endif() +if(AMBITAP_BUILD_FUZZERS) + add_subdirectory(tests/fuzz) +endif() diff --git a/README.md b/README.md index 9aba228..694f755 100644 --- a/README.md +++ b/README.md @@ -179,12 +179,20 @@ HRTF tables and a `tools/hrtf_trim` order-trimmed build. Profile definition, per-order cycle/memory budgets, and AudioReach integration notes live in [`docs/EMBEDDED.md`](docs/EMBEDDED.md). -## Audit - -A full correctness and quality audit (with its remediation status) lives in -[`docs/AUDIT.md`](docs/AUDIT.md). A cross-library comparison (correctness, -features, performance vs spaudiopy, pyshtools, and the C++ ecosystem) lives -in [`docs/COMPARISON.md`](docs/COMPARISON.md). +## Documentation + +- [`docs/CONCEPTS.md`](docs/CONCEPTS.md) — conventions, the real-time + contract, and the processor lifecycle: read this first. +- Generated API reference — `doxygen docs/Doxyfile` (published to GitHub + Pages from `main`). +- [`docs/AUDIT.md`](docs/AUDIT.md) — the full correctness/quality audit and + its remediation ledger. +- [`docs/COMPARISON.md`](docs/COMPARISON.md) — cross-library comparison: + correctness cross-checks (spaudiopy, pyshtools), a measured C++ + head-to-head vs libspatialaudio and SAF (`bench/compare/`), and a + feature/platform matrix across the ecosystem. +- [`docs/EMBEDDED.md`](docs/EMBEDDED.md) — the embedded real-time profile, + budgets, and AudioReach notes. ## License diff --git a/bench/compare/README.md b/bench/compare/README.md new file mode 100644 index 0000000..8c2fed8 --- /dev/null +++ b/bench/compare/README.md @@ -0,0 +1,52 @@ +# Cross-library C++ benchmarks + +Head-to-head runtime measurements of AmbiTap against **libspatialaudio** +and the **Spatial_Audio_Framework (SAF)**, feeding the "Measured C++ +head-to-head" section of [`docs/COMPARISON.md`](../../docs/COMPARISON.md). + +## What runs + +| Harness | Library | Processors timed | +|---|---|---| +| `../../bench/` (in-tree) | AmbiTap | encoder, rotator, decoder (cube), binaural — orders 1/3/5 | +| `bench_libspatialaudio.cpp` | libspatialaudio | `AmbisonicEncoder`, `AmbisonicRotator`, `AmbisonicDecoder` (cube), `AmbisonicBinauralizer` (built-in MIT HRTF) — orders 1/2/3 | +| `bench_saf.c` | SAF | `ambi_enc`, `rotator`, `ambi_dec` (8-speaker preset), `ambi_bin` — orders 1/3/5 | + +Shared methodology: 48 kHz, median of 9 runs × 400 blocks, 50 warm-up +blocks, single thread. AmbiTap and libspatialaudio process 64-sample +blocks directly. SAF's `ambi_dec`/`ambi_bin` process at their compiled +128-sample frame; the harness reports both raw µs/frame and the +normalized µs-per-64-samples used in the comparison tables. + +## Known asymmetries (read before quoting numbers) + +- SAF's decoder and binauralizer are **time–frequency (afSTFT)** + processors — the filterbank cost buys frequency-dependent decoding + that the broadband matrix decoders in AmbiTap and libspatialaudio do + not attempt. Its time-domain encoder/rotator are directly comparable. +- libspatialaudio's bundled MIT HRTF binauralizer only configures at + order 1 (`Configure` returns false at 2–3 without a SOFA file); the + harness reports this rather than substituting a different path. +- SAF is built with its default `SAF_ENABLE_SIMD=OFF` and the OpenBLAS + backend; enabling SIMD or MKL may improve its numbers. +- All three include their own parameter-smoothing/fade machinery in the + timed paths; none was disabled. + +## Requirements + +- CMake ≥ 3.24, a C++20 compiler, git +- SAF backend: `apt install libopenblas-dev liblapacke-dev` (or point + SAF at MKL / Accelerate per its docs) +- Eigen for the AmbiTap build (found or fetched automatically) + +## Run + +```bash +bench/compare/run.sh +``` + +Clones both libraries into `bench/compare/_work/` (git-ignored), builds +everything Release, and prints all three benchmark reports. Pinned +versions for the numbers in COMPARISON.md: libspatialaudio 0.4.1, +SAF v1.3.5 (master @ 2026-01), captured 2026-07-03 on a single-core +Intel Xeon @ 2.10 GHz, GCC 13.3, Ubuntu 24.04. diff --git a/bench/compare/bench_libspatialaudio.cpp b/bench/compare/bench_libspatialaudio.cpp new file mode 100644 index 0000000..512d66e --- /dev/null +++ b/bench/compare/bench_libspatialaudio.cpp @@ -0,0 +1,74 @@ +// libspatialaudio benchmark: encoder, rotator (AmbisonicProcessor), +// decoder (cube), binauralizer. 64-sample blocks @48kHz, median of 9x400. +#include +#include +#include +#include +#include +#include +#include "Ambisonics.h" + +using namespace spaudio; +static double now_us(){ timespec ts; clock_gettime(CLOCK_MONOTONIC,&ts); + return ts.tv_sec*1e6+ts.tv_nsec/1e3; } +#define RUNS 9 +#define BLOCKS 400 +#define NF 64 +#define SR 48000 + +template double bench(F f){ + std::vector runs; + for(int r=0;r tmp(NF); + for(int i=0;i pos; pos.azimuth=0.6f; pos.elevation=0.3f; pos.distance=1.f; + enc.SetPosition(pos); enc.Refresh(); + for(int w=0;w<50;w++) enc.Process(src,NF,&bf); + double us=bench([&]{ enc.Process(src,NF,&bf); }); + printf("encoder order %d %8.2f us/block\n",order,us); } + + { AmbisonicRotator rot; rot.Configure(order,true,NF,SR,10.f); + RotationOrientation o; o.yaw=0.6; o.pitch=0.2; o.roll=0.1; rot.SetOrientation(o); + for(int w=0;w<50;w++) rot.Process(&bf,NF); + double us=bench([&]{ rot.Process(&bf,NF); }); + printf("rotator order %d %8.2f us/block\n",order,us); } + + { AmbisonicDecoder dec; + dec.Configure(order,true,NF,SR,Amblib_SpeakerSetUps::kAmblib_Cube); + dec.Refresh(); + float** out=new float*[8]; for(int i=0;i<8;i++) out[i]=new float[NF]; + for(int w=0;w<50;w++) dec.Process(&bf,NF,out); + double us=bench([&]{ dec.Process(&bf,NF,out); }); + printf("decoder/cube order %d %8.2f us/block\n",order,us); } + + { AmbisonicBinauralizer bin; unsigned tail=0; + bool ok=bin.Configure(order,true,SR,NF,tail,"",true /*lowCpuMode*/); + if(!ok){ printf("binaural order %d (built-in HRTF unsupported at this order)\n",order); } + else { + float** out=new float*[2]; for(int i=0;i<2;i++) out[i]=new float[NF]; + for(int w=0;w<50;w++) bin.Process(&bf,out); + double us=bench([&]{ bin.Process(&bf,out); }); + printf("binaural order %d %8.2f us/block (tail %u)\n",order,us,tail); } + } + printf("\n"); + } + return 0; +} diff --git a/bench/compare/bench_saf.c b/bench/compare/bench_saf.c new file mode 100644 index 0000000..336e99d --- /dev/null +++ b/bench/compare/bench_saf.c @@ -0,0 +1,108 @@ +/* SAF benchmark: ambi_enc, rotator, ambi_dec, ambi_bin at orders 1/3/5. + * Methodology mirrors AmbiTap bench/: 48 kHz, per-block timing, median of + * 9 runs x 400 blocks. SAF examples process at their compiled frame size + * (reported); results are normalized to us per 64-sample block for + * comparison. */ +#include +#include +#include +#include +#include +#include "ambi_enc.h" +#include "ambi_dec.h" +#include "ambi_bin.h" +#include "rotator.h" +#include "_common.h" + +#define SR 48000.0f +#define RUNS 9 +#define BLOCKS 400 +#define MAXCH 64 + +static double now_us(void){ + struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec*1e6 + ts.tv_nsec/1e3; +} +static int cmpd(const void*a,const void*b){double d=*(double*)a-*(double*)b;return d<0?-1:d>0;} +static double median(double*v,int n){qsort(v,n,sizeof(double),cmpd);return v[n/2];} + +static float* bufs[MAXCH]; static float* obufs[MAXCH]; +static void alloc_bufs(int frame){ + for(int i=0;i nsh */ + { void*h; ambi_enc_create(&h); ambi_enc_init(h,SR); + ambi_enc_setOutputOrder(h,o); ambi_enc_setNumSources(h,1); + int f=ambi_enc_getFrameSize(); + /* warmup */ for(int w=0;w<50;w++) ambi_enc_process(h,(const float*const*)bufs,obufs,1,nsh,f); + double us=bench(h,ambi_enc_process,1,nsh,f); + printf("encoder order %d frame %3d %8.2f us/frame %8.2f us per 64\n",onum[i],f,us,us*64.0/f); + ambi_enc_destroy(&h); } + + /* rotator */ + { void*h; rotator_create(&h); rotator_init(h,SR); + rotator_setOrder(h,o); rotator_setYaw(h,37.0f); rotator_setPitch(h,10.0f); + int f=rotator_getFrameSize(); + for(int w=0;w<50;w++) rotator_process(h,(const float*const*)bufs,obufs,nsh,nsh,f); + double us=bench(h,rotator_process,nsh,nsh,f); + printf("rotator order %d frame %3d %8.2f us/frame %8.2f us per 64\n",onum[i],f,us,us*64.0/f); + rotator_destroy(&h); } + + /* decoder: 8-speaker preset */ + { void*h; ambi_dec_create(&h); ambi_dec_init(h,SR); + ambi_dec_setMasterDecOrder(h,o); + ambi_dec_setOutputConfigPreset(h,LOUDSPEAKER_ARRAY_PRESET_8PX); + ambi_dec_initCodec(h); + while(ambi_dec_getCodecStatus(h)!=CODEC_STATUS_INITIALISED){ + ambi_dec_initCodec(h); } + int f=ambi_dec_getFrameSize(); + for(int w=0;w<50;w++) ambi_dec_process(h,(const float*const*)bufs,obufs,nsh,8,f); + double us=bench(h,ambi_dec_process,nsh,8,f); + printf("decoder/8 order %d frame %3d %8.2f us/frame %8.2f us per 64\n",onum[i],f,us,us*64.0/f); + ambi_dec_destroy(&h); } + + /* binaural */ + { void*h; ambi_bin_create(&h); ambi_bin_init(h,SR); + ambi_bin_setInputOrderPreset(h,o); + ambi_bin_initCodec(h); + while(ambi_bin_getCodecStatus(h)!=CODEC_STATUS_INITIALISED){ + ambi_bin_initCodec(h); } + int f=ambi_bin_getFrameSize(); + for(int w=0;w<50;w++) ambi_bin_process(h,(const float*const*)bufs,obufs,nsh,2,f); + double us=bench(h,ambi_bin_process,nsh,2,f); + printf("binaural order %d frame %3d %8.2f us/frame %8.2f us per 64\n",onum[i],f,us,us*64.0/f); + ambi_bin_destroy(&h); } + printf("\n"); + } + return 0; +} diff --git a/bench/compare/run.sh b/bench/compare/run.sh new file mode 100755 index 0000000..bc15864 --- /dev/null +++ b/bench/compare/run.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Cross-library C++ benchmark: AmbiTap vs libspatialaudio vs SAF. +# Clones/builds the other two libraries next to this script, compiles the +# harnesses, and runs all three benchmarks. Results feed docs/COMPARISON.md. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$HERE/../.." +WORK="$HERE/_work" +mkdir -p "$WORK" + +# --- AmbiTap (this repo) --------------------------------------------------- +cmake -S "$ROOT" -B "$WORK/ambitap-build" -DCMAKE_BUILD_TYPE=Release \ + -DAMBITAP_BUILD_BENCH=ON -DAMBITAP_BUILD_TESTS=OFF -DAMBITAP_BUILD_EXAMPLES=OFF +cmake --build "$WORK/ambitap-build" -j"$(nproc)" + +# --- libspatialaudio --------------------------------------------------------- +[ -d "$WORK/libspatialaudio" ] || git clone --depth 1 \ + https://github.com/videolabs/libspatialaudio.git "$WORK/libspatialaudio" +cmake -S "$WORK/libspatialaudio" -B "$WORK/libspatialaudio/build" -DCMAKE_BUILD_TYPE=Release +cmake --build "$WORK/libspatialaudio/build" -j"$(nproc)" +g++ -O3 -std=c++17 -o "$WORK/bench_lsa" "$HERE/bench_libspatialaudio.cpp" \ + -I "$WORK/libspatialaudio/include" -I "$WORK/libspatialaudio/build/include" \ + -L "$WORK/libspatialaudio/build" -lspatialaudio \ + -Wl,-rpath,"$WORK/libspatialaudio/build" -lm + +# --- SAF (needs OpenBLAS + LAPACKE; e.g. apt install libopenblas-dev liblapacke-dev) +[ -d "$WORK/SAF" ] || git clone --depth 1 \ + https://github.com/leomccormack/Spatial_Audio_Framework.git "$WORK/SAF" +cmake -S "$WORK/SAF" -B "$WORK/SAF/build" -DCMAKE_BUILD_TYPE=Release \ + -DSAF_PERFORMANCE_LIB=SAF_USE_OPEN_BLAS_AND_LAPACKE +cmake --build "$WORK/SAF/build" -j"$(nproc)" +gcc -O3 -o "$WORK/bench_saf" "$HERE/bench_saf.c" \ + -I "$WORK/SAF/examples/include" -I "$WORK/SAF/framework/include" \ + "$WORK/SAF/build/examples/libsaf_example_ambi_enc.a" \ + "$WORK/SAF/build/examples/libsaf_example_ambi_dec.a" \ + "$WORK/SAF/build/examples/libsaf_example_ambi_bin.a" \ + "$WORK/SAF/build/examples/libsaf_example_rotator.a" \ + "$WORK/SAF/build/framework/libsaf.a" -lopenblas -llapacke -lm -lpthread + +echo; echo "================ AmbiTap ================" +"$WORK/ambitap-build/bench/ambitap_bench" +echo; echo "============ libspatialaudio ============" +"$WORK/bench_lsa" +echo; echo "=================== SAF =================" +"$WORK/bench_saf" diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 00c0439..a52f139 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -146,6 +146,25 @@ > invariant on the pathological layouts. All notebooks re-executed against > the fixed hull; 116 tests green. > +> **SOFA reader fuzzing (follow-up):** a libFuzzer harness over the SOFA +> load + SH-decompose path (`tests/fuzz/fuzz_sofa_reader.cpp`, Clang + +> ASan/UBSan, seed corpus in `tests/fuzz/corpus/`, bounded CI smoke job) +> immediately CAUGHT A REAL BUG: `load_sofa` dereferenced `Data.IR`, +> `SourcePosition`, and `Data.SamplingRate` through the declared M/N/R +> dimensions without checking the arrays were present and sized — a +> hostile or truncated SOFA file that parsed structurally would read +> through a null or short buffer (UBSan: load of null `float*` at +> sofa_reader.h:161). Fixed by validating every dereferenced array against +> its expected element count up front; malformed inputs now throw cleanly. +> Regression coverage: `tests/test_sofa.cpp` (reject-malformed contract, +> built on the SOFA CI leg) plus the corpus reproducers. +> +> **Documentation site (follow-up):** `docs/CONCEPTS.md` (conventions, the +> real-time contract, the processor lifecycle) plus a Doxygen config +> (`docs/Doxyfile`) generating the API reference from the header +> doc-comments, published to GitHub Pages from main by `.github/workflows/ +> docs.yml` (build-only on PRs). +> > **All remediation items closed:** libmysofa is pinned to the v1.3.4 commit > SHA (resolved by the author); the one-time `clang-format` reformat landed > with a config verified idempotent against the whole tree, and CI now diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 5c1c750..4a2d9a2 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -4,18 +4,22 @@ A comparison of AmbiTap against other open implementations on three axes: **correctness** (do independent codebases compute the same numbers?), **feature set**, and **performance** of the major algorithms. -Two kinds of evidence, kept separate: +Three kinds of evidence, kept separate: -- **Measured** — produced by +- **Measured (Python cross-checks)** — produced by [`notebooks/library_comparison.ipynb`](../notebooks/library_comparison.ipynb), which drives the compiled C++ through the C ABI against **spaudiopy 0.2.0** (independent Python implementation: SH, Wigner-D rotation, VBAP, ALLRAD/EPAD/mode-matching) and **pyshtools 4.14.1** (the geodesy community's SH reference), with SciPy as a third SH source. Every number below re-checks on each notebook run. -- **Documentation-based** — the feature matrix rows for C++ libraries that - could not be built in this environment; accurate as of their public - documentation, mid-2025. Treat as orientation, not measurement. +- **Measured (C++ head-to-head)** — produced by the harnesses in + [`bench/compare/`](../bench/compare/), which build **libspatialaudio 0.4.1** + and **SAF v1.3.5** from source and time their real-time processors on the + same machine, same compiler, same methodology as AmbiTap's own `bench/`. +- **Documentation-based** — feature-matrix cells for projects not built here + (IEM, SPARTA, Resonance); verified against their public documentation and + repositories, July 2026. Treat as orientation, not measurement. ## Correctness: measured cross-library agreement @@ -41,13 +45,16 @@ Notes: - **Rotation** is the strongest of these results: AmbiTap's Ivanic–Ruedenberg recurrence and spaudiopy's Wigner-D construction share no lineage at all, and agree to float32 precision at every order. + - **Mode-matching and EPAD are matrix-identical** across the libraries — the +1.63 dB scalar is the two projects' absolute-level conventions, each internally consistent. + - **VBAP diagonal ties**: on square faces (the cube) the two libraries pick different — equally valid — face diagonals, so gains differ while both satisfy the defining invariants exactly. On layouts with unambiguous triangulation they agree to float precision. + - **ALLRAD is the one deliberate divergence.** It depends on a virtual optimal layout (AmbiTap: Hardin–Sloane t-designs; spaudiopy: its own kernel grid) and on imaginary-speaker policy. Measured on 7.1.4 / @@ -79,12 +86,68 @@ independent-implementation comparison is for. ## Performance -**Real-time processing** (AmbiTap C++, 64-frame blocks at 48 kHz, single -core, `bench/`): order-5 binaural 25.0 µs/block (1.9% of budget), order-5 -rotation 14.3 µs, order-3 full encode–rotate–decode chain well under 1%. -No cross-library C++ runtime comparison was possible in this environment -(libspatialaudio/SAF could not be fetched); the absolute numbers and the -embedded results below are the anchor. +### Measured C++ head-to-head + +All three libraries were built from source and timed on the **same +machine** (single-core Intel Xeon @ 2.10 GHz, Ubuntu 24.04, GCC 13, +`-O3`), 48 kHz, median of 9 runs × 400 blocks, warm caches. Sources and +build commands are in [`bench/compare/`](../bench/compare/). Absolute +numbers will differ on other hardware; the *ratios* on one machine are +the point. + +- **AmbiTap** — `main`, its own `bench/`, 64-frame blocks. +- **libspatialaudio 0.4.1** — current `AmbisonicRotator`/`AmbisonicDecoder`/ + `AmbisonicBinauralizer` classes, 64-frame blocks, cube layout, + built-in MIT HRTF, `lowCpuMode`. +- **SAF v1.3.5** — its `ambi_enc` / `rotator` / `ambi_dec` / `ambi_bin` + example engines (the exact cores inside the SPARTA plug-ins), OpenBLAS + backend, default `SAF_ENABLE_SIMD=OFF`. `ambi_dec`/`ambi_bin` run at + their compiled 128-sample frame; values are normalized to µs per 64 + samples for comparison. + +**µs per 64-sample block** (budget at 48 kHz = 1333 µs): + +| Processor | Order | AmbiTap | libspatialaudio | SAF | +|---|---|---|---|---| +| encoder | 1 | 0.02 | 0.14 | 1.67 | +| | 3 | 0.08 | 0.56 | 2.01 | +| | 5 | 0.14 | n/a (max order 3) | 2.43 | +| rotator | 1 | 0.76 | 0.14 | 1.70 | +| | 3 | 5.08 | 1.08 | 2.11 | +| | 5 | 14.39 | n/a | 3.57 | +| decoder (8 spk) | 1 | 2.02 | 3.10 | 22.3 | +| | 3 | 5.87 | 10.96 | 38.0 | +| | 5 | 14.55 | n/a | 63.0 | +| binaural | 1 | 3.33 | 12.15 | 12.0 | +| | 3 | 11.20 | unsupported* | 29.7 | +| | 5 | 22.99 | unsupported* | 62.2 | + +\* libspatialaudio's bundled MIT HRTF binauralizer configures only at +order 1; orders 2–3 require supplying a SOFA file. + +Reading these numbers honestly: + +- **SAF's decoder and binauralizer are time–frequency processors** + (afSTFT filterbank), which is what buys its frequency-dependent + decoding orders and Mag-LS options. The constant-ish filterbank + overhead (~20 µs/64 even at order 1) is the price of that capability, + and its 128-sample internal frame sets a latency floor. AmbiTap and + libspatialaudio decode broadband with per-block matrices — cheaper, + but a different (simpler) operation. SAF's *time-domain* modules + (encoder, rotator) are directly comparable and land within a few µs. +- **libspatialaudio's encoder and rotator are the fastest here at + orders 1–3** — lean per-degree processing with gain interpolation. + Its decoder is ~2× AmbiTap's cost at matching orders, and it stops at + order 3. +- **AmbiTap's rotator cost** buys exact Ivanic–Ruedenberg matrices with + click-free crossfade machinery on every path; at order 5 it is the + only one of the three that pairs that with a wait-free, + allocation-free contract that is machine-checked in CI. +- **Binaural is where AmbiTap's design shows most**: the shared-spectrum + SH-domain engine is 3.6× cheaper than libspatialaudio at order 1 and + is the only embedded-HRTF path here that runs at orders up to 5 + without external files (12.0–62.2 µs for SAF's ambi_bin vs + 3.3–23.0 µs for AmbiTap across orders 1–5). **Design-time construction** (measured, same machine; spaudiopy is NumPy research code and makes no speed claims — this quantifies the workflow @@ -107,34 +170,102 @@ analytically. None of the compared libraries publishes an embedded story. ## Feature matrix -Measured column for AmbiTap and spaudiopy; other columns -documentation-based (mid-2025). +Measured columns for AmbiTap, spaudiopy, libspatialaudio, and SAF; IEM, +SPARTA, and Resonance columns documentation-based (verified July 2026). + +| | **AmbiTap** | spaudiopy | libspatialaudio 0.4.1 | SAF v1.3.5 | IEM Plug-in Suite | SPARTA | Resonance Audio | +|---|---|---|---|---|---|---|---| +| Language / form | C++20 header-only | Python | C++ library | C library | C++ (JUCE plugins) | C++ (JUCE plugins on SAF) | C++ SDK | +| License | MIT | MIT | LGPL-2.1+ (commercial dual-license via VideoLabs) | ISC core; optional modules GPLv2 | GPLv3 | GPLv3 | Apache-2.0 | +| Max order | 10 | arbitrary | 3 | 10 | 7 | 10 | 3 | +| Decoders | mode-match, ALLRAD, EPAD (+max-rE) | SAD/MAD, ALLRAD/ALLRAD2, EPAD, nearest | AllRAD-style presets + custom layouts | ALLRAD, EPAD, MMD, SAD (freq-dependent) | AllRAD (+IEM decoder files) | ALLRAD, EPAD, MMD, SAD | pre-computed | +| Binaural | SH-domain KEMAR embedded, LS + MagLS, resampled to host rate, orders ≤5 | MagLS (SOFA input) | HRTF convolution; built-in MIT HRTF **order 1 only**, SOFA for higher | LS, SPR, TA, Mag-LS; SOFA | binaural decoder plugin | LS/SPR/TA/Mag-LS, SOFA, OSC head-tracking | proprietary HRTFs | +| Rotation | Ivanic–Ruedenberg, exact; on-device capable | Wigner-D (SciPy-based) | yes (`AmbisonicRotator`, faded) | yes (`rotator` example) | SceneRotator | Rotator (OSC head-tracking) | yes | +| Real-time contract | machine-checked: wait-free, allocation-free process paths (TSan + operator-new proof in CI) | none (offline/research) | informal (RT-oriented, gain-faded) | informal (RT-oriented; powers SPARTA) | plugin RT via JUCE | plugin RT via JUCE | yes (game-audio) | +| Embedded | Cortex-M55 gate + QEMU execution in CI; Hexagon/AudioReach notes | no | no | partial (used in products) | no | no | mobile-focused | +| External deps for core | Eigen (design-time only; RT profile has none) | NumPy/SciPy | none | BLAS/LAPACK backend required (MKL / OpenBLAS / Accelerate / FFTW) | JUCE, FFTW | JUCE + SAF | none notable | +| Verification artifacts | 116 C++ tests, 6 executed notebooks, cross-library checks, audit doc | unit tests | tests | extensive tests | listening-tested plugins | (inherits SAF tests) | tests | +| Extras | doppler, spatial compressor, mirror, virtual mic, directional loudness, analysis layer, FuMa | sig/decoder analysis, plotting | object panning, speaker rendering, ADM (BS.2127-1) / IAMF workflows, unified `Renderer`, AmbiX/FuMa | beamforming, SLDoA, powermap, roomsim, DRC, array2SH | full plugin chain incl. RoomEncoder (200+ reflections, Doppler), granular encoder, multiband compressor | visualisers, roomsim, array encoders, COMPASS/HO-DirAC parametric suites | room modeling / reflections, spectral reverb | +| Activity (July 2026) | active | active | active (commit July 2026); `AmbisonicProcessor` deprecated in favor of `AmbisonicRotator` | active (v1.3.5, Jan 2026) | active | active | dormant — community steering committee; several platform SDK repos archived | + +Changes from the mid-2025 snapshot worth knowing about: + +- **libspatialaudio has outgrown "compact order-3 renderer."** It now + centers a unified `Renderer` for HOA + objects + speaker feeds + + binaural, targeting ADM (ITU-R BS.2127-1) and IAMF rendering. HOA is + still capped at order 3, and the built-in-HRTF binaural path is still + order-1 only. +- **SAF's example engines go to order 10** (not "7+"), and they are the + literal cores of the SPARTA plug-ins, so benchmarking them benchmarks + SPARTA's DSP. +- **Resonance Audio is best treated as stable-but-dormant**: Apache-2.0 + and still a fine reference for its decode approach, but several of its + platform SDKs are archived and activity is minimal. + +## Platforms and architectures -| | **AmbiTap** | spaudiopy | libspatialaudio | SAF (Spatial Audio Framework) | IEM Plug-in Suite | Resonance Audio | +| | AmbiTap | libspatialaudio | SAF | IEM | SPARTA | Resonance | |---|---|---|---|---|---|---| -| Language / form | C++20 header-only | Python | C++ library | C library | C++ (JUCE plugins) | C++ SDK | -| License | MIT | MIT | LGPL | ISC/GPL mix | GPL | Apache-2.0 | -| Max order | 10 | arbitrary | 3 | 7+ | 7 | 3 | -| Decoders | mode-match, ALLRAD, EPAD (+max-rE) | SAD/MAD, ALLRAD/ALLRAD2, EPAD, nearest | AllRAD-style presets | ALLRAD, EPAD, MMD, SPD | AllRAD (+IEM decoder files) | pre-computed | -| Binaural | SH-domain KEMAR, LS + MagLS, resampled to host rate | MagLS (SOFA input) | per-order HRTF convolution | LS/MagLS, SOFA | binaural decoder plugin | proprietary HRTFs | -| Rotation | Ivanic–Ruedenberg, exact; on-device capable | Wigner-D (SciPy-based) | yes | yes | SceneRotator | yes | -| Real-time contract | machine-checked: wait-free, allocation-free process paths (TSan + operator-new proof in CI) | none (offline/research) | informal | informal | plugin RT via JUCE | yes (game-audio) | -| Embedded | Cortex-M55 gate + QEMU execution in CI; Hexagon/AudioReach notes | no | no | partial (used in products) | no | mobile-focused | -| Verification artifacts | 116 C++ tests, 6 executed notebooks, cross-library checks, audit doc | unit tests | tests | extensive tests | listening-tested plugins | tests | -| Extras | doppler, spatial compressor, mirror, directional loudness, analysis layer, FuMa | sig/decoder analysis, plotting | AmbiX/FuMa, binauralizer | beamforming, SLDoA, etc. | full plugin chain | rooms/reflections | +| Desktop OS | macOS / Linux / Windows (CMake) | macOS / Linux / Windows (CMake + Meson) | macOS / Linux / Windows | macOS / Linux / Windows | macOS 12+ / Linux x86_64 / Windows x86_64 | macOS / Linux / Windows | +| Mobile | via wrapper targets (planned) | yes (used in VLC) | possible | no | no | Android / iOS focus | +| Bare-metal / DSP | **Cortex-M55 (CI-gated, QEMU-executed), Hexagon AudioReach notes** | no | no | no | no | no | +| SIMD story | target-independent C++; Eigen vectorizes design-time paths | portable C++ | optional SSE3/AVX2/AVX512 (`SAF_ENABLE_SIMD`); perf lib does the heavy lifting | via JUCE/FFTW | via SAF | optimized DSP classes | +| Plugin formats | Max/MSP `mc.` + Pd externals (planned) | n/a (library) | n/a (library; SPARTA is the plugin face) | VST2/VST3/LV2/AU/AAX + standalone | VST/VST3/LV2/AU + standalone | Unity/Unreal/FMOD/Wwise/Web SDKs, VST monitor | + +## Pros and cons, per library + +**AmbiTap** — *Pros*: machine-checked wait-free RT contract; embedded +deployability with CI proof; embedded order-5 HRTF set (no files +needed); fastest measured binaural and encoder; executable correctness +evidence; MIT. *Cons*: no room modeling; no shipped end-user tools yet +(Max/Pd wrappers planned); young project, small community. + +**libspatialaudio** — *Pros*: broadest *format* scope (HOA + objects + +speakers + ADM/IAMF) behind one `Renderer`; dependency-free; very lean +time-domain processors; production-proven (VLC lineage); LGPL usable in +closed apps with dynamic linking. *Cons*: HOA capped at order 3; +built-in binaural order-1 only; informal RT guarantees; LGPL obligations +where static linking or modification is needed. + +**SAF** — *Pros*: widest algorithm coverage in C (beamforming, +localisation, parametric methods); frequency-dependent decoding; order +10; ISC core; powers a mature plugin suite. *Cons*: mandatory +BLAS/LAPACK backend complicates deployment; filterbank overhead and +128-sample frame floor; optional modules flip the license to GPLv2; +informal RT guarantees. + +**IEM Plug-in Suite** — *Pros*: the reference for end-user Ambisonics +production; order 7; RoomEncoder is unmatched for externalization work; +every major plugin format. *Cons*: it's a plugin suite, not a library +API; GPLv3; JUCE + FFTW build. + +**SPARTA** — *Pros*: SAF's algorithms with GUIs, SOFA, OSC +head-tracking; order 10; research-current (COMPASS, HO-DirAC). *Cons*: +GPLv3; plugin-only; channel-count quirks vary by plugin format. + +**spaudiopy** — *Pros*: best prototyping companion; arbitrary order; +pairs directly with AmbiTap's notebooks. *Cons*: not real-time; NumPy +speeds (see design-time table). + +**Resonance Audio** — *Pros*: Apache-2.0; proven game/mobile scene +model with rooms and reflections; broad engine plugin coverage. +*Cons*: order 3; dormant; HRTF set fixed; not a general HOA toolkit. Fair summary: **SAF** is the most feature-broad C library (beamforming, -localization); **IEM** is the reference for end-user tooling; **spaudiopy** -is the best research/prototyping companion (and pairs naturally with -AmbiTap's notebooks); **libspatialaudio** is a compact order-3 renderer; -**Resonance** targets game/mobile scenes. AmbiTap's distinct position is -the combination of a machine-checked real-time contract, embedded -deployability, and executable correctness evidence — at the cost of (so -far) having no room modeling and no shipped end-user tools (the Max/Pd -wrappers are the planned front ends). +localization, parametric methods); **IEM** is the reference for end-user +tooling; **spaudiopy** is the best research/prototyping companion (and +pairs naturally with AmbiTap's notebooks); **libspatialaudio** has grown +into an ADM/IAMF-oriented renderer while remaining order-3 for HOA; +**Resonance** remains the game/mobile scene reference, now dormant. +AmbiTap's distinct position is the combination of a machine-checked +real-time contract, embedded deployability, and executable correctness +evidence — at the cost of (so far) having no room modeling and no +shipped end-user tools (the Max/Pd wrappers are the planned front ends). ## Reproducing +Python cross-checks: + ```bash pip install -r notebooks/requirements-comparison.txt jupyter execute notebooks/library_comparison.ipynb @@ -143,3 +274,15 @@ jupyter execute notebooks/library_comparison.ipynb The notebook `assert`s every agreement claim above; if a future spaudiopy or pyshtools release changes a convention, the run fails loudly rather than silently drifting. + +C++ head-to-head: + +```bash +# see bench/compare/README.md — clones and builds libspatialaudio and +# SAF, compiles the two harnesses, and runs all three benchmarks +bench/compare/run.sh +``` + +The head-to-head numbers above were captured 2026-07-03 at +libspatialaudio 0.4.1 and SAF v1.3.5 (master); re-run on your hardware +before quoting absolute values. diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md new file mode 100644 index 0000000..d191ab9 --- /dev/null +++ b/docs/CONCEPTS.md @@ -0,0 +1,101 @@ +# AmbiTap concepts + +The orientation guide to the library: the conventions everything obeys, the +real-time contract every processor honors, and the lifecycle a processor +moves through. Read this once and the per-class reference reads easily. The +API reference is generated from the header doc-comments; this page is the +map. + +## Conventions + +AmbiTap is **AmbiX throughout**: ACN channel ordering, SN3D normalization. + +- **Channel ordering (ACN).** Channel index for degree *n*, order *m* is + `acn = n(n+1) + m`. So W=0; Y,Z,X = 1,2,3; then the second-order five, and + so on. `channel_count(order)` is `(order+1)²`. +- **Normalization (SN3D).** The W channel of a unit-amplitude source is 1.0. + Decoder construction happens internally in the orthonormal **N3D** basis + (AmbiTap's N3D is unit-mean-square: `YᵀY = L·I` on an *L*-point t-design, + no 4π factor) and converts back; you never see N3D at the API surface. + FuMa (orders 0–3) is available via `dsp::format_converter`. +- **Angles are radians.** Azimuth 0 = front, +π/2 = left. Elevation 0 = + horizon, +π/2 = zenith. +- **Rotation.** Intrinsic Z-Y'-X'' Euler: yaw about +Z applied first, pitch + about +Y second, roll about +X last (right-hand rule; positive pitch tilts + the front axis *down*). Rotation matrices rotate the *soundfield*: + `Y(R·d) = R_sh · Y(d)`. Built by the exact Ivanic–Ruedenberg recurrence + (`math/core/rotation_recurrence.h`). +- **Decoder matrices** are shaped `(speakers × channels)`, row-major, with + `speaker_signals = D · hoa`. Mode-matching, ALLRAD, and EPAD share one + absolute-gain convention. +- **Binaural sample rate.** The embedded MIT KEMAR HRTFs are 44.1 kHz data; + pass the host rate to `binaural_renderer::prepare(block, sample_rate)` and + the FIRs are resampled to match. + +These are cross-checked against spaudiopy, pyshtools, and SciPy in +[`COMPARISON.md`](COMPARISON.md). + +## The real-time contract + +Every processor's `process()` path is **wait-free**: it never locks, never +allocates or frees, and never blocks on a worker thread. This is not a +promise in prose — it is machine-checked in CI by +`tests/test_rt_safety.cpp` (which replaces global `operator new`/`delete` to +prove the process paths never allocate) and `tests/test_dsp_threads.cpp` +(setter-vs-process stress under ThreadSanitizer). + +Two mechanisms make it work: + +- **Wait-free publication (`dsp::rt_published`, `dsp::async_rebuilder`).** + Matrix construction (decoder SVD, rotation) is not real-time-safe, so it + runs on a worker thread and the result is published to the audio thread + through a single-reader RCU handoff — the audio thread does two atomic + loads and never waits; the worker does all freeing. Until the first build + lands, the processor emits silence (decoder) or passes through (rotator). +- **Click-free parameter changes.** Coefficient tables ramp linearly over + `k_smoothing_samples` (128); decode/rotation matrices crossfade over + `k_fade_samples` (256); the doppler delay glides with a one-pole slew + (producing a real Doppler pitch bend on distance jumps); binaural volume + ramps per block. All of this is visualized in + [`notebooks/dsp_behavior.ipynb`](../notebooks/dsp_behavior.ipynb). + +**Threading model.** Setters run on one control thread; `process()` on +exactly one audio thread. Setters are safe to call while audio runs. For +offline/exact rendering where you want the change applied *now* with no +ramp, call `snap_parameters()` (or `wait_for_settling()` for the +worker-built matrices). + +## Processor lifecycle + +Every runtime processor follows the same shape: + +1. **Construct** with the ambisonics order. This validates the order and + allocates order-sized buffers. Out-of-range orders throw + `std::invalid_argument` (or, in the embedded no-exceptions profile, + assert in debug and clamp in release — see `validated_order`). +2. **`prepare(...)`** with the sample rate and/or block size where a + processor needs it (binaural, doppler, compressor, the analysis layer). + This is the last allocating call; do it before audio starts. +3. **Set parameters** from the control thread at will (`set_direction`, + `set_speakers`, `set_head_orientation`, …). Matrix-building setters + submit a worker job and return immediately. +4. **`process(...)`** on the audio thread, once per block. Wait-free. +5. For offline use, **`snap_parameters()` / `wait_for_settling()`** to + collapse ramps and drain pending rebuilds so output is exact. + +## The embedded real-time profile + +A freestanding subset of the above runs on bare-metal Cortex-M55 and Hexagon +AudioReach: float32 throughout, no exceptions, no threads, no Eigen on the +audio path. Matrix *construction* moves to the control side +(`compute_sh_rotation` builds rotation matrices on-device; decode matrices +are precomputed) and `dsp::matrix_applier` / `dsp::binaural_core` apply them. +Full definition, budgets, and the CI gate: [`EMBEDDED.md`](EMBEDDED.md). + +## Where to look next + +- **Per-class API** — the generated reference (the modules/namespaces list). +- **Correctness evidence** — [`AUDIT.md`](AUDIT.md) and the notebooks in + `notebooks/`. +- **Ecosystem comparison** — [`COMPARISON.md`](COMPARISON.md). +- **Embedded** — [`EMBEDDED.md`](EMBEDDED.md). diff --git a/docs/Doxyfile b/docs/Doxyfile new file mode 100644 index 0000000..2180e25 --- /dev/null +++ b/docs/Doxyfile @@ -0,0 +1,74 @@ +# Doxygen configuration for the AmbiTap API documentation site. +# Build: doxygen docs/Doxyfile (run from the repository root) +# Output: docs/html/ (deployed to GitHub Pages by CI on main) + +PROJECT_NAME = AmbiTap +PROJECT_NUMBER = 0.1.0 +PROJECT_BRIEF = "Target-independent C++20 higher-order ambisonics (AmbiX)" +OUTPUT_DIRECTORY = docs +HTML_OUTPUT = html +GENERATE_LATEX = NO +GENERATE_HTML = YES + +# Sources: the public headers plus the hand-written Markdown pages. The +# generated coefficient tables carry no doc-comments and would only bloat +# the output, so they are excluded. +INPUT = include/ambitap \ + README.md \ + docs/CONCEPTS.md \ + docs/AUDIT.md \ + docs/EMBEDDED.md \ + docs/COMPARISON.md +FILE_PATTERNS = *.h *.md +RECURSIVE = YES +EXCLUDE = include/ambitap/math/binaural/hrtf_data.h \ + include/ambitap/math/geometry/tdesigns.h +USE_MDFILE_AS_MAINPAGE = README.md + +# Extraction. The headers are thoroughly commented with ///-style docs; +# surface everything public, keep private/static members out. +EXTRACT_ALL = YES +EXTRACT_PRIVATE = NO +EXTRACT_STATIC = NO +HIDE_UNDOC_MEMBERS = NO +JAVADOC_AUTOBRIEF = YES +QT_AUTOBRIEF = YES +MARKDOWN_SUPPORT = YES +TOC_INCLUDE_HEADINGS = 3 +AUTOLINK_SUPPORT = YES +SORT_MEMBER_DOCS = NO + +# C++20: let Doxygen see the language, and predefine the optional-feature +# and embedded macros so the gated code (SOFA reader, no-exceptions paths) +# documents rather than silently dropping out. +BUILTIN_STL_SUPPORT = YES +PREDEFINED = AMBITAP_HAS_SOFA \ + AMBITAP_HAS_EXCEPTIONS=1 \ + __cpp_exceptions=1 +MACRO_EXPANSION = YES +EXPAND_ONLY_PREDEF = YES + +# Diagrams via Graphviz: include/collaboration graphs make the header +# layering legible. Kept modest so the build stays fast. +HAVE_DOT = YES +DOT_IMAGE_FORMAT = svg +INTERACTIVE_SVG = YES +CLASS_GRAPH = YES +COLLABORATION_GRAPH = NO +INCLUDE_GRAPH = YES +INCLUDED_BY_GRAPH = NO +CALL_GRAPH = NO + +# HTML presentation. +GENERATE_TREEVIEW = YES +DISABLE_INDEX = NO +FULL_SIDEBAR = NO +HTML_COLORSTYLE = TOGGLE +SEARCHENGINE = YES + +# Warnings: surface undocumented public API and broken \ref links, but do +# not fail the build on them (the tables and third-party headers are out). +QUIET = YES +WARN_IF_UNDOCUMENTED = NO +WARN_IF_DOC_ERROR = YES +WARN_NO_PARAMDOC = NO diff --git a/include/ambitap/dsp/binaural_renderer.h b/include/ambitap/dsp/binaural_renderer.h index 237fff7..d5a62a8 100644 --- a/include/ambitap/dsp/binaural_renderer.h +++ b/include/ambitap/dsp/binaural_renderer.h @@ -247,6 +247,9 @@ namespace ambitap::dsp { /// normalized so the louder ear peaks at 0 dB, preserving the inter-aural /// level difference. Runs synchronously on the calling thread. /// + /// @param azimuth Probe azimuth in radians (0 = front, +π/2 = left). + /// @param elevation Probe elevation in radians (0 = horizon, +π/2 = zenith). + /// @param fft_size FFT length for the magnitude spectrum. /// @param sample_rate Rate the HRTF taps are expressed at; defaults to /// the built-in dataset's rate. response probe_response(float azimuth, float elevation, size_t fft_size = 512, diff --git a/include/ambitap/dsp/util/matrix_applier.h b/include/ambitap/dsp/util/matrix_applier.h index e9093d4..37477a8 100644 --- a/include/ambitap/dsp/util/matrix_applier.h +++ b/include/ambitap/dsp/util/matrix_applier.h @@ -22,7 +22,7 @@ namespace ambitap::dsp { /// the matrix on the control side and pass it to apply(); a change of /// matrix identity (the `key` pointer) restarts the fade. /// - /// Freestanding: no allocation, no exceptions, no locks; only. + /// Freestanding: no allocation, no exceptions, no locks; `` only. /// State is the fade progress, owned by the (single) audio thread. class matrix_applier { public: @@ -44,6 +44,11 @@ namespace ambitap::dsp { /// @param mat Row-major (rows × cols) matrix. /// @param prev Same shape; the matrix faded from. Must stay valid /// alongside mat for the k_fade_samples after adoption. + /// @param rows Output channel count (matrix rows). + /// @param cols Input channel count (matrix columns). + /// @param in Input channel pointers (see frame_layout). + /// @param out Output channel pointers (see frame_layout). + /// @param frame_count Number of frames to process. /// @param frame_layout true: in/out are single-frame channel arrays /// (in[0][c], out[0][r]); false: planar buffers /// (in[c][i], out[r][i]). diff --git a/include/ambitap/dsp/util/sh_block_applier.h b/include/ambitap/dsp/util/sh_block_applier.h index 6f9da1c..10a25b4 100644 --- a/include/ambitap/dsp/util/sh_block_applier.h +++ b/include/ambitap/dsp/util/sh_block_applier.h @@ -47,6 +47,10 @@ namespace ambitap::dsp { /// @param mat Dense row-major (C × C); only diagonal blocks read. /// @param prev Same shape/layout; the matrix faded from. Must stay /// valid alongside mat during the fade. + /// @param order Ambisonics order; C = (order+1)². + /// @param in Input channel pointers (see frame_layout). + /// @param out Output channel pointers (see frame_layout). + /// @param frame_count Number of frames to process. /// @param frame_layout true: single-frame channel arrays /// (in[0][c], out[0][c]); false: planar buffers /// (in[c][i], out[c][i]). diff --git a/include/ambitap/math/binaural/sofa_reader.h b/include/ambitap/math/binaural/sofa_reader.h index b1f3325..847e090 100644 --- a/include/ambitap/math/binaural/sofa_reader.h +++ b/include/ambitap/math/binaural/sofa_reader.h @@ -130,13 +130,37 @@ namespace ambitap { } } + // mysofa_load can return a structurally valid handle whose mandatory + // arrays are absent or under-sized — a hostile or truncated file + // parses, but reading through the declared M/N/R dimensions would run + // off a null or short buffer (found by tests/fuzz/fuzz_sofa_reader). + // Validate every array this function dereferences, against the exact + // element counts the SOFA dimensions imply, before touching it. + const size_t M = static_cast(hrtf->M); + const size_t N = static_cast(hrtf->N); + if (M == 0 || N == 0) { + throw std::runtime_error("ambitap::load_sofa: \"" + path + + "\" has no measurements or zero-length IRs"); + } + auto require = [&](const MYSOFA_ARRAY& arr, size_t need, const char* what) { + if (arr.values == nullptr || arr.elements < need) { + throw std::runtime_error("ambitap::load_sofa: \"" + path + + "\" is missing or has a " + "truncated " + + what); + } + }; + require(hrtf->DataSamplingRate, 1, "Data.SamplingRate"); + require(hrtf->SourcePosition, M * 3, "SourcePosition"); + require(hrtf->DataIR, M * 2 * N, "Data.IR"); // R == 2 checked above + // libmysofa stores source positions in spherical-degrees by default; convert // to Cartesian to get a uniform handle, then derive radians. mysofa_tocartesian(hrtf.get()); hrtf_data data; - data.num_measurements = static_cast(hrtf->M); - data.hrir_length = static_cast(hrtf->N); + data.num_measurements = M; + data.hrir_length = N; data.sample_rate = static_cast(hrtf->DataSamplingRate.values[0]); data.azimuth.resize(data.num_measurements); diff --git a/include/ambitap/math/core/fast_math.h b/include/ambitap/math/core/fast_math.h index e16e6bb..269dcaf 100644 --- a/include/ambitap/math/core/fast_math.h +++ b/include/ambitap/math/core/fast_math.h @@ -21,9 +21,10 @@ namespace ambitap { /// below the smallest normal float (including 0 and denormals) are /// clamped to it; the callers in the audio paths clamp harder anyway. /// - /// Why this exists: std::log10/std::pow per sample are software-library - /// calls costing hundreds of cycles on embedded FPUs (Cortex-M55, - /// Hexagon). This is a handful of MACs and stays on the FPU everywhere. + /// Why this exists: `std::log10`/`std::pow` per sample are + /// software-library calls costing hundreds of cycles on embedded FPUs + /// (Cortex-M55, Hexagon). This is a handful of MACs and stays on the FPU + /// everywhere. inline float fast_log2(float x) { constexpr float k_min_normal = 1.17549435e-38f; if (x < k_min_normal) x = k_min_normal; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3c62480..ea13bda 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -26,6 +26,7 @@ add_executable(ambitap_tests test_dsp_threads.cpp test_rt_safety.cpp test_embedded_core.cpp + test_sofa.cpp ) target_link_libraries(ambitap_tests PRIVATE AmbiTap::ambitap ambitap_warnings GTest::gtest_main) diff --git a/tests/fuzz/CMakeLists.txt b/tests/fuzz/CMakeLists.txt new file mode 100644 index 0000000..8778775 --- /dev/null +++ b/tests/fuzz/CMakeLists.txt @@ -0,0 +1,29 @@ +# libFuzzer harnesses. Require Clang (for -fsanitize=fuzzer) and the SOFA +# reader (AMBITAP_ENABLE_SOFA, which brings in libmysofa). ASan+UBSan are +# layered on so memory and UB bugs surface during fuzzing, not just crashes. + +if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR + "AMBITAP_BUILD_FUZZERS requires Clang (needs -fsanitize=fuzzer); " + "current compiler is ${CMAKE_CXX_COMPILER_ID}") +endif() +if(NOT AMBITAP_ENABLE_SOFA) + message(FATAL_ERROR + "AMBITAP_BUILD_FUZZERS needs AMBITAP_ENABLE_SOFA=ON (the SOFA reader " + "is the fuzz target)") +endif() + +add_executable(fuzz_sofa_reader fuzz_sofa_reader.cpp) +target_link_libraries(fuzz_sofa_reader PRIVATE AmbiTap::ambitap) +target_compile_options(fuzz_sofa_reader PRIVATE + -g -fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all) +target_link_options(fuzz_sofa_reader PRIVATE + -fsanitize=fuzzer,address,undefined) + +# A short built-in run doubles as a CI smoke test: exercise the harness +# against the seed corpus for a bounded time and fail on any finding. +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/corpus) + add_test(NAME fuzz_sofa_reader_smoke + COMMAND fuzz_sofa_reader -runs=20000 -max_total_time=60 + ${CMAKE_CURRENT_SOURCE_DIR}/corpus) +endif() diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000..9c99b98 --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,45 @@ +# Fuzzing + +libFuzzer harnesses for AmbiTap's untrusted-input surfaces. Currently one: + +- **`fuzz_sofa_reader.cpp`** — the SOFA reader (`load_sofa` + + `decompose_sh`), the library's only consumer of arbitrary external files. + Exercises the whole path a host would drive: parse (libmysofa's HDF5 + reader), validate, and project onto the SH basis. + +## Build & run + +Requires Clang (for `-fsanitize=fuzzer`) and the SOFA reader; ASan+UBSan are +layered on so memory/UB bugs surface, not just crashes. + +```bash +cmake -B build -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DAMBITAP_ENABLE_SOFA=ON -DAMBITAP_BUILD_FUZZERS=ON \ + -DAMBITAP_BUILD_TESTS=OFF +cmake --build build --target fuzz_sofa_reader + +# Deep campaign (hours): mutate from the seed corpus, saving new coverage. +ASAN_OPTIONS=detect_leaks=0 \ + ./build/tests/fuzz/fuzz_sofa_reader -rss_limit_mb=4096 tests/fuzz/corpus +``` + +CI runs a bounded version of this on every push (the `fuzz` job) as a +regression gate: it replays the seed corpus and does a few minutes of +exploratory fuzzing. It is not a substitute for a periodic deep run. + +## Corpus + +`corpus/` is a deliberately small seed set: one structurally valid SOFA file +to mutate from, plus hand-authored malformed cases (empty, non-HDF5, +HDF5-magic-only, truncated). libFuzzer expands coverage from these at run +time; new inputs are not checked in (they regenerate, and would only bloat +the repo). + +## Findings + +The first run found a real bug: `load_sofa` dereferenced `Data.IR`, +`SourcePosition`, and `Data.SamplingRate` through the declared dimensions +without checking the arrays were present and sized, so a truncated or +hostile file that parsed structurally read off a null/short buffer. Fixed in +`sofa_reader.h` (up-front array validation); regression-tested in +`tests/test_sofa.cpp`. diff --git a/tests/fuzz/corpus/empty b/tests/fuzz/corpus/empty new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/corpus/hdf5_magic_only b/tests/fuzz/corpus/hdf5_magic_only new file mode 100644 index 0000000..028a288 Binary files /dev/null and b/tests/fuzz/corpus/hdf5_magic_only differ diff --git a/tests/fuzz/corpus/not_hdf5 b/tests/fuzz/corpus/not_hdf5 new file mode 100644 index 0000000..7833a36 --- /dev/null +++ b/tests/fuzz/corpus/not_hdf5 @@ -0,0 +1 @@ +this is not a sofa file at all \ No newline at end of file diff --git a/tests/fuzz/corpus/truncated_hdf5 b/tests/fuzz/corpus/truncated_hdf5 new file mode 100644 index 0000000..9a7368c Binary files /dev/null and b/tests/fuzz/corpus/truncated_hdf5 differ diff --git a/tests/fuzz/corpus/valid.sofa b/tests/fuzz/corpus/valid.sofa new file mode 100644 index 0000000..d7a1eff Binary files /dev/null and b/tests/fuzz/corpus/valid.sofa differ diff --git a/tests/fuzz/fuzz_sofa_reader.cpp b/tests/fuzz/fuzz_sofa_reader.cpp new file mode 100644 index 0000000..498c5fa --- /dev/null +++ b/tests/fuzz/fuzz_sofa_reader.cpp @@ -0,0 +1,72 @@ +/// AmbiTap: target-independent ambisonics library +/// libFuzzer harness for the SOFA reader load + SH-decompose path. +/// +/// The SOFA reader (math/binaural/sofa_reader.h) is the library's only +/// consumer of untrusted external files: users load arbitrary HRTF SOFA +/// files, which are HDF5 containers parsed by libmysofa. This fuzzes the +/// whole path a host would drive — parse, validate, and project onto the SH +/// basis — against adversarial bytes, so a malformed or hostile file fails +/// cleanly (exception or rejection) rather than crashing, reading out of +/// bounds, or looping. +/// +/// libmysofa parses the HDF5/NetCDF container; that third-party surface is +/// in scope here too (AmbiTap ships it as an optional dependency and is +/// responsible for feeding it safely). +/// +/// Build: -DAMBITAP_BUILD_FUZZERS=ON with a Clang toolchain (needs +/// -fsanitize=fuzzer). See tests/fuzz/CMakeLists.txt. +/// Timothy Place +/// Copyright 2026 Timothy Place. + +#include "ambitap/math/binaural/sofa_reader.h" + +#include +#include +#include +#include +#include + +namespace { + + // libmysofa only takes a file path, so each input is materialized to a + // temp file, named from a monotonic counter (no timestamp — reproducible + // across a replay of the same corpus). + std::string write_temp(const uint8_t* data, size_t size) { + static unsigned long counter = 0; + std::string path = "/tmp/ambitap_fuzz_" + std::to_string(counter++) + ".sofa"; + std::FILE* f = std::fopen(path.c_str(), "wb"); + if (!f) return {}; + if (size > 0) std::fwrite(data, 1, size, f); + std::fclose(f); + return path; + } + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + // Cap input size: HDF5 parsing of huge inputs is slow and adds no + // coverage past a point; keep iterations fast. + if (size > (1u << 20)) return 0; + + const std::string path = write_temp(data, size); + if (path.empty()) return 0; + + try { + const ambitap::hrtf_data hrtf = ambitap::load_sofa(path); + + // Exercise the downstream math too: project onto a low order (cheap; + // higher orders just rank-reject on sparse grids). Only meaningful + // when the grid could plausibly support it. + if (hrtf.num_measurements >= 4 && hrtf.hrir_length > 0) { + std::vector> left; + std::vector> right; + hrtf.decompose_sh(1, hrtf.hrir_length, left, right); + } + } + catch (const std::exception&) { + // Expected: malformed/unsupported files must throw, not crash. + } + + std::remove(path.c_str()); + return 0; +} diff --git a/tests/test_sofa.cpp b/tests/test_sofa.cpp new file mode 100644 index 0000000..8abc6ff --- /dev/null +++ b/tests/test_sofa.cpp @@ -0,0 +1,55 @@ +/// AmbiTap: target-independent ambisonics library +/// SOFA reader hardening tests. Built only when AMBITAP_ENABLE_SOFA is on +/// (the reader needs libmysofa); a no-op translation unit otherwise. +/// Timothy Place +/// Copyright 2026 Timothy Place. + +#ifdef AMBITAP_HAS_SOFA + +#include "ambitap/math/binaural/sofa_reader.h" + +#include + +#include +#include +#include + +using namespace ambitap; + +namespace { + + std::string write_temp(const std::vector& bytes, const char* tag) { + const std::string path = std::string("/tmp/ambitap_sofa_test_") + tag + ".sofa"; + std::FILE* f = std::fopen(path.c_str(), "wb"); + if (f) { + if (!bytes.empty()) std::fwrite(bytes.data(), 1, bytes.size(), f); + std::fclose(f); + } + return path; + } + +} // namespace + +// The reader is the library's only untrusted-file surface. Malformed inputs +// must be rejected with an exception, never crash or read out of bounds +// (the whole path is fuzzed by tests/fuzz/fuzz_sofa_reader.cpp under +// ASan+UBSan; these are the deterministic regression cases). +TEST(SofaReader, RejectsMalformedFiles) { + // Empty file. + EXPECT_THROW(load_sofa(write_temp({}, "empty")), std::runtime_error); + + // Not an HDF5/SOFA container at all. + const std::string junk = "this is definitely not a SOFA file"; + EXPECT_THROW(load_sofa(write_temp({junk.begin(), junk.end()}, "junk")), std::runtime_error); + + // Valid HDF5 magic, then garbage — parses far enough to matter, then fails. + std::vector hdf5_magic = {0x89, 'H', 'D', 'F', '\r', '\n', 0x1a, '\n'}; + hdf5_magic.resize(128, 0); + EXPECT_THROW(load_sofa(write_temp(hdf5_magic, "hdf5magic")), std::runtime_error); +} + +TEST(SofaReader, RejectsNonexistentPath) { + EXPECT_THROW(load_sofa("/nonexistent/path/does/not/exist.sofa"), std::runtime_error); +} + +#endif // AMBITAP_HAS_SOFA