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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .clang-format-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
include/ambitap/math/binaural/hrtf_data.h
include/ambitap/math/geometry/tdesigns.h
third_party/*
bench/compare/*
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -208,3 +209,6 @@ endif()
if(AMBITAP_BUILD_CAPI)
add_subdirectory(tools/capi)
endif()
if(AMBITAP_BUILD_FUZZERS)
add_subdirectory(tests/fuzz)
endif()
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions bench/compare/README.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions bench/compare/bench_libspatialaudio.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// libspatialaudio benchmark: encoder, rotator (AmbisonicProcessor),
// decoder (cube), binauralizer. 64-sample blocks @48kHz, median of 9x400.
#include <cstdio>
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <vector>
#include <algorithm>
#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<typename F> double bench(F f){
std::vector<double> runs;
for(int r=0;r<RUNS;r++){ double t0=now_us();
for(int b=0;b<BLOCKS;b++) f();
runs.push_back((now_us()-t0)/BLOCKS); }
std::sort(runs.begin(),runs.end());
return runs[RUNS/2];
}

int main(){
printf("libspatialaudio benchmarks - %d-frame blocks, %d Hz, median of %dx%d\n\n",NF,SR,RUNS,BLOCKS);
float src[NF]; for(int i=0;i<NF;i++) src[i]=sinf(0.01f*i);

for(int order : {1,2,3}){
BFormat bf; bf.Configure(order,true,NF);
// fill with signal
for(unsigned c=0;c<bf.GetChannelCount();c++){
std::vector<float> tmp(NF);
for(int i=0;i<NF;i++) tmp[i]=sinf(0.02f*i+c);
bf.InsertStream(tmp.data(),c,NF);
}

{ AmbisonicEncoder enc; enc.Configure(order,true,SR,10.f);
PolarPosition<float> 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;
}
108 changes: 108 additions & 0 deletions bench/compare/bench_saf.c
Original file line number Diff line number Diff line change
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <time.h>
#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<MAXCH;i++){
bufs[i]=calloc(frame,sizeof(float));
obufs[i]=calloc(frame,sizeof(float));
for(int j=0;j<frame;j++) bufs[i][j]=sinf(0.01f*j+i);
}
}

typedef void (*proc_fn)(void*, const float*const*, float*const*, int,int,int);

static double bench(void* h, proc_fn fn, int nin, int nout, int frame){
double runs[RUNS];
for(int r=0;r<RUNS;r++){
double t0=now_us();
for(int b=0;b<BLOCKS;b++)
fn(h,(const float*const*)bufs,obufs,nin,nout,frame);
runs[r]=(now_us()-t0)/BLOCKS;
}
return median(runs,RUNS);
}

int main(void){
int orders[3]={SH_ORDER_FIRST,SH_ORDER_THIRD,SH_ORDER_FIFTH};
int onum[3]={1,3,5};
printf("SAF benchmarks - 48 kHz, median of %d runs x %d blocks\n",RUNS,BLOCKS);
printf("frame sizes: enc=%d rot=%d dec=%d bin=%d\n\n",
ambi_enc_getFrameSize(),rotator_getFrameSize(),
ambi_dec_getFrameSize(),ambi_bin_getFrameSize());
alloc_bufs(4096);

for(int i=0;i<3;i++){
int o=orders[i]; int nsh=(onum[i]+1)*(onum[i]+1);

/* encoder: 1 mono source -> 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;
}
Loading
Loading