Skip to content

ITU compliance Stage 2: residual-echo post-filter, comfort noise, AEC chain#7

Merged
tap merged 2 commits into
claude/itu-stage1-signalsfrom
claude/itu-stage2-postfilter
Jul 16, 2026
Merged

ITU compliance Stage 2: residual-echo post-filter, comfort noise, AEC chain#7
tap merged 2 commits into
claude/itu-stage1-signalsfrom
claude/itu-stage2-postfilter

Conversation

@tap

@tap tap commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Stage 2 of the ITU compliance plan: the residual-echo suppressor + matched comfort noise (mutap/postfilter.h), and mutap::aec_chain — the canceller + post-filter unit the compliance matrix measures. Stacked on #6 (Stage 1 signal layer); rebases onto main after #6 merges.

What's in it

  • residual_suppressor<Sample> — per-bin Wiener gain on the canceller output E whose depth comes from a leakage estimate (residual power per unit echo-estimate power), gated by mic-vs-echo-estimate coherence. Gains constrained to a causal 2B-tap filter (one block of latency), attack-smoothed, suppressed bins refilled with comfort noise shaped to a two-window minimum-statistics noise floor. RT contract as everywhere: constructor allocates and may throw; everything after is noexcept and allocation-free.
  • aec_chain<Sample, Canceller> — defaults to the raw partitioned FD-Kalman core; pem_afc still composes via its 3-argument surface (it grew an echo_estimate_block() accessor).
  • tests/test_postfilter.cpp — 12 measured-first tests pinning every Stage 2 deliverable at its margin target, on both fixture rooms, both cores, 48 kHz and 16 kHz.
  • Instrument fix: A-weighting poles above Nyquist are prewarp-clamped (the 12.2 kHz pole pair made every 16 kHz measurement NaN).
  • Docs: "Stage 2 delivered" section in the matrix with the full measured table, README chain bullet, HANDOFF stage note.

The three design decisions the measurements forced

  1. The AEC chain does not use PEM. Open-loop AEC has an exogenous far end — no closed-loop bias to remove — and the predictor's block-by-block refit injects gradient noise that floors misalignment near −20 dB. Measured on the identical scenario: raw FD-Kalman single-talk residual −75.6 dBm0(A) bare vs PEM-Kalman's −28.9, with double-talk immunity intact (post-DT −70.9) from the Kalman's own per-bin noise-PSD tracking. PEM remains the right structure for the closed loop (AFC).
  2. The discriminator correlates the MIC (D = E + Ŷ), never E, against Ŷ. A converged adaptive filter keeps E orthogonal to its reference by construction: coh(E, Ŷ) saturates at 0.34 while adapting (0.53 frozen — the isolation experiment that located this). coh(D, Ŷ) measures 0.99 and is insensitive to adaptation jitter.
  3. Suppression depth comes from a coherence-gated leakage estimate, not the coherence itself. P.501's AM-FM double-talk combs interleave 20 Hz apart at the low end — no realizable gain filter notches that selectively, and a coherence-proportional gain measured 5.5 dB of near-end attenuation there. The Wiener-on-E form rides to unity wherever near-end energy inflates |E|², so double-talk transparency is the shape of the rule, not a detector decision.

Measured vs matrix margin targets (all pass)

Deliverable Clause bound Margin target Measured
ST residual, cabin / studio < −58 dBm0(A) < −64 −80.5 / −89.7
ST residual, NLMS core / 16 kHz < −58 < −64 −66.2 / −70.7
DT send attenuation, cabin / studio ≤ 3 dB ≤ 1.5 1.14 / 1.03
DT echo loss, worst band 200–6950 Hz ≥ 27 dB ≥ 33 37.9 / 34.2 (270 Hz)
ERL by 600 / 1200 ms 40 dB @ 1200 ≥ 40 / ≥ 46 43.2 / 46.9
Comfort-noise level match +2 / −5 dB +1 / −2.5 −1.50
Comfort-noise spectrum, worst band mask half-mask 1.69 dB
Noise pumping ≤ 10 dB ≤ 5 2.9
Near-end build-up at DT onset ≤ 50 ms ≤ 25 11.6 ms

Chain config for all of the above: raw FD-Kalman, transition 0.9998 + initial uncertainty 10 (both swept; 0.99995 costs double-talk transparency and tracking, the larger P(0) buys ~4 dB of early convergence), suppressor at defaults.

Full test suite: 124/124 pass locally. Rejected designs (power-leakage ratios, instantaneous-|E|² rules, coh(E,Ŷ), Hann-tapered gain cut, one-pole floor trackers) are documented in the header comments with their measured failures.

Still Stage 3's to prove: the per-row multi-rate compliance suite, spectral echo mask, switching/activation battery, stability sweep, and the G.168-adapted rows.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TLE2rvptWxV8catE8vTvgx


Generated by Claude Code

claude added 2 commits July 16, 2026 14:11
… chain

mutap/postfilter.h delivers the Stage 2 items of docs/itu-compliance.md:

- residual_suppressor: per-bin Wiener gain on E whose DEPTH comes from a
  leakage estimate (residual power per unit echo-estimate power) gated by
  mic-vs-echo-estimate coherence. The discriminator correlates the MIC
  (D = E + Yhat), never E, against Yhat: a converged adaptive filter
  keeps E orthogonal to its reference, so coh(E, Yhat) saturates at 0.34
  while adapting where coh(D, Yhat) measures 0.99. Gains are constrained
  to a causal 2B-tap filter (rectangular cut — a Hann taper measured
  WORSE double-talk transparency), attack-smoothed against estimator
  chatter, and suppressed bins are refilled with comfort noise shaped to
  a two-window minimum-statistics noise floor (one-pole trackers
  measured either -8 dB undershoot or tens-of-seconds acquisition).

- aec_chain: canceller + suppressor as the unit the compliance matrix
  measures. Defaults to the RAW partitioned FD-Kalman core, not pem_afc:
  open-loop AEC has an exogenous far end — no closed-loop bias to remove
  — and PEM's predictor refit floors misalignment near -20 dB where the
  raw core measures -75.6 dBm0(A) bare with double-talk immunity from
  its own noise-PSD tracking. pem_afc (grown an echo_estimate_block()
  accessor) still composes via the 3-argument branch.

Measured on the golden model (test_postfilter.cpp, all margin targets):
ST residual -80.5/-89.7 dBm0(A) cabin/studio (target < -64; NLMS core
-66.2, 16 kHz -70.7), DT send atten 1.14/1.03 dB (<= 1.5), DT echo loss
worst band 37.9/34.2 dB (>= 33), ERL 43.2 dB by 600 ms / 46.9 by 1200
(>= 40/46), comfort noise -1.50 dB level / <= 1.69 dB per band /
pumping 2.9 dB / build-up 11.6 ms. Full tables and the four design
decisions the measurements forced are in the matrix's "Stage 2
delivered" section.

Instrument fix: A-weighting poles above Nyquist (12.2 kHz pair at
fs = 16 kHz) are prewarp-clamped to 0.45 fs — tan() past pi/2 flipped
sign and the 16 kHz chain measurements went NaN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLE2rvptWxV8catE8vTvgx
…honest number

ASan (and MSVC, via the garbage it read) caught NearEndBuildUpTime
reading 250 ms past the end of its level trace: the near-end onset sat
at 5/6 of the run but the settled-level window needed 2 s of tail.
Onset moved to 2/3 (3.5 s of tail) with an explicit size guard.

Measured honestly, build-up at gain_release 0.9 is 26.0 ms — over the
switching rows' 25 ms margin target (the old 11.6 ms figure was
computed against the corrupted median). Release 0.85 measures 20.9 ms
and is the new default; 0.8 was measured too fast for the rest of the
board (studio worst DT echo-loss band fell to 32.8 dB, under the 33 dB
target). Full re-measurement at 0.85, all margin targets still met:
ST -79.9/-88.9 dBm0(A), DT atten 1.05/0.93 dB, DT echo loss worst band
38.0/34.1 dB, comfort noise -1.20 dB, pumping 3.3 dB. Test comments,
matrix table and README refreshed; ItuChain suite re-run green under
ASan+UBSan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLE2rvptWxV8catE8vTvgx
@tap tap force-pushed the claude/itu-stage1-signals branch from dcb2254 to a223fb4 Compare July 16, 2026 14:11
@tap tap force-pushed the claude/itu-stage2-postfilter branch from f5b5243 to 558395d Compare July 16, 2026 14:11
@tap tap merged commit 130ce4c into claude/itu-stage1-signals Jul 16, 2026
20 checks passed
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