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
@@ -1,4 +1,5 @@
# Generated data tables — regenerate via scripts/, never hand-format.
include/ambitap/dsp/room_data.h
include/ambitap/math/binaural/hrtf_data.h
include/ambitap/math/geometry/tdesigns.h
third_party/*
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
$(git ls-files '*.h' '*.cpp' \
':!:third_party/**' \
':!:bench/compare/**' \
':!:include/ambitap/dsp/room_data.h' \
':!:include/ambitap/math/binaural/hrtf_data.h' \
':!:include/ambitap/math/geometry/tdesigns.h')

Expand Down
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ endif()
if(AMBITAP_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
# Offline dsp::room IR renderer for the room_verification.ipynb gates.
add_subdirectory(tools/room_render)
endif()
if(AMBITAP_BUILD_EXAMPLES)
add_subdirectory(examples)
Expand Down
12 changes: 10 additions & 2 deletions docs/PERCEPTUAL-VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ binauralization through `dsp::binaural_renderer`; the ISO 3382 machinery
| R6 | Clarity | C50 and C80 from the rendered IR vs the analytic prediction of the direct + ER + exponential-tail parameterization | within **±2 dB**, and strictly monotone decreasing in source distance across a 3-distance sweep |
| R7 | SH order balance | tail (t > 80 ms) energy per SH order n, normalized per channel of that order | flat within **±1.5 dB** across orders — no order-dependent coloration of the diffuse field |
| R8 | Tail isotropy | \|rE\| (energy-vector magnitude) of the late tail, 20 ms windows, averaged | ≤ **0.1** |
| R9 | Interaural coherence | IACC of the binauralized tail (t > 80 ms), per octave band | broadband **0.3**; per-band within **0.15** of the KEMAR diffuse-field coherence curve above 500 Hz (below 500 Hz diffuse coherence is naturally high — track the reference, don't chase 0) |
| R9 | Interaural coherence | IACC of the binauralized tail (t > 80 ms), per octave band and broadband | broadband within **0.15** of the same-order diffuse-field reference (*revised 2026-07 — see below*); per-band within **0.15** of the KEMAR diffuse-field coherence curve above 500 Hz (below 500 Hz diffuse coherence is naturally high — track the reference, don't chase 0) |
| R10 | Determinism | fixed seed, two renders | byte-identical SH IR |

Rationale: R1–R3 are the "checkable exactly" layer — if the image-source
Expand All @@ -170,6 +170,14 @@ itself, and at the ears — late IACC near the diffuse-field reference is
the strongest known objective correlate of externalization and perceived
envelopment.

Threshold revision, R9 broadband (2026-07, first measurement run): the
original absolute target (broadband IACC ≤ 0.3) is unattainable through an
order-3 rendering chain — the ideal isotropic reference itself measures
0.429 broadband, because order truncation reconstructs both ears from the
same smooth low-order field at high frequencies — so the broadband gate is
restated relative to the diffuse-field reference (|IACC − IACC_ref| ≤
0.15), the same tracking rule the per-band gate already applies.

### Listening pass

Headphone-based (the module's headline value is making binaural
Expand Down Expand Up @@ -209,7 +217,7 @@ numbers, so "it passed" is always attributable to a build.

| Date | Commit | Module | Numeric gates | Listening (n, headline result) | Verdict |
|---|---|---|---|---|---|
| | | — | *no runs yet — modules not built* | — | — |
| 2026-07-03 | ada6191 | room~ (offline prototype, seed 11) | all 21 enforced R1–R10 checks PASS, both tail architectures — fdn: worst T20 +6.0%, EDT +21.1%, C50/C80 err 0.56 dB, order balance 0.16 dB, \|rE\| 0.053, worst IACC dev 0.079, broadband dev 0.020; conv: +9.6%, +12.9%, 0.58 dB, 0.19 dB, 0.037, 0.018, 0.034 (broadband gated re. reference per the R9 revision above) | — (numeric phase; listening pending first ship) | prototype accepted; **FDN tail selected** for the C++ `dsp::room` |

---

Expand Down
1 change: 1 addition & 0 deletions include/ambitap/ambitap.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include "dsp/format_converter.h"
#include "dsp/mirror.h"
#include "dsp/nfc.h"
#include "dsp/room.h"
#include "dsp/rotator.h"
#include "dsp/spatial_compressor.h"
#include "dsp/util/async_rebuilder.h"
Expand Down
1,073 changes: 1,073 additions & 0 deletions include/ambitap/dsp/room.h

Large diffs are not rendered by default.

5,192 changes: 5,192 additions & 0 deletions include/ambitap/dsp/room_data.h

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions scripts/generate_room_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Generate the seed-11 FDN noise/sign tables for dsp::room.

dsp::room replicates the FDN tail architecture verified by
notebooks/room_verification.ipynb against the R1-R10 gates in
docs/PERCEPTUAL-VERIFICATION.md. That prototype (notebooks/room_model.py)
draws every random quantity from one numpy Generator (PCG64) seeded with the
committed seed 11 — the seed is part of the parameterization, and gate R10
byte-compares two renders. Reproducing numpy's PCG64 stream *and* its
ziggurat Gaussian sampler bit-for-bit in C++ is not reasonable, so the raw
draws are baked here instead (the hrtf_data.h approach) and everything
DERIVED from them — octave-band splitting, decay shaping, normalization,
FIR fitting — is computed by dsp::room at run time from its parameters.

Baked data, in the prototype's exact draw order (room_model.render_fdn_tail):

1. feedback matrix signs s1, s2 = rng.choice([-1, 1], 16) twice
(feedback = diag(s1) . H/4 . diag(s2))
2. output matrix signs s3, s4 likewise
(out_mix = (diag(s3) . H/4 . diag(s4))[:channels])
3. input-burst noise 16 lines x rng.standard_normal(2560)
(raw, UNSHAPED: dsp::room band-splits and applies
the parameterized per-octave decay envelopes)

Regeneration (only needed if the seed or FDN topology changes — both are
part of the verified parameterization, so expect to re-run the verification
notebook too):

python3 scripts/generate_room_data.py

It rewrites include/ambitap/dsp/room_data.h.

Copyright 2026 Timothy Place. MIT License.
"""

import pathlib

import numpy as np

SEED = 11
LINES = 16
BURST_LEN = 2560

HEADER = pathlib.Path(__file__).resolve().parent.parent / "include/ambitap/dsp/room_data.h"


def emit_signs(lines, name, signs):
body = ", ".join(f"{s:+.1f}f" for s in signs)
lines.append(f"inline constexpr float {name}[room_data_lines] = {{{body}}};")
lines.append("")


def main():
rng = np.random.default_rng(SEED)
s1 = rng.choice([-1.0, 1.0], size=LINES)
s2 = rng.choice([-1.0, 1.0], size=LINES)
s3 = rng.choice([-1.0, 1.0], size=LINES)
s4 = rng.choice([-1.0, 1.0], size=LINES)
noise = np.stack([rng.standard_normal(BURST_LEN) for _ in range(LINES)])

lines = [
"/// AmbiTap: target-independent ambisonics library",
"/// Seed-11 FDN noise/sign tables for dsp::room.",
f"/// Auto-generated by scripts/generate_room_data.py (numpy PCG64, seed {SEED});",
"/// see that script for why the draws are baked rather than re-derived.",
"/// Timothy Place",
"/// Copyright 2026 Timothy Place.",
"",
"#ifndef AMBITAP_DSP_ROOM_DATA_H",
"#define AMBITAP_DSP_ROOM_DATA_H",
"",
"#include <cstddef>",
"",
"namespace ambitap {",
"",
f"inline constexpr int room_data_seed = {SEED};",
f"inline constexpr size_t room_data_lines = {LINES};",
f"inline constexpr size_t room_data_burst_length = {BURST_LEN};",
"",
"/// Diagonal sign conjugations of the (Sylvester) Hadamard mixing",
"/// matrices: feedback = diag(rows) . H/sqrt(16) . diag(cols).",
]
emit_signs(lines, "room_feedback_sign_rows", s1)
emit_signs(lines, "room_feedback_sign_cols", s2)
lines.append("/// Same construction for the line-to-SH output distribution")
lines.append("/// (rows 0..channels-1 are used at render order <= 3).")
emit_signs(lines, "room_output_sign_rows", s3)
emit_signs(lines, "room_output_sign_cols", s4)

lines.append("/// Raw unit-variance noise draws for the per-line input bursts,")
lines.append("/// indexed room_noise[line][sample]. Unshaped: dsp::room applies the")
lines.append("/// octave-band split, decay envelopes, and unit-energy normalization")
lines.append("/// from its runtime RT60 parameterization.")
lines.append("inline constexpr float room_noise[room_data_lines][room_data_burst_length] = {")
for i in range(LINES):
lines.append(f" {{ // line {i}")
row = noise[i].astype(np.float32)
for start in range(0, BURST_LEN, 8):
chunk = ", ".join(f"{v:+.8e}f" for v in row[start:start + 8])
lines.append(f" {chunk},")
lines.append(" },")
lines.append("};")
lines.append("")
lines.append("} // namespace ambitap")
lines.append("")
lines.append("#endif // AMBITAP_DSP_ROOM_DATA_H")
lines.append("")

HEADER.write_text("\n".join(lines))
print(f"wrote {HEADER} ({HEADER.stat().st_size} bytes)")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ add_executable(ambitap_tests
test_dsp_transforms.cpp
test_dsp_stateful.cpp
test_nfc.cpp
test_room.cpp
test_dsp_matrix.cpp
test_dsp_binaural.cpp
test_analysis_soundfield.cpp
Expand Down
Loading
Loading