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
45 changes: 45 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,51 @@ carries the measured numbers; this is the map:
but is not defined inside `tap::mu::inline TAP_DSP_FFT_ABI` and does
not pass its FFT size through `fft_detail::checked_fft_size` (working
note 6 (a) and (b)); pin it in `test_fft_engine_contract.cpp`.
12. **The safety layer (2026-09-30; [`docs/howl-guard.md`](docs/howl-guard.md)).**
PR A (tap/MuTap#77) adds `howl_detector`; PR B adds
`include/mutap/howl_guard.h` (`tap::mu::howl_guard`: per-mic ARMING /
OPEN / OPEN_CAPPED / DUCKED / RELEASING / LATCHED over D ∧ A′ and the
detector, strikes and back-off, attribution, a post-reverb bus stage),
`afc_chain::set_guard()` (refuses a canceller without the shadow;
`reset()` re-arms the guard; bit-identical without one), the live loop
`tests/support/guard_loop.h` / `guard_runs.h`, the float state-machine
suite in both emulated selections and the host claims
(`test_howl_guard_host.cpp`, 13 gated rows, 596.71 s on 4 threads of
the Intel Mac; `HowlGuardSweep.*` behind `MUTAP_SLOW`). Measured (macOS
x86_64, double, every run from reset): 0 howl blocks with the guard in
every single-mic gated row, including the canceller's limit + 6 dB where
the unguarded twins howled (575 and 1203 blocks), and 1 block in the
two-mic forced-howl row; attribution ducked the wrong mic alone in 0 of
8 gated runs on the Intel Mac, 1 of 8 on macOS arm64 CI and 1 of 20 in
the sweep (gated as at most 2 of 8); cold starts with the
backing track declare in a median 1.77–2.07 s; without it none declares
and every run leaves ARMING through the cap; 0 ducks on stable material
at the limit − 6; cost 0.60 / 0.88 % of a canceller per mic (float /
double). The `MUTAP_SLOW` sweep (4656.5 s on 3 threads): 0 howl blocks in
620 guarded cold starts; 0 ducks off a loop-born burst in 180 audible-cost
runs. Open, with the numbers in the doc:
- **F → 2F still cycles duck / open.** The timer re-arm and the edge
rule work as specified, but the verdict reads ok again after the
re-arm, which re-arms LOST: 16 LOST-ducks after a re-arm in 6 of 8
gated runs, 47 in 20 of 30 sweep runs (0 howl blocks). A policy
question: a LOST in probation as a strike, or a re-arm that needs ok
held.
- **The verdict thresholds are one loop's calibration.** At the
canceller's limit − 6 D's pre-walk median is −8.46 dB, the verdict is
lost for at most 0.22 s after a walk, and no walk ducked (0 of 30 in
the sweep); at exact_msg_db − 6 (the release experiment's point) 6 of
30 did, releasing a median 1.60 s (minimum 1.38) after the
misalignment oracle, 0 early.
- **A′ does not return to ~0 dB in silence**: in a 20 s gap it peaks
at −15.21 / −15.30 dB (medians, cabin / mt5; maximum −14.96), so
`restart_a_db` (−1, a field this PR added) fires only on a
canceller reset; LOST ducks in a song gap and the timer re-arm opens
again before the singer returns.
- **The release experiment's +6 dB rehearsal → hall howl does not
reproduce in this loop** (0 blocks unguarded); the stress rows run at
the canceller's limit + 6 instead.
- The latch never engaged in a gated row; it is covered by the state
machine suite only.

## The next effort (Rev 4): AEC objects + echo chapter

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,11 @@ algorithmically complete. What exists today:
[`tools/ml/README.md`](tools/ml/README.md) and the executed notebooks
[`notebooks/ml_aec_comparison.ipynb`](notebooks/ml_aec_comparison.ipynb) /
[`notebooks/aec_head_to_head.ipynb`](notebooks/aec_head_to_head.ipynb).
- **The anti-howl safety layer** — `tap::mu::howl_guard`
([`include/mutap/howl_guard.h`](include/mutap/howl_guard.h)), per-mic
arming, duck, re-arm and back-off over the canceller's verdict and a
`howl_detector` on each residual, attached to `afc_chain` with
`set_guard()`; measured live in [`docs/howl-guard.md`](docs/howl-guard.md).

Next up (see [HANDOFF.md](HANDOFF.md) "What's next"): in-Max listening in
a real room and the default-engine decision, then the M55 performance
Expand Down
629 changes: 629 additions & 0 deletions docs/howl-guard.md

Large diffs are not rendered by default.

117 changes: 104 additions & 13 deletions include/mutap/afc_chain.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <vector>

#include "mutap/fd_kalman.h"
#include "mutap/howl_guard.h"
#include "mutap/lpc.h"
#include "mutap/pem_afc.h"

Expand Down Expand Up @@ -68,7 +69,8 @@ namespace tap::mu {
/// The anti-howl PoC's chain (the PoC plan's §2.1), canceller-first:
///
/// mic_1..M -> canceller_m (reference u = the speaker feed, delayed)
/// -> voice bus = sum_m e_m
/// -> [guard: per-mic gain g_m] (optional; set_guard)
/// -> voice bus = sum_m g_m e_m
/// -> decorrelator -> reverb -> safety (optional; null = bypass)
/// -> output delay line (spends what is left of the budget)
/// -> + aux (the backing track, summed after the chain)
Expand All @@ -78,9 +80,10 @@ namespace tap::mu {
/// loudspeaker each mic's loop is a single path from the speaker feed, so
/// M mics cost M cancellers summed into the bus (§2.5; whether two mics
/// hold is the two-mic experiment's question, not this class's). The
/// three stages are off by default — the canceller-first decision (Rev
/// 4.1): the shift and the reverb are studied options, and the safety
/// layer waits on the release-policy experiment. Stage order is fixed.
/// three stages and the guard are off by default — the canceller-first
/// decision (Rev 4.1): the shift and the reverb are studied options; the
/// safety layer is howl_guard (set_guard(), and its bus stage in the
/// safety slot). Stage order is fixed.
///
/// THE REFERENCE. The canceller input u is what the DAC plays: the
/// speaker output (bus through the stages and the delay line, plus aux),
Expand Down Expand Up @@ -199,12 +202,58 @@ namespace tap::mu {
}
}

/// Clears the cancellers and both delay lines. The stages are the
/// caller's and are not touched.
/// Attach (or, with nullptr, detach) the safety layer's per-mic
/// guard (howl_guard.h). Call between blocks, on the audio thread.
/// Every block the chain cancels every mic, hands each residual e_k
/// and its canceller's uncertainty_ratio() and
/// shadow_residual_ratio() to the guard, runs one guard tick, and
/// sums the guarded residuals into the bus; error_block() stays the
/// canceller's own residual (the guard's input, before its gain).
/// Attach the guard's post-reverb stage with
/// set_safety(guard->bus_stage()).
///
/// Requires a canceller with both statistics (a compile error
/// otherwise) and its shadow comparator built: returns false, leaving
/// the chain without a guard, when any canceller's shadow_enabled()
/// is false (D would read 0 dB for good and the guard could never
/// leave ARMING), or when the guard's microphones() or block_size()
/// differs from the chain's. nullptr detaches and returns true.
bool set_guard(howl_guard<Sample>* guard) noexcept {
static_assert(k_has_statistics,
"afc_chain::set_guard: the canceller needs uncertainty_ratio(), shadow_residual_ratio() "
"and shadow_enabled() (pem_afc on the Kalman core)");
if (guard == nullptr) {
m_guard = nullptr;
return true;
}
if (guard->microphones() != microphones() || guard->block_size() != block_size()) {
return false;
}
if constexpr (k_has_statistics) {
for (const auto& c : m_cancellers) {
if (!c.shadow_enabled()) {
return false;
}
}
}
m_guard = guard;
return true;
}
/// The attached guard, or nullptr.
howl_guard<Sample>* guard() const noexcept { return m_guard; }

/// Clears the cancellers and both delay lines, and resets an
/// attached guard to ARMING: the one stage the chain resets, because
/// a cleared canceller behind an open guard is the full-gain cold
/// start ARMING exists to prevent. The decorrelator, reverb and
/// safety stages are the caller's and are not touched.
void reset() noexcept {
for (auto& c : m_cancellers) {
c.reset();
}
if (m_guard != nullptr) {
m_guard->reset();
}
fill_zero(m_u);
fill_zero(m_e);
fill_zero(m_bus_a);
Expand Down Expand Up @@ -333,15 +382,17 @@ namespace tap::mu {
}
Sample* bus = m_bus_a.data();
Sample* alt = m_bus_b.data();
for (size_t i = 0; i < b; ++i) {
bus[i] = m_e[i];
}
for (size_t k = 1; k < m; ++k) {
const Sample* e = m_e.data() + k * b;
for (size_t i = 0; i < b; ++i) {
bus[i] += e[i];
if constexpr (k_has_statistics) {
if (m_guard != nullptr) {
guarded_sum(bus);
}
else {
plain_sum(bus);
}
}
else {
plain_sum(bus);
}

// The stages, in order, ping-ponging so no stage sees in == out.
run_stage(m_decorrelator, bus, alt, b);
Expand Down Expand Up @@ -371,6 +422,45 @@ namespace tap::mu {
m_ref_pos = advance(m_ref_pos, b, m_ref_ring.size());
}

/// Whether the canceller exposes the convergence statistics the guard
/// reads.
static constexpr bool k_has_statistics = requires(const Canceller& c) {
c.uncertainty_ratio();
c.shadow_residual_ratio();
c.shadow_enabled();
};

/// bus = the sum of the error blocks (the first copied, so one mic
/// passes bit-exactly).
void plain_sum(Sample* bus) const noexcept {
const size_t b = block_size();
for (size_t i = 0; i < b; ++i) {
bus[i] = m_e[i];
}
for (size_t k = 1; k < m_cancellers.size(); ++k) {
const Sample* e = m_e.data() + k * b;
for (size_t i = 0; i < b; ++i) {
bus[i] += e[i];
}
}
}

/// One guard tick over every mic, then bus = the sum of the guarded
/// error blocks.
void guarded_sum(Sample* bus) noexcept {
const size_t b = block_size();
const size_t m = m_cancellers.size();
for (size_t k = 0; k < m; ++k) {
m_guard->analyze(k, m_e.data() + k * b, m_cancellers[k].uncertainty_ratio(),
m_cancellers[k].shadow_residual_ratio());
}
m_guard->update();
m_guard->apply(0, m_e.data(), bus);
for (size_t k = 1; k < m; ++k) {
m_guard->accumulate(k, m_e.data() + k * b, bus);
}
}

/// Run `stage` from `bus` into `alt` and swap them, or do nothing
/// when it is bypassed.
static void run_stage(const stage_ref& stage, Sample*& bus, Sample*& alt, size_t b) noexcept {
Expand Down Expand Up @@ -440,6 +530,7 @@ namespace tap::mu {
stage_ref m_decorrelator;
stage_ref m_reverb;
stage_ref m_safety;
howl_guard<Sample>* m_guard = nullptr;
};

} // namespace tap::mu
Loading
Loading