HANDOFF Rev 3 + RIR fixtures: modeled rooms as closed-loop baselines - #3
Merged
Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two housekeeping-with-teeth changes:
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.RIR fixtures — the fixture half of the open RIR decision:
tools/fixtures/make_rir_fixtures.pygeneratestests/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).--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