Skip to content

HANDOFF Rev 3 + RIR fixtures: modeled rooms as closed-loop baselines - #3

Merged
tap merged 3 commits into
mainfrom
claude/handoff-doc-plan-o111dm
Jul 15, 2026
Merged

HANDOFF Rev 3 + RIR fixtures: modeled rooms as closed-loop baselines#3
tap merged 3 commits into
mainfrom
claude/handoff-doc-plan-o111dm

Conversation

@tap

@tap tap commented Jul 15, 2026

Copy link
Copy Markdown
Owner

What

Two housekeeping-with-teeth changes:

  1. HANDOFF.md Rev 3 — the plan document becomes a handoff document proper: a "State of the repo" map, "Working notes for the next session" capturing the operational lessons this build cycle paid for (the measured-threshold doctrine, the chaotic-trajectory assertion rules with the three incidents that taught them, one room is not an evaluation, the two emulated filter lists that must stay in sync, the Hexagon CI quirks, the post-rebase-merge submodule re-pin dance, the min-api outlet gotcha, the book's honesty rule), a ranked what's-next (in-Max listening first), and refreshed open decisions. Also closes a gap the pass exposed: the demo notebook's generator now lives in the repo (tools/notebook/build_afc_demo.py), and the committed notebook was regenerated by the versioned script as proof.

  2. RIR fixtures — the fixture half of the open RIR decision:

    • tools/fixtures/make_rir_fixtures.py generates tests/fixtures/rir_*.h: three physically-modeled rooms (pyroomacoustics image-source; deterministic, geometry embedded in each header) — a dry nearfield studio, a mid-size rehearsal room, a reverberant hall. Conditioning is uniform and documented (bulk-delay trim, 4096-tap truncation, unit-energy normalization).
    • The same script imports measured rooms with --from-wav (provenance/license text mandatory) — that's the remaining half of the decision: pick MYRiAD/openAIR rooms or measure your own; one command each. (The dataset hosts are unreachable from the dev container's network policy, so measured WAVs enter via a commit.)
    • tests/test_rir_fixtures.cpp: the conditioning contract plus closed-loop ASG on real reflection structure, measured before thresholds were set — Kalman +17.8/+18.8/+19.1 dB across the three rooms (asserted > 10), classic NLMS stack +9.4 dB (asserted > 4).

CI

Native 88/88; M55 QEMU green locally with the fixture data in the image (~120 KB rodata, 2 MB region; RIR tests themselves are host-only via the existing emulated-selection filters). The style gate's naming rule caught the generated constants un-prefixed — fixed at the generator (k_rir_*) in the last commit, style now green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YMmsdFrRWzrxKXiJDHwEHt


Generated by Claude Code

claude added 3 commits July 15, 2026 17:53
…k generator

The plan document becomes a handoff document proper: a "State of the
repo" map, "Working notes for the next session" capturing the
operational lessons this build cycle paid for (measured-threshold
doctrine, chaotic-trajectory assertion rules with the three incidents
that taught them, the one-room-is-not-an-evaluation rule, the two
emulated filter lists that must stay in sync, the Hexagon CI quirks,
the submodule re-pin dance, the min-api outlet gotcha, the book's
honesty rule), a ranked what's-next (in-Max listening first), and
refreshed open decisions.

Also closes a gap the handoff pass exposed: notebooks/afc_demo.ipynb
was committed but its generator lived outside the repo. It now lives at
tools/notebook/build_afc_demo.py (paths made repo-relative); the
notebook is regenerated by the versioned script as proof.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMmsdFrRWzrxKXiJDHwEHt
tools/fixtures/make_rir_fixtures.py generates tests/fixtures/rir_*.h:
three image-source rooms (pyroomacoustics; deterministic and exactly
reproducible from the parameters embedded in each header) — a dry
nearfield studio, a mid-size rehearsal room, and a reverberant hall
with a distant mic. Conditioning is uniform and documented: bulk-delay
trim to a 32-sample guard, 4096-tap truncation, unit-energy
normalization. The same script imports MEASURED rooms (academic dataset
WAVs or swept-sine measurements) with --from-wav, provenance/license
text required — that half of the fixtures decision stays open in
HANDOFF.md (the dataset hosts are unreachable from this dev container,
so measured WAVs enter via a commit).

tests/test_rir_fixtures.cpp: the fixtures' conditioning contract, plus
closed-loop ASG on real reflection structure (block 64, 16 partitions
over the first 1024 taps, speech-envelope material, measured before
thresholds were set): Kalman +17.8/+18.8/+19.1 dB across the three
rooms (asserted > 10), classic NLMS stack +9.4 dB on the studio
(asserted > 4).

Native suite: 88/88. The fixture data adds ~120 KB of rodata to the
bare-metal image (text 693 KB of 2 MB; real data+bss ~9 KB, heap
2039 KB) — the RIR tests themselves are host-only via the existing
emulated-selection filters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMmsdFrRWzrxKXiJDHwEHt
clang-tidy's naming gate rejects un-prefixed global constants; the
generator now emits k_rir_<name>* and the headers are regenerated
(identical data, renamed identifiers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMmsdFrRWzrxKXiJDHwEHt
@tap
tap merged commit 7a82d0d into main Jul 15, 2026
20 checks passed
@tap
tap deleted the claude/handoff-doc-plan-o111dm branch July 17, 2026 12:56
tap pushed a commit that referenced this pull request Jul 20, 2026
Every echo path so far is linear, which structurally flatters a linear
canceller. Add a Hammerstein loudspeaker model (memoryless scaled-error-
function nonlinearity, then the room) so the comparison exercises the
regime a linear filter cannot win — where WebRTC AEC3's nonlinear-aware
suppressor is built to operate.

- compare_driver.h: loudspeaker() (SEF, the canonical nonlinear-echo test
  curve, level-restored so eta is a pure distortion knob) + a THD meter.
- compare_main.cpp: --nl-sweep prints ERLE vs THD for every subject.
- aecmos_eval.py: a matching loudspeaker(), --nl-sweep for AECMOS echo
  MOS vs THD on real speech, and the scoring refactored into a shared
  score_subjects() helper.

The result is the sharpest finding of the whole comparison. On linear
paths MuTap leads ERLE by 40+ dB; the instant the speaker distorts,
MuTap and Speex shed that lead and cross AEC3 below ~1% THD, while AEC3
holds ~19-24 dB across the range (nl_sweep.json). Perceptually the blow
is softer — MuTap's residual suppressor + comfort noise mask most of the
residual, so AECMOS echo MOS barely moves (nl_aecmos.json): raw ERLE
overstates the nonlinear vulnerability of a chain with a suppressor,
while a bare linear canceller (Speex) has no answer. Notebook and
docs/aec-comparison.md gain a "Direction 1c" section; the linear-path
caveat is replaced by the residual memory/Volterra-model gap.

Portable (no third-party) build still compiles; the sweep degrades to
MuTap-only when the other subjects are not linked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYAjxkJi1gAsNNoj5Vn9UE
tap pushed a commit that referenced this pull request Aug 8, 2026
…rflow)

The first run of the new macOS sanitizer job found one memory error, in
the harness rather than the library:

  ERROR: AddressSanitizer: container-overflow
  READ of size 8
    #2 mutap_test::itu::max_level_dbm0a(...)      itu_chain.h:277
    #3 Float32Parity_DoubleTalkTransparency_Test  test_float32.cpp:139
  allocated by:
    #2 std::vector<double>::reserve(...)
    #3 mutap_test::itu::run_chain<f32_chain>(...) itu_chain.h:260

run_chain_on reserves x.size() but appends only whole blocks, so an
output is up to block-1 samples shorter than the input that drove it.
DoubleTalkTransparency positions its window from input lengths
(xc.size() + n_dt), which overshoots that, and the sub-vector
construction inside max_level_dbm0a then reads unconstructed doubles out
of the reserved tail. The report's arithmetic matches exactly: a
4684800-byte region read at offset 4683776, i.e. 128 doubles short of
the end, and x.size() % 256 == 128.

Uninstrumented, this silently folds whatever the allocator left in that
tail into a compliance measurement. test_float32.cpp:183 already guards
the same hazard with std::min(rr.out.size(), ...); line 139 does not.

Clamp inside the meter instead of at one call site, so no present or
future caller can reproduce it, and guard the 0.1*fs skip so a
degenerate window cannot walk max_element past the end. Only windows
that were already out of range change value, so no legitimate
measurement moves: the full suite passes 187/187 locally.

This does NOT explain the float32 flake in #31. Both #31 rows failed by
assertion in the same job with ASan silent on them, and their windows
are in bounds by construction — test_float32.cpp:166 passes
rr.out.size() as the end, and max_in already clamps with
std::min(tr.size(), ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5ChQPJTkMipypJMdRudco
tap pushed a commit that referenced this pull request Aug 11, 2026
…rflow)

The first run of the new macOS sanitizer job found one memory error, in
the harness rather than the library:

  ERROR: AddressSanitizer: container-overflow
  READ of size 8
    #2 mutap_test::itu::max_level_dbm0a(...)      itu_chain.h:277
    #3 Float32Parity_DoubleTalkTransparency_Test  test_float32.cpp:139
  allocated by:
    #2 std::vector<double>::reserve(...)
    #3 mutap_test::itu::run_chain<f32_chain>(...) itu_chain.h:260

run_chain_on reserves x.size() but appends only whole blocks, so an
output is up to block-1 samples shorter than the input that drove it.
DoubleTalkTransparency positions its window from input lengths
(xc.size() + n_dt), which overshoots that, and the sub-vector
construction inside max_level_dbm0a then reads unconstructed doubles out
of the reserved tail. The report's arithmetic matches exactly: a
4684800-byte region read at offset 4683776, i.e. 128 doubles short of
the end, and x.size() % 256 == 128.

Uninstrumented, this silently folds whatever the allocator left in that
tail into a compliance measurement. test_float32.cpp:183 already guards
the same hazard with std::min(rr.out.size(), ...); line 139 does not.

Clamp inside the meter instead of at one call site, so no present or
future caller can reproduce it, and guard the 0.1*fs skip so a
degenerate window cannot walk max_element past the end. Only windows
that were already out of range change value, so no legitimate
measurement moves: the full suite passes 187/187 locally.

This does NOT explain the float32 flake in #31. Both #31 rows failed by
assertion in the same job with ASan silent on them, and their windows
are in bounds by construction — test_float32.cpp:166 passes
rr.out.size() as the end, and max_in already clamps with
std::min(tr.size(), ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5ChQPJTkMipypJMdRudco
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.

2 participants