Skip to content

WP15: head-tracked HOA to binaural monitoring + JUCE 9.0.1 - #5

Merged
pob31 merged 14 commits into
mainfrom
feat/hoa-binaural
Aug 12, 2026
Merged

pob31 merged 14 commits into
mainfrom
feat/hoa-binaural

Conversation

@pob31

@pob31 pob31 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What this is

WP15 — head-tracked HOA→binaural monitoring — plus the JUCE 9.0.1 / spatcore
v0.2.0 toolchain move that landed on top of it.

The monitor taps the SH bus post-dual-band / pre-decode, applies a
head-compensation SH rotation (reusing AmbiRotation), convolves it with a
static 2×121 partitioned-FIR bank designed from a SOFA set on a background
worker, and lands L/R on a patch-reserved hardware pair. Head orientation comes
from manual dials or the webcam tracker via the shared wfs_headtrack plugin.

Decision records D51–D54; Documentation/XOA-DEVPLAN.md §WP15 is authoritative.

Commits

  • 09a33241560330 — WP15 stages 0–8 and the non-finite head-attitude fix
  • 1e24b66 — JUCE 9.0.1 + spatcore v0.2.0
  • 6676582 — head-tracker tooling parity with WFS-DIY
  • 524f17a — head-tracker plugin compile-check in CI

The diff reads large (+49k) but is dominated by vendored and generated content:
ThirdParty/zlib, ThirdParty/libmysofa, the 18.6k-line binaural reference
golden, the bundled SADIE II KU100 SOFA set and the YuNet model.

What the tests prove

  • Speaker render is byte-identical with the monitor running (asserted
    exactly, both paths checked non-silent)
  • Partitioned convolver matches a direct time-domain convolution
  • Decoder bank matches an independent mpmath derivation (≤1e-5)
  • Head compensation equals pre-rotating the field
  • Head-mapping goldens, SOFA smoke on the bundled set, registry/localization
    gates
  • Non-finite head attitude never reaches the SH rotation (regression test fails
    without either guard)

Measured monitor cost on the audio thread at order 10 (Release, dev machine):
9.5–15% of real time head-still, 12–18% head-moving, across 64/128/256-sample
blocks.

Verified locally on Windows (VS 18 2026), Release and Debug: build clean,
ctest 2/2, --gui-smoke exit 0, OSC control-replay 12/12 against the golden,
head-tracker plugin builds from a cold OpenCV cache with all 7 ABI exports.

Outstanding

  • The live hardware pass is not done. WP15's exit criterion (DEVPLAN
    §WP15) calls for live head-tracked monitoring on hardware; that waits on the
    RME AoX Dante lab session, together with the spatcore-io stages. Merging
    ahead of it is deliberate — the debugging session is expected to be long, and
    parking the JUCE 9.0.1 move behind it would leave main off-step with
    spatcore and WFS-DIY.
  • This is the first CI run for every commit here (the workflow only triggers on
    main and PRs into it), so the macOS and Linux lanes — including the new
    libxi-dev dependency and the head-tracker compile-checks — are being
    exercised for the first time in this PR.

Deliberate deviations, all recorded in the DEVPLAN

  • High-band mode is alignedHf (true phase below 1.5 kHz, time-aligned above),
    not MagLS. True MagLS remains open; the mode enum has room for it.
  • sampling stays selectable as the golden-anchored reference decode.
  • No --binaural offline-render baseline (D54: no OSC surface either).

🤖 Generated with Claude Code

pob31 and others added 14 commits August 9, 2026 22:34
…ing)

What to take as-is from spatcore (orientation sources, wfs_headtrack plugin ABI, HeadFrame math, SOFA bake), copy-and-adapt from WFS-DIY (webcam source, tracker manager, fast-path pattern), and build new (SH rotation, MagLS-style decode); load-bearing conventions and milestones. Prerequisite: bump spatcore pin to >= c8fd7bf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…) + WP15 stage 0

Vendors ThirdParty/libmysofa + ThirdParty/zlib (trimmed trees from WFS-DIY)
and wires spatcore-mysofa; bundles the SADIE II KU100 set at assets/SOFA
(default binaural monitoring set + smoke fixture). Adds the fixture-gated
XoaSofaSmokeTests (SofaLoader -> HrirDatabase, ITD/alignment sanity).

Docs: WP15 promoted from the post-v1 backlog with decision records D51-D54
(HOA-native path, post-dual-band tap, RT orientation pull, no OSC yet);
CLAUDE.md pin + stale next-D-number corrected; third-party notices for
libmysofa / zlib subset / SADIE II.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…15 stage 1)

The orientation half of binaural monitoring, with no audio-path change:
the offline-render harness is byte-identical to the previous commit
(verified by building both and diffing --check output).

- AmbiHeadMapping.h: the spatcore<->XOA convention boundary. spatcore's
  HeadOrientation (+yaw right / +pitch up / +roll right-ear-down, radians)
  becomes an XOA soundfield rotation, and compensation is the matrix
  TRANSPOSE, never three negated angles. AmbiRotation forbids flips inside
  the DSP, so they live here and only here.
- XoaCameraHeadTrackerSource / HeadTrackerManager: ported from WFS-DIY. The
  wfs_headtrack plugin ABI, exeDir-first load, ABI-check-first, plugin-thread
  zero calibration by matrix composition, 1-Euro filtering and the 300 ms
  staleness fallback all carry over verbatim; XOA has no logger and no
  settings file, so failures surface as a status string and the camera index
  comes from the store.
- XoaMonitoringEngine: message-thread controller owning the trackers. A
  tracker that fails to start resolves to manual WITHOUT clearing the
  persisted id, so the selection survives an unplugged camera.
- Monitoring section: binaural* ids route there (new scope + undo domain),
  persist as monitoring.xml, and a project now carries a sofa/ folder.
  Defaults keep the monitor OFF. No OSC bindings by design (D54).
- MonitoringTab: enable/gain, tracker combo that rescans on popup, Set Zero
  shown only while a tracker is active, manual attitude dials and a live
  orientation readout.

Tests: convention goldens anchored on the encoder (turn right -> source moves
left, look up -> source drops, right-ear-down -> left source drops), the
transpose-is-inverse and composition-is-not-per-angle-negation traps, and the
Monitoring section's routing, undo isolation and project round-trip
(including a pre-WP15 project with no monitoring.xml).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
designShFilters() projects a baked HRIR grid onto the SN3D/ACN basis:

    h_ear[c][n] = (2l+1)/(4pi) * sum_g w_g * Y_c(d_g) * hrir_ear(d_g)[n]

the same law as XOA's SAD speaker decode with the virtual speakers taken to
the whole grid. Two conventions are load-bearing and documented where they
are applied: spatcore's HRIR grid measures azimuth positive to the listener's
RIGHT while XOA's SH basis is +Y-left, so the basis is evaluated at the
NEGATED azimuth; and quadrature uses exact zone areas (summing to 4pi) rather
than a cos(el) Riemann weight, which would misweight the rings and give the
poles zero.

The ITD is put back before projecting. SofaLoader hands over time-aligned
HRIRs with the onsets stripped into relDelaySec; a decoder that ignored them
would have no interaural time difference at all. Re-applied with a
Blackman-windowed sinc.

Every filter carries a constant 16-sample lead. The sinc kernel rings
BACKWARD as well as forward, and without the lead any ITD smaller than the
kernel half-width had its pre-ring clipped at tap 0 — losing energy
asymmetrically between the near and far ear, i.e. corrupting the very ILD the
bank exists to carry. The lead is identical for both ears, so it is only
0.33 ms of common monitor latency. The W-channel DC test below is what caught
this.

Tests: the full 2x121 bank against an mpmath golden built from the spec (not
transcribed from the C++) over a synthetic HRIR fixture; an encoder-anchored
reconstruction check; interaural polarity (ipsilateral ear louder AND
earlier, symmetric dead ahead); and the exact W-channel anchor — for SN3D the
W basis is identically 1, so that filter must be the solid-angle-weighted
mean HRIR, which pins the (2l+1)/(4pi) factor and the quadrature weights with
no SH truncation involved.

Measured and documented: the sampling decode reproduces arrival TIME within
~1 sample but smears peak AMPLITUDE (5% ipsilateral, 20%+ on a delayed
contralateral ear), because a direction-dependent delay is not band-limited
in the SH domain. That is inherent, and is what MagLS attacks in stage 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RT half. 121 SH channels in, a binaural pair out on a reserved hardware
output pair, with the loudspeaker render provably untouched.

- AmbiBinauralRenderer: uniform-partitioned overlap-save with a SHARED input
  transform. Each SH channel is transformed once per block and both ears
  accumulate from the same frequency-delay line, so a block costs 121 forward
  + 2 inverse transforms rather than 242 of each. A short or varying device
  block is staged through a FIFO; at the steady n == P case the step runs in
  the same call and adds no latency. Nothing allocates after prepare().
- AmbiBinauralFilterBank: FFT-bakes a design into partition spectra behind
  the same double-buffer + RtSnapshot hot-swap DecoderMatrixBuilder uses, so
  the pointer the RT side holds stays valid until the next publish and
  nothing is ever freed on the audio thread.
- Tap seam (D52): one call in AmbiBusAlgorithm between the dual-band stage
  and the decode GEMM. The renderer only reads the bus. The bail-out path
  marks the monitor idle so a stale block can never reach the pair.
- Output pair (D52): the Monitoring section doubles as the shared patch
  matrix's binaural tree, so the matrix greys the pair out and no speaker can
  be patched over it. Written inside the scatter phase, after every hardware
  input has been read (the aliasing invariant) and before the clear loop,
  which still silences the rows whenever the monitor is idle.
- BinauralDesignWorker + XoaMonitoringEngine: SOFA load and design run on a
  background thread (latest-wins, generation-stamped, AsyncUpdater hop back),
  never on the message thread. The bundled SADIE II set is staged next to the
  binary and used when no project HRTF file is chosen; a repeat request for
  the same key is dropped so a failed load does not retry forever.
- Enabling fades in over a block. Headphones are on someone's head.

Tests: partitioned convolution against a direct time-domain reference for
K=1/K=2/K=4; silence (not a null deref) with no bank; refusal of a bank
cooked for another block size; the enable edge; short/varying blocks; and the
D52 acceptance test — the same input rendered with and without a live monitor
gives BIT-identical speaker output, with both paths asserted non-silent so
the comparison cannot pass vacuously. Plus an end-to-end pass over the real
bundled SADIE set: load, design, cook, render, finite and bounded.

The offline-render harness is byte-identical to stage 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The monitor now rotates the SOUNDFIELD by the inverse of the head attitude,
so the static SH->ear bank keeps pointing where the listener's ears actually
are. This is why the bank can be static at all (D51): head motion costs one
block-diagonal multiply, not a new HRIR selection.

- The attitude is read fresh from the active orientation source every block
  (D53), bypassing the damped control path; a tracker that has gone stale or
  lost the face reports invalid and the manual parameters in the snapshot
  take over in the same branch.
- The rotation matrix is rebuilt only when the attitude actually moves, and a
  change crossfades over one block with the same idiom the scene rotation
  uses — a tracker stepping between blocks would otherwise click.
- A zero attitude with no rotation history short-circuits to the bus itself,
  so a monitor with no tracker and no manual offset pays nothing.

Reuses AmbiRotation (the handoff listed the SH rotation as "build new"; XOA
has had it since WP4, golden-tested) through AmbiHeadMapping's convention
boundary.

Test: rendering a field with attitude A equals rendering the SAME field
pre-rotated by the compensation for A with the head at zero — the definition
of "rotate the field, not the listener" — plus a guard that the rotation
does change the output, so the equivalence cannot pass for a renderer that
ignored the head. Speaker output remains byte-identical in the offline
harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Monitoring tab gains the SOFA row: a rescan-on-popup combo listing the
bundled SADIE II set first (stored as an empty filename) then every .sofa in
<project>/sofa, plus an Import entry that COPIES the chosen file into that
folder so a project stays self-contained and only a bare filename is ever
persisted.

A persisted set whose file has gone missing is listed as missing rather than
silently dropped — the stored name survives so the selection returns when the
file does, the same rule the tracker list follows. Load and design status
(including libmysofa's own message) is shown next to the combo, which is
XOA's substitute for the logger WFS-DIY had.

Verified with the GUI smoke run (app launches, cycles every tab including the
rebuilt Monitoring surface, exits clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lifts tools/headtrack/ from WFS-DIY. The plugin depends on nothing from
either app — it speaks the versioned C ABI in spatcore, so one built
wfs_headtrack.{dll,so,dylib} serves both, and the name is kept deliberately
rather than forking the artefact.

Only the app-shaped bits changed: the default staging directory now points at
XOA's CMake output (build/XOA_artefacts/<Config>) instead of WFS-DIY's
Projucer layout, and the README documents XOA's route to it (Monitoring tab
-> Head tracker -> Webcam, camera index as a project parameter, failures
reported in the UI since XOA has no log file).

The build gotchas already encoded in the scripts are carried over intact:
OpenCV's prebuilt config needs -DOpenCV_RUNTIME=vc16 pinned under VS2026, and
the staged runtime is opencv_world + the MSMF videoio DLL, NOT the 25 MB
ffmpeg one (that is for video files, not cameras).

Third-party notices updated for OpenCV and YuNet, both plugin-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sampling decode's measured weakness is that restoring the ITD makes the
HRIR field direction-dependently delayed, and a delay is not band-limited in
the SH domain — order 10 reproduces arrival TIME within a sample but smears
the peak amplitude of a delayed ear by 20%+.

alignedHf attacks exactly that: true phase (and thus the ITD) BELOW a 1.5 kHz
crossover where the ear reads interaural time, and above it the time-ALIGNED
HRIRs spatcore already provides — removing the very delay the truncation
cannot represent. The two are combined with complementary real per-bin gains
summing to exactly 1, the frequency-domain sibling of the loudspeaker path's
dual-band decode.

NAMING, deliberately: this shares MagLS's intent and crossover rationale but
is NOT MagLS — there is no iterative magnitude fit with phase continuation.
The plan and D51 called for MagLS; implementing something else under that
name would have been the wrong kind of convenient, so the mode says what the
code does and true MagLS stays recorded as future work.

Two bugs the tests caught while building it:
- The blend needs taps of its own. A one-octave crossover at 1.5 kHz has an
  impulse response ~1 ms long; without reserved headroom the truncation rang.
- The crossover's BACKWARD smear wrapped around the transform and was cut,
  costing 2.3% of broadband DC gain. Both banks are now placed at a
  linear-phase lead so the smear lands inside the filter.

Tests: DC gain survives the crossover (its gains sum to 1 and bin 0 is pure
low band); the interaural time ordering survives in the blended bank; and the
contralateral peak error is strictly SMALLER than the sampling decode's — the
actual quality claim, not a proxy for it. The golden tests now pin
DecoderMode::sampling explicitly, since the default moved.

binauralDecoderMode is exposed in the Monitoring tab so sampling stays
reachable as the golden-anchored reference. Speaker DSP byte-identical; GUI
smoke clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measures what the monitor actually costs on the audio thread at order 10
rather than asserting a guess: Release, dev machine, 9.5-15% of real time
with the head still and 12-18% while it is moving (the rotation matrix
rebuilds per block), across 64/128/256-sample blocks. Printed every run, with
a loose 35% tripwire that only applies to optimized builds — a Debug run
measures roughly ten times this and says nothing about shipped code, so
gating on it would just break the Debug suite.

The dense-bank detail matters: the perf fixture loads all 121 SH channels,
because a sparse one would flatter the very inner loop being measured. The
obvious next lever is SIMD on the complex multiply-accumulate, which is
essentially the whole cost.

Docs: DEVPLAN WP15 records the status, the measured numbers, and the three
deliberate deviations (alignedHf is not MagLS; sampling stays selectable as
the reference decode; no --binaural offline baseline, since the unit suite
and the bit-identical speaker assertion already pin correctness without a
second hashed artefact to maintain). The original handoff doc keeps its
intent but now opens with the corrections implementation found in it — the
Wigner-D rotation already existed, PartitionedFirConvolver.h does not, and
XOA has neither a logger nor a settings file. CLAUDE.md points at the DEVPLAN
as authoritative and states the opposite-azimuth-sense trap between XOA's
+Y-left soundfield frame and spatcore's SOFA-style head frame, which is the
single easiest thing to get silently wrong here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion

The head-track plugin ABI hands over RAW attitude, and a degenerate face box
makes the geometric estimator emit inf/NaN. That reached
rot::buildFromCartesian on the AUDIO thread, where it built a non-orthonormal
rotation: the determinant jassert in Debug, and in Release NaN that then lived
in the convolution FDL forever, silently killing the headphone bus.

Guard at both ends. The camera source drops non-finite poses at the ABI
boundary and never publishes a non-finite filter output; the RT stage treats
any non-finite attitude as identity, because it does not get to trust its
sources (a corrupt project file reaches the same code through the manual
parameters).

The regression test drives a source reporting NaN with valid=true and asserts
the output stays finite across several blocks — it fails without either guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
spatcore bumped its own JUCE pin to 9.0.1; this matches it on the app side.

JUCE 9.0.0 -> 9.0.1 is 56 commits of bugfixes (unresponsive Linux GUIs,
Freetype on older distros, CoreAudio compilation, malformed-AudioFormat
hardening). Both documented breaking changes are non-issues here:

- The WebView interop package moved to native/typescript/webview-interop.
  Neither XOA nor spatcore uses WebBrowserComponent (juce_simpleweb is an
  unrelated HTTP/WS module).
- zlib/libpng/libjpeg/libflac are now documented as built in C language mode,
  which would put JUCE's unnamespaced zlib symbols next to the vendored
  inflate subset that spatcore-mysofa links into the same binary. Checked:
  modules/juce_core/juce_core_zlib.c is byte-identical at 9.0.0 and 9.0.1, so
  that build mode was already in effect at the version we shipped. The doc
  entry is catching up, not a new hazard.

spatcore d011103 -> 47b1ae5 (v0.2.0) is CI-only: minimal-app pinned to JUCE
9.0.1 plus libxi-dev on its Linux lane. No engine source changed.

libxi-dev (XInput2, required since JUCE 9) is now listed explicitly in our
Linux CI and in the documented apt lines. It arrives transitively via
libgtk-3-dev today, which is why CI was green without it; the build no longer
depends on that.

Verified on Windows (VS 18 2026), Release and Debug: build clean (no new
warnings), ctest 2/2, --gui-smoke exit 0, OSC control-replay 12/12 against the
golden. macOS/Linux left to CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oling

The head-tracker plugin sources (tools/headtrack/src, models) are byte-identical
between the two apps; only the build tooling had drifted.

- .gitignore: the Windows build script caches a ~180 MB OpenCV prebuilt under
  tools/headtrack/.opencv, which was showing up as untracked here. (WFS-DIY has
  ignored it since the tooling landed; its build/ tree is already covered by our
  generic build/ rule.)
- build-headtrack-plugin.sh: cap `cmake --build --parallel` at the core count.
  Unbounded `make -j` OOM-killed WFS-DIY's 16 GB Linux runner (exit 143).

Still deliberately NOT ported: the BUNDLED_OPENCV static-OpenCV path in
WFS-DIY's tools/headtrack/CMakeLists.txt. It exists for release packaging, and
XOA has no release workflow yet — when one lands, port it then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/headtrack builds OUT of the app tree against its own OpenCV — that is
what keeps OpenCV out of the app build — so nothing else in CI would catch a
break there. WFS-DIY has gated it since the tooling landed; this matches.

Windows caches the 4.10.0 prebuilt under tools/headtrack/.opencv (key
opencv-win-4.10.0, ~180 MB, downloaded once); macOS uses brew opencv, Linux
libopencv-dev. Release in every lane even though CI builds the app Debug: the
plugin ships Release regardless (C ABI, no shared CRT state). Each step sits
last in its job so the app build and tests fail first.

Compile check only — runtime behaviour needs a camera and stays a dev-machine
gate. The scripts stage next to the app only if that output dir exists, so a
Debug-app / Release-plugin run just skips the copy.

Verified the Windows lane locally end to end from a cold cache: download,
configure against the prebuilt, build, dumpbin shows all 7 wfs_headtrack_*
exports. macOS/Linux follow WFS-DIY's identical, green steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pob31
pob31 merged commit 8b7656d into main Aug 12, 2026
3 checks passed
@pob31
pob31 deleted the feat/hoa-binaural branch August 12, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant