diff --git a/HANDOFF.md b/HANDOFF.md index 4ed391e..47359e7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/README.md b/README.md index c6a44cd..e58a03a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/howl-guard.md b/docs/howl-guard.md new file mode 100644 index 0000000..5cc6552 --- /dev/null +++ b/docs/howl-guard.md @@ -0,0 +1,629 @@ +# The safety layer: a per-mic howl guard + +*Measured September 2026 in simulation: six rooms from both generator +families, band-limited through a loudspeaker model, every run live from a +cold start. Host: macOS 15.7 x86_64 (i9-8950HK), AppleClang 17, Release, on +a shared machine ([Hosts](#hosts)). Nothing here has been heard in a real +room. Every number below comes from the gated rows of +`tests/test_howl_guard_host.cpp` or its `MUTAP_SLOW` sweep; +[Provenance](#provenance) gives the commands.* + +`tap::mu::howl_guard` (`include/mutap/howl_guard.h`) is the gain policy that +sits between the cancellers and the voice bus of `afc_chain`. Per mic it +reads the canceller's two convergence statistics (D, the shadow comparator +ratio, and A′, the identification progress) and a `howl_detector` on the +canceller's residual, and it sets that mic's gain: + +- **ARMING** from reset: 30 dB down until the verdict (D < −1.235 dB and + A′ < −23.842 dB) has held for 1.5 s with the detector quiet. After 10 s + without a declaration it raises `unprotected`, and, when the host has set + a cap (the dry limit − 6 dB), it opens to the cap instead. +- **OPEN** at full gain, less 3 dB per strike. +- **DUCKED** 20 dB down on a detector trip (the level catch always; the + growth path only while the verdict is not ok) or on the verdict lost + (ok → not-ok held 0.3 s). It releases when the verdict has held ok for + 1.5 s with the detector quiet, or on a timer (5 s × 2^strikes) when the + verdict cannot recover, and the verdict trigger then stays disarmed until + the verdict has read ok once. +- **RELEASING** ramps back over 200 ms; **LATCHED** is the end state after + three strikes. + +Two mics are attributed by the detectors' band level at the shared howl +frequency, with a duck-all fallback. A bus stage in `afc_chain`'s safety +slot repeats the duck after the reverb. The header lists every transition. + +## Headline + +| Claim (the 40 dB rule is the howl oracle) | Gated rows (cabin, mt5) | `MUTAP_SLOW` sweep (six rooms, five seed sets) | +|---|---|---| +| Guarded cold starts that reached the 40 dB rule | 0 of 108 | 0 of 620 | +| … at the canceller's limit + 6, unguarded twins | 575 blocks, 3 of 6 runs | — | +| Declaration with the backing track, median | 1.77 to 2.07 s | 1.76 to 2.11 s | +| Declaration without it | 0 runs: all leave ARMING through the cap | 0 runs; without a cap, ARMING for all 20 s | +| Walk at exact − 6: release − misalignment-oracle reconvergence, median [min] | 1.59 s [1.38], 0 early of 3 | 1.60 s [1.38], 0 early of 6 | +| Walk at the limit − 6: ducks / howl blocks | 0 of 12 / 0 | 0 of 30 / 0 | +| Walk at the limit + 6 (rehearsal → hall): howl blocks guarded / unguarded | 0 / 1203 | — | +| F → 2F: howl blocks; LOST-ducks after the timer re-arm | 0; 16 in 6 of 8 runs | 0; 47 in 20 of 30 runs | +| Two mics, one forced: first TRIP on the wrong mic alone; howl blocks | 0 of 8; 1 | 1 of 20; 3 | +| Stable material at the limit − 6: ducks off a loop-born burst | 0 in 24 runs | 0 in 180 runs | +| Dattorro in the loop: voice 0.5 s after a trip, with / without the bus stage | −66.53 / −47.13 dB | — | +| Cost per block, one mic, float / double | 0.60 / 0.88 % of a canceller | — | + +*macOS 15.7 x86_64, AppleClang 17, Release, double; S1 (10 ms) unless a +section says otherwise.* + +- **No guarded single-mic run reached the 40 dB rule**, including the stress + rows where the unguarded loop howled. With two mics and one forced into a + howl, a few blocks reached it (1 gated, 3 in the sweep, all mt5+mt9). +- **It is not free of audible cost where the verdict is weak.** Without a + backing track no run declares from ARMING, so those runs sit at the cap. + A louder coupling (F → 2F) makes the guard cycle between ducked and open. +- **At the operating point the verdict does not see a walk.** No walk + ducked and none howled: the canceller re-identifies on its own there. + +## How it is measured + +**The loop** (`tests/support/guard_loop.h`) is `afc_chain` itself, with the +guard attached through `set_guard()`, in a simulated room: each mic hears +its near end plus the speaker feed through its own band-limited 1024-tap +path after an electrical delay (S1 = 480 samples, 10 ms; S3 = 960). The +reference is aligned as `afc_chain` documents (the direct path at tap 32). +The forward gain K, and the optional 2 / 5 Hz frequency shift (the harness's +exact-ramp shifter), ride in the decorrelator slot, so the cancellers +identify the room and not the gain. The canceller is PEM + FD-Kalman with the +speech cascade and the 2-partition shadow. Every run starts from reset: cold +cancellers, the guard in ARMING. + +**Materials.** "voiced" is the harness's held 300 Hz note, "music" the +A-major chord, "speech" the AR(4) speech envelope; "+aux" adds the white +backing track at −12 dB into the speaker (after the chain, so the reference +carries it). + +**Operating points**, stated per row: + +- **The canceller's limit − 6 dB** for the protection claims. The limit is + bisected as the karaoke and two-mic suites do: 1500 blocks of convergence + at the dry limit − 6 dB, then the forward gain bisected to 0.25 dB on a + 10 s probe of the next seed, with a fresh copy of the converged canceller + per probe; the median over seed sets 1, 21, 41, 61, 81 is the table in + `tests/test_howl_guard_host.cpp` ([Operating points](#operating-points)). +- **exact_msg_db + 3 / + 6 dB** (over the dry loop's phase-exact limit) and + **the canceller's limit + 6 dB** for the stress rows. +- **exact_msg_db − 6 dB**, the release experiment's point, for the walk + timing. + +The host's cap is set as the protocol would set it: the dry limit − 6 dB, +relative to the operating gain. + +**Oracles** (`tests/support/guard_runs.h`). None reads the guard's own +statistics: + +- **the 40 dB rule**: a residual block at RMS ≥ 100 over the unit-RMS near + end (the harness's howl criterion). It is the howl oracle every claim + below uses. +- **the frozen-estimate margin**: the phase-exact limit of the loop the + canceller's current snapshot would close, at the current gains, minus K. + It is reported as the time it spent below 0 dB. At these operating points + it reads "unstable" for most of a run even when nothing howls (median + 18.03 s of 20 on voiced + aux at the limit − 6): the adaptive loop holds + where the frozen snapshot would not, as the release experiment found at + +3 / +6 dB. It is not a howl oracle here, and it is undefined with a + shift or a reverb in the loop. +- **the misalignment oracle** (walks): the filter's misalignment over the + taps it covers; "reconverged" is the start of a 0.3 s run back under the + pre-walk median + 3 dB. +- **the burst oracle**: a block within 0.5 s of a block at +20 dB. + +The detector's ceiling is the harness's: +30 dB re the unit near end, 10 dB +under the 40 dB rule. + +## Cold start + +Every run starts from reset at the operating gain, with the protocol's cap, +and runs 20 s. Gated rows: cabin and mt5, seeds 1, 21, 41 (6 runs a row), +S1 unless the row says otherwise. + +| Row | Declared | Time to OPEN, median [max] (s) | Left ARMING through the cap | Time in ARMING, median (s) | Howl blocks (40 dB rule) | Frozen margin < 0, median (s) | Unguarded twins: howl blocks (runs) | +|---|---|---|---|---|---|---|---| +| voiced+aux, limit − 6 | 6 of 6 | 1.78 [1.79] | 0 | 1.78 | 0 | 18.03 | 0 (0 of 6) | +| music+aux, limit − 6 | 6 of 6 | 1.77 [1.78] | 0 | 1.77 | 0 | 15.79 | — | +| speech+aux, limit − 6 | 6 of 6 | 2.07 [2.17] | 0 | 2.07 | 0 | 1.13 | — | +| voiced, limit − 6 | 0 of 6 | — | 6 | 10.00 | 0 | 19.09 | — | +| music, limit − 6 | 0 of 6 | — | 6 | 10.00 | 0 | 16.64 | — | +| speech, limit − 6 | 0 of 6 | — | 6 | 10.00 | 0 | 0.00 | 1 (1 of 6) | +| voiced+aux, exact + 3 | 6 of 6 | 1.77 [1.78] | 0 | 1.77 | 0 | 17.33 | 0 (0 of 6) | +| voiced, exact + 3 | 0 of 6 | — | 6 | 10.00 | 0 | 19.00 | — | +| voiced+aux, exact + 6 | 6 of 6 | 1.78 [1.79] | 0 | 1.78 | 0 | 18.03 | 0 (0 of 6) | +| voiced, exact + 6 | 0 of 6 | — | 6 | 10.00 | 0 | 19.23 | — | +| voiced+aux, limit + 6 | 6 of 6 | 1.77 [1.79] | 0 | 1.77 | 0 | 14.77 | 575 (3 of 6) | +| voiced, limit + 6 | 0 of 6 | — | 6 | 10.00 | 0 | 19.69 | 0 (0 of 6) | +| voiced+aux, 2 Hz shift | 6 of 6 | 1.77 [1.79] | 0 | 1.77 | 0 | — | — | +| voiced, 2 Hz shift | 0 of 6 | — | 6 | 10.00 | 0 | — | — | +| voiced+aux, 5 Hz shift | 6 of 6 | 1.78 [1.78] | 0 | 1.78 | 0 | — | — | +| voiced, 5 Hz shift | 0 of 6 | — | 6 | 10.00 | 0 | — | — | +| voiced+aux, S3 | 6 of 6 | 1.77 [1.78] | 0 | 1.77 | 0 | 18.06 | — | +| voiced, S3 | 0 of 6 | — | 6 | 10.00 | 0 | 19.39 | — | + +*macOS 15.7 x86_64, AppleClang 17, Release; "exact" is exact_msg_db, the +dry loop's phase-exact limit.* + +- **No guarded cold start reached the 40 dB rule.** That includes the + canceller's limit + 6 dB, where three of the six unguarded voiced+aux twins + howled (575 blocks). +- **With the backing track every run declares**, in a median 1.77 to + 2.07 s. ARMING costs that long at −30 dB. +- **Without it no run declares within 20 s**, on any material (held note, + music, speech). The likely cause, not isolated by a test: at the arming + gain the loop's own signal reaches the reference 30 dB down, so the + canceller has little to identify from. The 10 s timeout and the cap are + what let these runs open at all; without a cap they stay in ARMING with + `unprotected` raised. +- **The unguarded cold start rarely howls in this loop.** One speech run of + six at the limit − 6, none at exact + 3 / + 6. ARMING's value shows only at + the limit + 6. The release experiment's rig, which howled from cold at + + 3 / + 6 dB, ran a different loop (no reference alignment, a different + backing track). + +The sweep (`HowlGuardSweep.ColdStart`) runs every material: six rooms and +five seed sets at S1, cabin and mt5 at S3, with the shifts, and at exact + 3 / ++ 6 on cabin, mt5, rehearsal and hall. + +| Row | Runs | Declared | Time to OPEN, median [max] (s) | Left through the cap | Howl blocks | Frozen margin < 0, median [max] (s) | Unguarded twins: howl blocks (runs) | +|---|---|---|---|---|---|---|---| +| voiced+aux, S1 | 30 | 30 | 1.77 [1.80] | 0 | 0 | 18.02 [18.07] | 0 (0 of 30) | +| voiced, S1 | 30 | 0 | — | 30 | 0 | 19.01 [19.39] | — | +| music, S1 | 30 | 0 | — | 30 | 0 | 16.58 [17.12] | — | +| music+aux, S1 | 30 | 30 | 1.78 [1.81] | 0 | 0 | 15.75 [17.53] | — | +| speech, S1 | 30 | 0 | — | 30 | 0 | 0.00 [0.00] | 1 (1 of 30) | +| speech+aux, S1 | 30 | 30 | 2.11 [2.42] | 0 | 0 | 0.78 [2.31] | — | +| voiced+aux, S3 | 10 | 10 | 1.77 [1.79] | 0 | 0 | 18.06 [18.08] | — | +| voiced, S3 | 10 | 0 | — | 10 | 0 | 19.37 [19.56] | — | +| music, S3 | 10 | 0 | — | 10 | 0 | 18.45 [18.82] | — | +| music+aux, S3 | 10 | 10 | 1.78 [1.79] | 0 | 0 | 17.53 [17.69] | — | +| speech, S3 | 10 | 0 | — | 10 | 0 | 0.00 [0.00] | — | +| speech+aux, S3 | 10 | 10 | 2.10 [2.19] | 0 | 0 | 4.62 [5.46] | — | +| each material, 2 Hz | 10 a row | +aux rows 10 of 10 | 1.76 to 2.08 (medians) | non-aux rows 10 of 10 | 0 | — | — | +| each material, 5 Hz | 10 a row | +aux rows 10 of 10 | 1.77 to 2.07 (medians) | non-aux rows 10 of 10 | 0 | — | — | +| each material, exact + 3 | 20 a row | +aux rows 20 of 20 | 1.77 to 2.11 (medians) | non-aux rows 20 of 20 | 0 | — | voiced+aux 0 of 20, speech 0 of 20 | +| each material, exact + 6 | 20 a row | +aux rows 20 of 20 | 1.78 to 2.11 (medians) | non-aux rows 20 of 20 | 0 | — | voiced+aux 0 of 20, speech 0 of 20 | +| voiced, S1, no cap | 10 | 0 | — | 0 (in ARMING for all 20 s) | 0 | 19.16 [19.45] | — | +| music, S1, no cap | 10 | 0 | — | 0 (in ARMING for all 20 s) | 0 | 16.34 [16.80] | — | + +*macOS 15.7 x86_64, AppleClang 17, Release; 1979.5 s on 3 threads.* + +**0 howl blocks in 620 guarded cold starts.** Every run with the backing +track declared, every run without it left ARMING only through the cap, and +without a cap the held note and the music stayed in ARMING for the whole +20 s. + +## Walks to another room + +A walk (S2a) swaps every mic's room at 10 s; the run lasts 25 s. Gated: +the six walks of the release experiment, voiced + aux, seeds 1 and 21, at +two operating points. + +| Operating point | Walks | Ducked | By LOST | Duck after the change, median (s) | Releases | Release − reconvergence, median [min] (s) | Early | Howl blocks after the walk | D before the walk, median (dB) | Longest not-ok run after it, median [max] (s) | +|---|---|---|---|---|---|---|---|---|---|---| +| exact_msg_db − 6 | 12 | 3 | 3 | 0.36 | 3 | 1.59 [1.38] | 0 | 0 | −7.91 | 0.25 [0.47] | +| the limit − 6 | 12 | 0 | — | — | 0 | — | — | 0 | −8.46 | 0.21 [0.22] | + +*macOS 15.7 x86_64, AppleClang 17, Release. The release instant is the ramp +start (RELEASING entry); "reconvergence" is the misalignment oracle's.* + +- At the release experiment's operating point the guard reproduces its live + H1.5 policy: the ducks come 0.36 s after the change and the releases 1.38 + to 1.60 s after the oracle reconverged (studio → rehearsal 1.59 and 1.60, + mt5 → mt105 1.38). Only 3 of 12 walks ducked; the gated set is two seeds, + and the sweep below has five. +- **At the limit − 6 no walk ducked, and none howled.** Before the walks D + sits at a median −8.46 dB, far under the −1.235 dB threshold (calibrated + in the release experiment's loop); after a walk the verdict is lost for + at most 0.22 s, under the 0.3 s trip hold. At exact − 6 the longest + excursions reach 0.47 s, and those are the walks that ducked. + +The sweep runs the same six walks over five seed sets: + +| Walk | At exact − 6: ducked (by LOST) | Duck after the change, median (s) | Release − reconvergence, median [min] (s) | Early | At the limit − 6: ducked | Howl blocks (both points) | +|---|---|---|---|---|---|---| +| studio → rehearsal | 5 of 5 (5) | 0.33 | 1.60 [1.44] | 0 | 0 of 5 | 0 | +| rehearsal → hall | 0 of 5 | — | — | — | 0 of 5 | 0 | +| hall → cabin | 0 of 5 | — | — | — | 0 of 5 | 0 | +| cabin → studio | 0 of 5 | — | — | — | 0 of 5 | 0 | +| mt5 → mt105 | 1 of 5 (1) | 0.36 | 1.38 [1.38] | 0 | 0 of 5 | 0 | +| mt9 → mt109 | 0 of 5 | — | — | — | 0 of 5 | 0 | +| pooled | 6 of 30 (6) | 0.34 | 1.60 [1.38] | 0 | 0 of 30 | 0 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +Every release came after the misalignment oracle had reconverged (6 of 6, +the earliest 1.38 s after it). The verdict sees one walk in five at the +experiment's point, and none at the canceller's limit − 6. + +## Above the limits + +The release experiment's late howl, seconds after a rehearsal → hall walk at +exact + 6 with the verdict never having declared, is the case the detector +exists for. Gated: seeds 1, 21, 41, 25 s, with unguarded twins. + +| Gain | Guarded: howl blocks after the walk | Strikes (3 runs) | Latched | Unguarded: howl blocks | Longest unguarded howl (s) | +|---|---|---|---|---|---| +| exact + 6 | 0 | 0 | 0 | 0 | 0.00 | +| the limit + 6 (exact + 15.61) | 0 | 5 | 0 | 1203 | 0.55 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +- **The late howl does not reproduce at exact + 6 in this loop.** This + loop's canceller holds +6 dB with margin (rehearsal's limit is exact + + 9.61), so the row runs at the canceller's limit + 6 instead. +- **There the guard holds the loop:** 0 howl blocks against 1203 + unguarded, with five strikes over the three runs and no latch. + +The sweep adds hall → cabin and runs five seed sets, at exact + 6: + +| Walk at exact + 6 | Guarded: ducks (by TRIP) | Strikes | Latched | Guarded howl blocks | Unguarded howl blocks | Longest unguarded howl (s) | +|---|---|---|---|---|---|---| +| rehearsal → hall | 0 | 0 | 0 | 0 | 0 | 0.00 | +| hall → cabin | 5 (5) | 5 | 0 | 0 | 35 | 0.04 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +In hall → cabin the unguarded loop howls briefly after the walk (35 blocks +over five runs, the longest 0.04 s); the guard ducks on the detector (five +TRIPs in five runs, a median 0.10 s after the walk) and nothing reaches the +40 dB rule. + +## A louder coupling (F → 2F) + +F → 2F (S2b) doubles every path at 10 s: the louder coupling the verdict +reads as mismatch for good. Gated: cabin, mt5, studio, hall, seeds 1 and 21, +voiced + aux at the limit − 6, 30 s. + +| Room | Runs ducked | Duck after the change, median (s) | Runs re-armed | Re-arm after the duck, median (s) | LOST-ducks after the re-arm (runs) | Howl blocks | Time ducked or releasing after the change, median | +|---|---|---|---|---|---|---|---| +| cabin | 2 of 2 | 4.65 | 0 | — | 0 (0) | 0 | 0.56 | +| mt5 | 2 of 2 | 0.39 | 2 | 10.01 | 4 (2) | 0 | 0.96 | +| studio | 2 of 2 | 0.37 | 2 | 5.00 | 6 (2) | 0 | 0.94 | +| hall | 2 of 2 | 0.38 | 2 | 5.00 | 6 (2) | 0 | 0.93 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +- Every run ducked (LOST), the timer re-arm came at rearm_timeout_s (5.00 s, + 10.01 s after a strike), and nothing howled. +- **The edge rule does not stop the duck / open cycle.** The rule disarms + LOST after a timer re-arm until the verdict reads ok once, and the verdict + does read ok again once the re-armed gain is up: 16 LOST-ducks came after + a re-arm, in 6 of 8 runs. In mt5, studio and hall the guard spends a median + 0.93 to 0.96 of the time after the change ducked or releasing. Cabin never + re-armed and still ducked 7 times: there the verdict held ok for 1.5 s + while ducked (the walk path) and was lost again at full gain. +- This is safe (0 howl blocks) but audible. Two candidate policy changes, + neither measured: count a LOST within probation as a strike (the back-off + then lowers the level and doubles the hold), or re-arm only into a release + that needs ok held. + +The sweep's six rooms over five seed sets: + +| Room | Runs ducked | Duck after the change, median (s) | Runs re-armed | Re-arm after the duck, median (s) | LOST-ducks after the re-arm (runs) | TRIP-ducks | Howl blocks | Time ducked or releasing, median | +|---|---|---|---|---|---|---|---|---| +| cabin | 5 of 5 | 2.53 | 1 | 16.73 | 1 (1) | 0 | 0 | 0.55 | +| mt5 | 5 of 5 | 0.38 | 5 | 5.00 | 8 (4) | 4 | 0 | 0.96 | +| studio | 5 of 5 | 0.37 | 5 | 5.00 | 14 (5) | 0 | 0 | 0.94 | +| rehearsal | 5 of 5 | 0.37 | 5 | 5.00 | 0 (0) | 0 | 0 | 0.26 | +| hall | 5 of 5 | 0.38 | 5 | 5.00 | 15 (5) | 0 | 0 | 0.93 | +| mt9 | 5 of 5 | 0.37 | 5 | 5.00 | 9 (5) | 3 | 0 | 0.95 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +0 howl blocks in 30 runs; 47 LOST-ducks after a re-arm, in 20 of 30 runs. +Rehearsal is the one room where the re-armed loop stayed open (0.26 of the +time ducked). + +## A gap between songs + +A gap between songs: the singer and the backing track at digital zero +from 8 s to 28 s, voiced + aux at the limit − 6, cabin and mt5, seeds 1, 21, +41, 34 s. + +| Room | Peak A′ in the gap, median [max] (dB) | Ducks in the gap | Restarts (ARMING) | OPEN when the singer returns | Howl blocks after | Loudest residual in the 1 s after the gap, median (dB re the near end) | +|---|---|---|---|---|---|---| +| cabin | −15.21 [−14.96] | 3 | 0 | 3 of 3 | 0 | +3.91 | +| mt5 | −15.30 [−15.11] | 3 | 0 | 3 of 3 | 0 | +3.91 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +- **A′ does not return to ~0 dB in silence.** It regrows toward the path + level and peaks near −15 dB, far below `restart_a_db` (−1 dB). The + restart rule therefore fires only on a canceller reset, not on a gap. +- In the gap A′ crosses −23.842 dB, so the verdict is lost and the guard + ducks. Five seconds later the timer re-arms it, and it is OPEN, at full + gain, when the singer returns. Nothing howled at re-entry here. + +## Two microphones + +Two mics (`two_mic_loop.h`'s room pairs and separated singers, −10 dB +leakage), each on its own canceller, at the two-mic loop's own limit − 6 dB. +At 10 s mic 1's path is multiplied by 4 (+12 dB, the mic moved toward the +speaker): a howl that one mic causes. Gated: seeds 1 and 21, 20 s. + +| Pair | Runs | First TRIP ducked mic 1 alone | Both | Mic 0 alone | Fallbacks | Cascades (mic 0 ducked by LOST) | Howl blocks after the force | +|---|---|---|---|---|---|---|---| +| cabin | 2 | 1 | 1 | 0 | 0 | 0 | 0 | +| cabin + aux | 2 | 2 | 0 | 0 | 0 | 0 | 0 | +| mt5+mt9 | 2 | 2 | 0 | 0 | 0 | 0 | 1 | +| mt5+mt9 + aux | 2 | 2 | 0 | 0 | 0 | 0 | 0 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +- Attribution picked the forced mic alone in 7 of 8 runs, and both mics in + the other; it never ducked the wrong mic alone here. On a second host + (macOS arm64, AppleClang, CI run 36828198214) the same rows read 7 of 8 + forced mic alone and 1 of 8 wrong mic alone (cabin, no aux), with 3 howl + blocks, the longest one block: the gate is a count with margin (at most + 2 of 8 wrong, at most 24 howl blocks, no howl run of 0.1 s), not a zero. The duck-all fallback never + fired, and mic 0's verdict never dropped after mic 1 was ducked. + +The sweep's five seed sets: + +| Pair | Runs | Mic 1 alone | Both | Mic 0 alone | Fallbacks | Cascades | Howl blocks after the force | +|---|---|---|---|---|---|---|---| +| cabin | 5 | 1 | 3 | 1 | 0 | 0 | 0 | +| cabin + aux | 5 | 5 | 0 | 0 | 0 | 0 | 0 | +| mt5+mt9 | 5 | 3 | 2 | 0 | 0 | 0 | 3 | +| mt5+mt9 + aux | 5 | 5 | 0 | 0 | 0 | 0 | 0 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +With the backing track attribution picked the forced mic alone in 10 of 10 +runs. Without it, both mics ducked in 5 of 10 and the wrong mic alone in one +(cabin: by the rule, mic 0 read more than 6 dB louder than mic 1 at that +trip's peak band). No fallback fired and mic 0's verdict was never lost +afterwards. 3 blocks over five mt5+mt9 runs reached the 40 dB rule. + +## Audible cost + +Stable material at the limit − 6, from reset, 30 s, counting after the first +OPEN. Gated: cabin and mt5, seeds 1 and 21. + +| Material, shift | Runs (declared) | Ducks | On a burst | Ducked time (s) | Runs with a +20 dB block | Howl blocks | Loudest residual block, median [max] (dB re the near end) | +|---|---|---|---|---|---|---|---| +| voiced+aux, 0 Hz | 4 (4) | 0 | 0 | 0.00 | 0 | 0 | +4.77 [+8.82] | +| voiced+aux, 2 Hz | 4 (4) | 0 | 0 | 0.00 | 0 | 0 | +6.96 [+7.54] | +| voiced+aux, 5 Hz | 4 (4) | 0 | 0 | 0.00 | 0 | 0 | +6.30 [+9.42] | +| speech+aux, 0 Hz | 4 (4) | 0 | 0 | 0.00 | 0 | 0 | +11.67 [+19.56] | +| speech+aux, 2 Hz | 4 (4) | 0 | 0 | 0.00 | 0 | 0 | +13.05 [+15.56] | +| speech+aux, 5 Hz | 4 (4) | 0 | 0 | 0.00 | 0 | 0 | +15.82 [+18.12] | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +- **0 ducks in 24 runs.** The margin is the ceiling's: the loudest residual + block came within 10.44 dB of the +30 dB ceiling (speech + aux, +19.56). + No run produced a loop-born burst (a block at +20 dB), so the burst oracle + had nothing to separate here. + +The sweep's six rooms and five seed sets (30 runs a row): + +| Material, shift | Ducks | On a burst | Off any burst | Ducked time, max (s) | Runs with a +20 dB block | Howl blocks | Loudest residual block, median [max] (dB) | +|---|---|---|---|---|---|---|---| +| voiced+aux, 0 Hz | 0 | 0 | 0 | 0.00 | 0 | 0 | +4.20 [+15.35] | +| voiced+aux, 2 Hz | 0 | 0 | 0 | 0.00 | 0 | 0 | +7.65 [+9.97] | +| voiced+aux, 5 Hz | 0 | 0 | 0 | 0.00 | 0 | 0 | +7.75 [+12.26] | +| speech+aux, 0 Hz | 1 | 1 | 0 | 1.71 | 6 | 0 | +13.37 [+28.97] | +| speech+aux, 2 Hz | 4 | 4 | 0 | 1.72 | 8 | 0 | +13.81 [+30.34] | +| speech+aux, 5 Hz | 4 | 4 | 0 | 1.72 | 10 | 0 | +15.82 [+34.23] | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +**0 ducks off a burst in 180 runs.** All 9 ducks fell within 0.5 s of a +loop-born burst: speech at the limit − 6 still produces them (24 of 90 speech +runs had a block at +20 dB), and a duck there is the guard doing its job. A +duck costs at most 1.72 s of ducked time here. + +## The reverb tail and the bus stage + +The vendored Dattorro plate (`mutap_faust::faust_block`, paper defaults; `FaustVendored.DattorroAsShippedT30` measures its +T30 at 1.184593 s) in the reverb slot, wet 0.5 mixed over +the dry bus; cabin, voiced + aux at the limit − 6, seed 1. At 10 s the +forward gain steps up 12 dB, a forced howl. Voice is the speaker feed minus +the backing track, in dB relative to the block of the first TRIP: + +| After the TRIP | With the bus stage | Without it | Apart | +|---|---|---|---| +| 0.05 s | −43.82 | −22.86 | 20.96 | +| 0.10 s | −62.26 | −43.39 | 18.87 | +| 0.25 s | −55.84 | −37.31 | 18.53 | +| 0.50 s | −66.53 | −47.13 | 19.40 | +| 1.00 s | −81.39 | −66.25 | 15.14 | +| 1.50 s | −80.15 | −64.65 | 15.50 | + +*macOS 15.7 x86_64, AppleClang 17, Release.* + +- The first TRIP came 0.115 s after the step in both runs. The per-mic duck + alone leaves the charged plate ringing into the speaker. The bus stage + repeats the duck after it, which cuts the tail by about the duck depth + (15.14 to 20.96 dB at these offsets). +- On the dry path a trip then counts twice, per mic and on the bus. That is + the cost of the stage. + +## Cost per block + +`HowlGuardHost.CostPerBlock`: analyze() per mic, one update(), apply() per +mic, block 64 at 48 kHz, white noise (no trip in the timed loop); against one +`pem_afc` (1024 taps, the 2-partition shadow) process_block in the same +process. Median of five repetitions. + +| Mics | Float32 (ns / block) | Of one canceller | Double (ns / block) | Of one canceller | +|---|---|---|---|---| +| 1 | 2246 | 0.60 % | 3180 | 0.88 % | +| 2 | 3962 | 1.02 % | 6311 | 1.59 % | + +*macOS 15.7 x86_64 (i9-8950HK), AppleClang 17, Release, a shared machine.* + +The guard is the detectors plus a few dozen comparisons and a gain ramp per +mic. It allocates only in its constructor. + +## What did not separate, and what was not measured + +- **F → 2F cycles duck / open.** The edge rule works as specified, but the + verdict itself reads ok at the ducked gain, so LOST re-arms and fires + again at full gain ([A louder coupling](#a-louder-coupling-f--2f)). Safe, + audible, and a policy decision for the next revision. +- **The verdict's thresholds are one loop's calibration.** At the + canceller's limit − 6 D sits far below −1.235 dB, and a walk's excursion + is shorter than the trip hold, so no walk ducked + ([Walks](#walks-to-another-room)). No walk howled either: the canceller + re-identified at that gain on its own. Whether a deployment needs a + per-room D threshold (the design note's soundcheck readout) is open. +- **Nothing declares without a backing track.** Held notes, music and + speech alone never declared in 20 s from ARMING; every such run relies on + the arming timeout and the host's cap. Without a cap they stay ducked + 30 dB with `unprotected` raised. A soundcheck excitation, or ARMING at a + shallower duck for those materials, was not measured. +- **Silence does not restart the guard.** A′ peaks near −15 dB in a 20 s + gap, so `restart_a_db` fires only on a canceller reset; the gap's LOST + duck and timer re-arm leave the guard OPEN at full gain when the singer + returns. No re-entry howled here. +- **The frozen-estimate margin is not a howl oracle at these gains**, so + the howl claims rest on the 40 dB rule alone. +- **The latch never engaged in a live row.** The most strikes were 5 over + three runs at the limit + 6; the latch, `clear()` and the end state are + covered by the state-machine suite (`tests/test_howl_guard.cpp`) only. +- **The release experiment's late howl at + 6 dB does not reproduce here**, + and neither does its cold-start howling at + 3 / + 6. Both stress claims + run at the canceller's limit + 6 instead. +- **The growth hint does not pre-arm attribution.** Growth while the + verdict is ok is counted (`hints()`) and does nothing else. +- **Not measured:** the guard in float in the live loop (the host claims are + double; the float suite checks the state machine and the chain hook on + both emulated selections); two mics at S3, with a shift or through a walk; + the guard during a gain ramp (the protocol keeps it off there); any real + room or real singing. + +## Operating points + +`HowlGuardSweep.OperatingPoints` bisects the converged canceller's runaway +limit for every room and material over seed sets 1, 21, 41, 61, 81, and +prints the table the gated rows use (`k_ops` in the test file). Values are dB +over exact_msg_db; the gated rows run at the median − 6 dB. + +| Room | Material | S1 median | S1 per seed | S3 median | S3 per seed | +|---|---|---|---|---|---| +| cabin | voiced+aux | +11.89 | +10.66 +11.89 +12.07 +12.25 +9.61 | +13.12 | +13.30 +11.54 +13.12 +12.95 +13.83 | +| cabin | voiced | +9.26 | +11.37 +5.57 +13.12 +9.26 +8.20 | +14.88 | +15.23 +14.88 +14.71 +14.36 +17.34 | +| cabin | music | +10.14 | +10.14 +9.79 +9.43 +11.72 +10.14 | +15.76 | +17.34 +15.94 +14.88 +15.59 +15.76 | +| cabin | music+aux | +9.96 | +10.31 +10.84 +9.96 +9.96 +9.61 | +13.48 | +15.59 +12.95 +14.00 +13.48 +13.12 | +| cabin | speech | +19.63 | +20.16 +19.45 +19.63 +19.63 +19.10 | +20.16 | +20.68 +19.28 +20.33 +19.80 +20.16 | +| cabin | speech+aux | +19.45 | +18.75 +19.80 +20.33 +19.45 +18.57 | +19.98 | +18.75 +19.98 +21.04 +19.98 +19.10 | +| mt5 | voiced+aux | +11.02 | +11.02 +8.73 +11.19 +11.37 +9.43 | +12.42 | +11.37 +12.07 +12.77 +12.60 +12.42 | +| mt5 | voiced | +11.02 | +11.02 +10.31 +13.48 +13.65 +7.50 | +15.76 | +16.82 +14.53 +17.34 +15.76 +15.41 | +| mt5 | music | +11.19 | +11.72 +11.19 +10.66 +11.19 +11.89 | +16.99 | +15.94 +16.82 +16.99 +17.70 +17.17 | +| mt5 | music+aux | +11.89 | +11.19 +12.95 +11.72 +12.42 +11.89 | +16.11 | +16.11 +16.46 +15.06 +13.83 +16.11 | +| mt5 | speech | +22.62 | +22.79 +22.62 +21.39 +22.97 +22.09 | +22.62 | +22.62 +22.27 +22.44 +22.97 +22.62 | +| mt5 | speech+aux | +21.56 | +21.56 +20.68 +22.44 +20.51 +22.44 | +22.62 | +22.44 +22.62 +22.62 +21.74 +22.79 | +| studio | voiced+aux | +9.08 | +8.38 +8.03 +9.08 +9.61 +9.26 | — | — | +| studio | voiced | +10.84 | +10.49 +11.89 +7.85 +11.72 +10.84 | — | — | +| studio | music | +10.84 | +12.95 +10.14 +12.07 +10.84 +10.49 | — | — | +| studio | music+aux | +11.02 | +11.72 +11.19 +10.14 +9.96 +11.02 | — | — | +| studio | speech | +19.28 | +19.45 +19.28 +18.93 +17.87 +19.80 | — | — | +| studio | speech+aux | +18.93 | +18.57 +18.75 +19.45 +19.80 +18.93 | — | — | +| rehearsal | voiced+aux | +9.61 | +9.96 +9.61 +9.61 +11.19 +9.26 | — | — | +| rehearsal | voiced | +10.66 | +10.66 +11.19 +8.38 +14.18 +7.68 | — | — | +| rehearsal | music | +12.07 | +10.14 +11.37 +14.00 +12.07 +12.07 | — | — | +| rehearsal | music+aux | +11.54 | +11.89 +11.54 +11.37 +8.73 +12.25 | — | — | +| rehearsal | speech | +15.06 | +15.06 +14.88 +15.06 +14.88 +15.23 | — | — | +| rehearsal | speech+aux | +13.83 | +13.83 +14.18 +14.00 +13.65 +13.83 | — | — | +| hall | voiced+aux | +9.26 | +9.08 +9.61 +9.26 +10.84 +9.26 | — | — | +| hall | voiced | +7.32 | +6.62 +6.97 +7.32 +7.85 +7.32 | — | — | +| hall | music | +11.89 | +9.61 +12.07 +12.42 +11.89 +9.79 | — | — | +| hall | music+aux | +11.37 | +11.19 +11.37 +12.07 +10.66 +11.37 | — | — | +| hall | speech | +19.63 | +19.45 +19.63 +19.98 +19.10 +19.98 | — | — | +| hall | speech+aux | +19.98 | +19.45 +20.16 +20.68 +19.10 +19.98 | — | — | +| mt9 | voiced+aux | +8.73 | +10.49 +9.43 +8.73 +8.55 +8.38 | — | — | +| mt9 | voiced | +10.31 | +10.84 +10.31 +6.27 +8.55 +10.49 | — | — | +| mt9 | music | +11.19 | +12.42 +8.73 +11.54 +10.84 +11.19 | — | — | +| mt9 | music+aux | +11.02 | +10.49 +11.02 +11.37 +11.37 +10.66 | — | — | +| mt9 | speech | +21.39 | +21.39 +21.21 +21.56 +21.21 +21.91 | — | — | +| mt9 | speech+aux | +20.51 | +20.51 +18.75 +21.74 +18.93 +21.56 | — | — | + +The two-mic loop's limit over exact_msg_db(F_0 + F_1), the same protocol: + +| Pair | Without the backing track, median | Per seed | With it, median | Per seed | +|---|---|---|---|---| +| cabin | +9.43 | +9.43 +9.43 +8.73 +9.96 +9.61 | +11.72 | +9.79 +12.95 +11.54 +12.07 +11.72 | +| mt5+mt9 | +8.55 | +8.55 +7.68 +7.32 +9.43 +8.91 | +8.73 | +8.55 +8.20 +9.61 +10.49 +8.73 | + +*macOS 15.7 x86_64, AppleClang 17, Release; 1583.2 s on 3 threads.* + +These are this loop's numbers, not the karaoke suite's: the chain's +reference lags one block and is aligned to the direct path, and the probes +are 10 s. + +## Hosts + +Every number in this document was measured on **macOS 15.7.9 x86_64 +(i9-8950HK), AppleClang 17.0.0, Release**, double precision, on a machine +shared with other jobs. The loop is chaotic: platform arithmetic moves +single runs, which is why the gated rows assert medians, directions and +counts with margin, never one trajectory. No second host has run these +tests. Quote the host with the number. + +## Provenance + +The state machine, the ramps and the chain hook (float and double, every +expectation a transition time or an exact gain; both emulated selections run +the float half): + +```sh +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DMUTAP_WERROR=ON +cmake --build build +build/tests/mutap_tests --gtest_filter='howl_guard_test*:HowlGuardConfigValidation.*:HowlGuardRtContract.*' +``` + +The gated live rows run by default with the rest of the suite: + +```sh +build/tests/mutap_tests --gtest_filter='HowlGuardHost.*' +``` + +Wall times in one full `ctest --test-dir build` run (each row on 4 +threads; 364 of 364 tests passed, 18 of them skipped: the `MUTAP_SLOW` +sweeps and `PortableRandom.MatchesLibstdcxxBitForBit`): + +| Test | Seconds | +|---|---| +| `HowlGuardHost.ColdStartWithBackingTrack` | 55.01 | +| `HowlGuardHost.ColdStartWithoutBackingTrack` | 54.29 | +| `HowlGuardHost.ColdStartAboveTheDryLimit` | 84.02 | +| `HowlGuardHost.ColdStartAboveTheCancellerLimit` | 53.84 | +| `HowlGuardHost.ColdStartShiftedAndAtS3` | 72.96 | +| `HowlGuardHost.WalkReleasesAfterTheMisalignmentOracle` | 68.75 | +| `HowlGuardHost.LateHowlAfterAWalkIsCaught` | 41.62 | +| `HowlGuardHost.LouderCouplingRearms` | 20.99 | +| `HowlGuardHost.SongGapDoesNotRestart` | 26.74 | +| `HowlGuardHost.TwoMicsAttributeASingleMicHowl` | 28.88 | +| `HowlGuardHost.AudibleCostOnStableMaterial` | 62.33 | +| `HowlGuardHost.BusStageCutsTheReverbRing` | 9.07 | +| `HowlGuardHost.CostPerBlock` | 18.21 | +| total | 596.71 | + +The cost table above is from an earlier run of the same gated rows on the +same machine (`CostPerBlock` re-measures on every run and gates the ratio +with margin). + +The sweep is behind `MUTAP_SLOW=1`; it prints and asserts nothing. It was +run on 3 threads (`MUTAP_SLOW_THREADS=3`) beside other work: + +```sh +MUTAP_SLOW=1 MUTAP_SLOW_THREADS=3 build/tests/mutap_tests --gtest_filter='HowlGuardSweep.*' +``` + +| Test | Wall time (3 threads) | +|---|---| +| `HowlGuardSweep.OperatingPoints` | 1583.2 s | +| `HowlGuardSweep.ColdStart` | 1979.5 s | +| `HowlGuardSweep.WalksAndLouderCoupling` | 426.3 s | +| `HowlGuardSweep.TwoMicsAndAudibleCost` | 667.5 s | +| total | 4656.5 s | + +The operating points are deterministic: `HowlGuardSweep.OperatingPoints` +reprints the gated rows' `k_ops` table to the last digit on this host. diff --git a/include/mutap/afc_chain.h b/include/mutap/afc_chain.h index 2ad15fa..30422b9 100644 --- a/include/mutap/afc_chain.h +++ b/include/mutap/afc_chain.h @@ -13,6 +13,7 @@ #include #include "mutap/fd_kalman.h" +#include "mutap/howl_guard.h" #include "mutap/lpc.h" #include "mutap/pem_afc.h" @@ -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) @@ -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), @@ -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* 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* 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); @@ -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); @@ -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 { @@ -440,6 +530,7 @@ namespace tap::mu { stage_ref m_decorrelator; stage_ref m_reverb; stage_ref m_safety; + howl_guard* m_guard = nullptr; }; } // namespace tap::mu diff --git a/include/mutap/howl_guard.h b/include/mutap/howl_guard.h new file mode 100644 index 0000000..69f428e --- /dev/null +++ b/include/mutap/howl_guard.h @@ -0,0 +1,944 @@ +/// @file howl_guard.h +/// @brief The anti-howl safety layer: per-mic gain policy over the +/// canceller's convergence verdict and a howl detector on each +/// residual - arming, duck, hold, re-arm, back-off and latch. +// SPDX-License-Identifier: MIT +// Copyright 2026 MuTap contributors +// +// WHY. A feedback canceller protects the loop only once it has identified +// the room, and it says so through two raw statistics (pem_afc.h): the +// shadow comparator ratio D and the identification progress A'. The release +// experiment found that D and A' read "ok" DURING an established howl, that +// every sustained howl above the dry limit came from a run whose verdict had +// never declared, and that a louder coupling (F -> 2F) leaves the verdict +// "not ok" for good. So the guard (1) ducks from reset until the verdict +// first declares, (2) ducks on a howl detector over each mic's residual +// (howl_detector.h), and (3) re-arms on a timer when the verdict cannot +// recover, with an edge-triggered verdict trigger so it does not pump. +// +// THE VERDICT, per mic: ok = D < d_db AND A' < a_db (dB of the canceller's +// shadow_residual_ratio() and uncertainty_ratio(); defaults the phase-0 +// calibration, -1.235 / -23.842 dB). QUIET = no detector verdict of any path +// in the last quiet_s. +// +// TRIGGERS, per mic and tick (one tick = one block): +// * TRIP (a detector trip): the level catch (the absolute ceiling or the +// relative catch; howl_detector's ceiling_db is a deployment +// calibration), always; or the growth path while the verdict is NOT ok. +// Growth while the verdict IS ok ducks nothing: it is counted as a hint +// (hints()), because howl_detector.h measured its growth path tripping +// on stable loops' residuals (12 clean trips in 4560 s, 10 of them with +// a 2 Hz shift). +// * LOST (the verdict trigger): the verdict has been not-ok for +// trip_hold_s, counted from an ok tick seen since the last re-arm (a +// fresh ok -> not-ok edge). Armed only in OPEN and RELEASING after a +// declaration; a timer re-arm disarms it until the verdict reads ok +// once, so F -> 2F does not pump duck / open. +// +// STATES (guard_state) and every transition. "Gain" is the per-mic gain in +// dB relative to the host's operating gain (0 = pass). level = -strike_db x +// strikes (the gain the guard restores TO). arming gain = -arming_duck_db, +// further limited by cap_db when the host set one. Every gain change is a +// linear ramp in dB over a fixed duration - attack_ms down, release_ramp_ms +// up - that lands exactly on its target. +// +// ARMING entered on construction, reset() (and afc_chain::reset()), +// and from any other state when A' RETURNS to restart_a_db +// (it fell below a_db since ARMING, then regrew: a canceller +// restart). Gain = arming gain. +// -> OPEN ok held release_hold_s AND quiet (the declaration; +// ramps up over release_ramp_ms, probation starts). +// -> OPEN_CAPPED arming_timeout_s in ARMING, cap set, quiet: ramp to +// the cap; `unprotected`. +// (timeout, no cap: stays ARMING at the arming gain, `unprotected`.) +// TRIP: stays ARMING, a strike (never latches here), restarts the hold. +// ARMING never re-arms on a timer. +// OPEN gain = level. Probation (probation_s) from entry. +// -> DUCKED TRIP (a strike within probation); LOST (no strike). +// Strike decay: one strike per strike_decay_s in OPEN without a trip +// (the level ramps up strike_db). +// OPEN_CAPPED gain = min(cap, level); no declaration yet; `unprotected`. +// -> DUCKED TRIP (a strike within probation). LOST is disarmed: the +// verdict never declared. +// -> RELEASING ok held release_hold_s AND quiet (a late declaration: +// ramps to level, then OPEN). +// DUCKED gain = restore - duck_db (restore: level; min(cap, level) +// before a declaration; the floor while latched). The hold +// timer starts at entry and restarts on every TRIP. +// -> RELEASING ok held release_hold_s AND quiet [walk path] +// -> RELEASING hold reached rearm_timeout_s x 2^strikes AND quiet +// (disarms LOST until ok is seen) [re-arm path] +// RELEASING gain ramps to restore over release_ramp_ms. +// -> DUCKED TRIP (a strike); LOST (no strike). +// -> on arrival: LATCHED while latched, else OPEN after a declaration, +// else OPEN_CAPPED; probation starts. +// LATCHED gain = the floor (cap if set, else level when it latched); +// `unprotected`. Entered (through DUCKED, then RELEASING) by +// the strike that brings strikes to max_strikes outside +// ARMING. +// -> DUCKED TRIP (no strike; the release returns to LATCHED). +// -> RELEASING a FRESH ok edge (not-ok seen since latching, then ok +// held release_hold_s) AND quiet: unlatched, ramps to +// level, then OPEN with strikes still at max_strikes (one +// strike in probation latches again). +// clear() (host): strikes 0, level 0, unlatched -> RELEASING. +// +// A strike: a TRIP in ARMING, in RELEASING, or in OPEN / OPEN_CAPPED within +// probation_s of entering it. Each lowers level by strike_db and doubles +// the re-arm timeout. +// +// PER-MIC ATTRIBUTION (M > 1). A howl lives in the shared speaker signal, +// so every residual carries it. On a tick with TRIPs, the shared peak is +// the peak band of the tripping mic with the largest block power; the mics +// whose band_level_db at that band is within attrib_db of the loudest get +// the TRIP. If a mic was left out and any mic still TRIPs attack_ms + 50 ms +// later, every mic gets the TRIP (the fallback; fallbacks()). +// +// THE BUS STAGE (bus_stage(), an afc_stage for afc_chain::set_safety()). +// The per-mic gains act before the decorrelator and reverb, so a ducked +// howl keeps ringing in a charged reverb. The bus stage applies, after the +// reverb, the deepest DUCK among the mics in DUCKED or RELEASING (each mic's +// gain relative to its restore target, so it ramps with the mic and is 0 dB +// in every other state): on a trip the post-reverb bus is cut too. On the +// dry path a trip then counts twice (per mic, and on the bus). +// +// THE CANCELLER MUST HAVE THE SHADOW. Without it D is 1 (0 dB) and the +// verdict can never declare. afc_chain::set_guard() static_asserts that the +// canceller has uncertainty_ratio() and shadow_residual_ratio(), and +// refuses (returns false, leaving the guard detached) when any canceller's +// shadow_enabled() is false. +// +// MEASURED (tests/test_howl_guard_host.cpp, the gated rows; docs/howl-guard.md +// has the MUTAP_SLOW grids. macOS x86_64, i9-8950HK, AppleClang 17, +// Release, double; afc_chain with PEM + FD-Kalman and the 2-partition shadow, +// 1024 taps, block 64 at 48 kHz, S1; cabin and mt5, seeds 1 / 21 / 41; +// every run from reset; the harness's ceiling at +30 dB re the unit near end): +// * Cold start at the canceller's limit - 6 dB: 0 blocks at the 40 dB +// rule in 36 runs. With the backing track every run declares (median +// 1.77 to 2.07 s); without it none declares in 20 s - at the arming +// gain the canceller sees no excitation - and every run leaves ARMING +// through the cap at 10 s. At exact_msg_db + 3 / + 6: 0 howl blocks in +// 24 runs; at the limit + 6: 0 guarded, against 575 blocks in 3 of 6 +// unguarded voiced+aux runs. +// * Walks (S2a) at exact_msg_db - 6: 3 of 12 ducked (LOST, 0.36 s after +// the change); release (ramp start) - misalignment-oracle reconvergence +// median 1.59 s, minimum 1.38 s, 0 early. At the limit - 6 no walk +// ducked (D's pre-walk median -8.46 dB; the verdict lost for at most +// 0.22 s, under trip_hold_s) and none howled. +// * Above the canceller's limit (+6, rehearsal -> hall): 0 howl blocks +// guarded (5 strikes in 3 runs, no latch), 1203 unguarded. +// * F -> 2F (S2b): every run ducks; the timer re-arm comes at 5.00 s +// (10.01 s after a strike); 0 howl blocks; but the edge rule does NOT +// stop a duck / open cycle there: the verdict reads ok again after the +// re-arm, which re-arms LOST (16 LOST-ducks after a re-arm in 6 of 8 +// runs). An open question for the policy. +// * A 20 s gap at digital zero: A' plateaus at -15.21 / -15.30 dB (not +// ~0 dB), so restart_a_db (-1) never fires on silence; LOST ducks in the +// gap and the re-arm opens again before the singer returns; 0 howl. +// * Two mics, mic 1's path x4 at 10 s: the first TRIP ducks mic 1 alone in +// 7 of 8 runs, both in 1, the wrong mic alone in 0 (1 of 8 on macOS +// arm64 CI, 1 of 20 in the sweep); 0 fallbacks, 0 +// cascades. +// * Audible cost at the limit - 6 (0 / 2 / 5 Hz shift, 30 s): 0 ducks in +// 24 runs; the loudest residual block +19.56 dB against the +30 dB +// ceiling. +// * Dattorro (wet 0.5) in the reverb slot: with the bus stage the voice +// 0.5 s after a trip is 19.40 dB lower than without it. +// * Cost per block and mic: 2246 ns float, 3180 ns double - 0.60 / 0.88 % +// of one canceller's process_block. +// The policy defaults are the design note's; this PR's tables moved none +// of them (docs/howl-guard.md says what each row could and could not test). +// +// Real-time contract (as fd_kalman.h): the constructor validates, may throw +// std::invalid_argument, and allocates (M detectors, M x block_size gains). +// Every other entry point is noexcept and allocation-free; set_policy() +// clamps and never changes M, the block size or the detector's bands. The +// policy is double (host-facing configuration, converted once); no double +// arithmetic runs in the float hot path. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mutap/howl_detector.h" + +// No ABI tag: the guard holds howl detectors (resonator banks, no FFT), so +// its layout does not depend on the build's FFT engine (as howl_detector.h). +namespace tap::mu { + + /// The guard's per-mic state (see the file comment). + enum class guard_state : std::uint8_t { arming, open, open_capped, ducked, releasing, latched }; + + /// The guard's policy: every threshold and time. Defaults are the + /// release experiment's and the design note's starting points; + /// docs/howl-guard.md records what this library's tests measured with + /// them. + struct guard_policy { + double d_db = -1.235; ///< verdict ok needs D below this (dB) + double a_db = -23.842; ///< ... and A' below this (dB) + double trip_hold_s = 0.3; ///< LOST: verdict not-ok this long after an ok + double release_hold_s = 1.5; ///< declarations and walk releases: ok held this long + double quiet_s = 1.0; ///< no detector verdict this long = quiet + double arming_duck_db = 30.0; ///< ARMING's gain is -this (and at most cap_db) + double arming_timeout_s = 10.0; ///< ARMING without a declaration this long: unprotected + double duck_db = 20.0; ///< DUCKED sits this far under the restore target + double attack_ms = 5.0; ///< every gain decrease ramps over this + double release_ramp_ms = 200.0; ///< every gain increase ramps over this + double rearm_timeout_s = 5.0; ///< DUCKED re-arms after this x 2^strikes (and quiet) + double probation_s = 10.0; ///< a trip this soon after entering OPEN is a strike + double strike_db = 3.0; ///< each strike lowers the restore level by this + size_t max_strikes = 3; ///< this many strikes (outside ARMING) latch + double strike_decay_s = 60.0; ///< one strike decays per this long in OPEN without a trip + double attrib_db = 6.0; ///< attribution: mics within this of the loudest at the peak + double restart_a_db = -1.0; ///< A' at or above this: the canceller restarted -> ARMING + std::optional cap_db; ///< the host's ceiling (dry MSG - 6 dB, relative); none = unset + }; + + /// Per-mic gain policy over M canceller residuals. See the file comment. + template + class howl_guard { + public: + struct config { + size_t microphones = 1; ///< M >= 1 + size_t block_size = 64; ///< samples per tick (the chain's) + double sample_rate = 48000.0; ///< Hz; finite, > 0 + typename howl_detector::config + detector; ///< every mic's; block_size and sample_rate are the guard's + guard_policy policy; + }; + + /// The post-reverb bus stage (afc_stage): see the file comment. + class bus_stage_type { + public: + void process_block(const Sample* in, Sample* out, size_t n) noexcept { + const size_t b = m_guard->m_block; + const Sample* g = m_guard->m_bus_gain.data(); + for (size_t i = 0; i < n; ++i) { + out[i] = g[i < b ? i : b - 1] * in[i]; + } + } + + private: + friend class howl_guard; + explicit bus_stage_type(const howl_guard* guard) noexcept + : m_guard(guard) {} + const howl_guard* m_guard; + }; + + /// @throws std::invalid_argument on microphones == 0, block_size == + /// 0, a bad sample rate, or a detector config the detector + /// rejects. + explicit howl_guard(const config& cfg) + : m_block(validated(cfg).block_size) + , m_rate(cfg.sample_rate) + , m_tick_s(static_cast(cfg.block_size) / cfg.sample_rate) + , m_bus(this) { + auto dc = cfg.detector; + dc.block_size = cfg.block_size; + dc.sample_rate = cfg.sample_rate; + m_detectors.reserve(cfg.microphones); + for (size_t m = 0; m < cfg.microphones; ++m) { + m_detectors.emplace_back(dc); + } + m_det_cfg = m_detectors.front().cfg(); + m_mics.resize(cfg.microphones); + m_gain.resize(cfg.microphones * cfg.block_size); + m_bus_gain.resize(cfg.block_size); + m_policy = clamped(cfg.policy, guard_policy{}); + derive(); + reset(); + } + + // The bus stage points back at this object. + howl_guard(const howl_guard&) = delete; + howl_guard& operator=(const howl_guard&) = delete; + howl_guard(howl_guard&&) = delete; + howl_guard& operator=(howl_guard&&) = delete; + ~howl_guard() = default; + + size_t microphones() const noexcept { return m_mics.size(); } + size_t block_size() const noexcept { return m_block; } + const guard_policy& policy() const noexcept { return m_policy; } + + /// Replace the policy between blocks (clamped; a non-finite field + /// keeps its current value). State, strikes and timers carry over; + /// targets follow on the next update(). + void set_policy(const guard_policy& p) noexcept { + m_policy = clamped(p, m_policy); + derive(); + on_cap_change(); + } + + /// The host's ceiling (relative dB, at most 0), or none. Removing it + /// while a mic runs on it before a declaration (OPEN_CAPPED, or + /// ducked / releasing toward the cap) sends that mic to ARMING. + void set_cap_db(std::optional cap_db) noexcept { + guard_policy p = m_policy; + p.cap_db = cap_db; + m_policy = clamped(p, m_policy); + derive(); + on_cap_change(); + } + + /// Every mic to ARMING as constructed: gains at the arming gain at + /// once (no ramp), strikes, timers, counters and detectors cleared. + void reset() noexcept { + for (auto& d : m_detectors) { + d.reset(); + } + const Sample g = arming_db(); + const Sample gl = db_to_lin(g); + for (auto& s : m_mics) { + s = mic_state{}; + s.cur = g; + s.tgt = g; + s.tgt_l = gl; + s.since = m_quiet_ticks; // quiet from the start + } + std::fill(m_gain.begin(), m_gain.end(), gl); + std::fill(m_bus_gain.begin(), m_bus_gain.end(), Sample(1)); + m_episode = false; + m_fallbacks = 0; + } + + /// The host clears the back-off: strikes 0, level 0 dB, the latch + /// released (a LATCHED mic ramps up through RELEASING), the ARMING + /// timeout restarted. + void clear() noexcept { + for (auto& s : m_mics) { + s.strikes = 0; + s.level = Sample(0); + s.decay = 0; + s.timed_out = false; + if (s.latched) { + s.latched = false; + if (s.state == guard_state::latched) { + enter(s, guard_state::releasing); + } + } + if (s.state == guard_state::arming) { + s.in_state = 0; + } + } + } + + // ------------------------------------------------ the block's work + + /// Analyse mic `mic`'s residual (block_size() samples) with its + /// canceller's statistics, linear as the canceller returns them + /// (uncertainty_ratio(), shadow_residual_ratio()). Call for every + /// mic, then update(). @pre mic < microphones() + void analyze(size_t mic, const Sample* e, Sample uncertainty_ratio, Sample shadow_ratio) noexcept { + m_detectors[mic].process_block(e, m_block); + mic_state& s = m_mics[mic]; + constexpr Sample tiny = std::numeric_limits::min(); + s.a_db = Sample(10) * std::log10(std::max(uncertainty_ratio, tiny)); + s.d_db = Sample(10) * std::log10(std::max(shadow_ratio, tiny)); + } + + /// One tick of every mic's policy from this block's analyses: + /// triggers, attribution, state, and this block's gains. + void update() noexcept { + // Triggers per mic. + bool any_trip = false; + for (size_t m = 0; m < m_mics.size(); ++m) { + mic_state& s = m_mics[m]; + const howl_detector& d = m_detectors[m]; + s.ok = s.d_db < m_d_db && s.a_db < m_a_db; + const howl_trigger tr = d.trigger(); + const bool growth = tr == howl_trigger::growth; + bool catch_now = tr == howl_trigger::ceiling || tr == howl_trigger::level; + if (growth && m_det_cfg.level_catch) { + // The detector reports one path; a growth verdict can + // hide a level catch on the same tick. + catch_now = + d.power_db() >= m_det_cfg.ceiling_db + || (d.level_db() >= m_det_cfg.level_db && d.prominence_db() >= m_det_cfg.level_prominence_db); + } + s.raw_trip = catch_now || (growth && !s.ok); + s.trip = false; + if (growth && s.ok && !catch_now) { + ++s.hints; + } + // Quiet: ticks since any detector verdict. + s.since = d.verdict() ? 0 : std::min(s.since + 1, k_big); + any_trip = any_trip || s.raw_trip; + } + attribute(any_trip); + for (auto& s : m_mics) { + step(s); + } + render(); + } + + /// out = mic `mic`'s gains of this block x in (block_size() samples; + /// in may equal out). + void apply(size_t mic, const Sample* in, Sample* out) const noexcept { + const Sample* g = &m_gain[mic * m_block]; + for (size_t i = 0; i < m_block; ++i) { + out[i] = g[i] * in[i]; + } + } + + /// bus += mic `mic`'s gains of this block x in. + void accumulate(size_t mic, const Sample* in, Sample* bus) const noexcept { + const Sample* g = &m_gain[mic * m_block]; + for (size_t i = 0; i < m_block; ++i) { + bus[i] += g[i] * in[i]; + } + } + + /// The post-reverb bus stage: afc_chain::set_safety(guard.bus_stage()). + bus_stage_type* bus_stage() noexcept { return &m_bus; } + + // ------------------------------------------------------- readouts + + guard_state state(size_t mic) const noexcept { return m_mics[mic].state; } + /// The gain at the end of the last block, dB relative to the host's. + Sample gain_db(size_t mic) const noexcept { return m_mics[mic].cur; } + /// This block's per-sample gains (block_size() values, linear). + const Sample* gain_block(size_t mic) const noexcept { return &m_gain[mic * m_block]; } + /// This block's post-reverb bus gains (linear). + const Sample* bus_gain_block() const noexcept { return m_bus_gain.data(); } + /// Seconds in the current state. + double time_in_state_s(size_t mic) const noexcept { + return static_cast(m_mics[mic].in_state) * m_tick_s; + } + size_t strikes(size_t mic) const noexcept { return m_mics[mic].strikes; } + /// The restore level after strikes, dB (0 or below). + Sample level_db(size_t mic) const noexcept { return m_mics[mic].level; } + bool latched(size_t mic) const noexcept { return m_mics[mic].latched; } + /// The canceller has not certified this mic: ARMING timed out, + /// OPEN_CAPPED, or latched. + bool unprotected(size_t mic) const noexcept { + const mic_state& s = m_mics[mic]; + return s.timed_out || s.latched || s.state == guard_state::open_capped; + } + /// Whether the verdict has declared since reset (or the last restart). + bool declared(size_t mic) const noexcept { return m_mics[mic].declared; } + /// Last block's verdict and its inputs, dB. + bool verdict_ok(size_t mic) const noexcept { return m_mics[mic].ok; } + Sample uncertainty_db(size_t mic) const noexcept { return m_mics[mic].a_db; } + Sample shadow_db(size_t mic) const noexcept { return m_mics[mic].d_db; } + /// Whether mic `mic` got a TRIP this tick (after attribution). + bool tripped(size_t mic) const noexcept { return m_mics[mic].trip; } + /// Counters since reset(): TRIPs applied, entries into DUCKED, + /// timer re-arms, growth-while-ok hints. + size_t trips(size_t mic) const noexcept { return m_mics[mic].trips; } + size_t ducks(size_t mic) const noexcept { return m_mics[mic].ducks; } + size_t rearms(size_t mic) const noexcept { return m_mics[mic].rearms; } + size_t hints(size_t mic) const noexcept { return m_mics[mic].hints; } + /// Attribution fallbacks (every mic ducked) since reset(). + size_t fallbacks() const noexcept { return m_fallbacks; } + const howl_detector& detector(size_t mic) const noexcept { return m_detectors[mic]; } + + private: + static constexpr size_t k_big = std::numeric_limits::max() / 4; + + struct mic_state { + guard_state state = guard_state::arming; + // gain ramp (dB), per sample + Sample cur = Sample(0); ///< gain at the end of the last block + Sample tgt = Sample(0); + Sample tgt_l = Sample(1); + Sample rate = Sample(0); ///< dB per sample while ramping + Sample ratio = Sample(1); ///< 10^(rate / 20) + size_t left = 0; ///< ramp samples left + // verdict + Sample a_db = Sample(0); + Sample d_db = Sample(0); + bool ok = false; + // timers, ticks + size_t in_state = 0; + size_t ok_run = 0; + size_t notok_run = 0; + size_t since = 0; ///< since the last detector verdict + size_t hold = 0; ///< DUCKED: since entry or the last TRIP + size_t probation = 0; ///< ticks of probation left + size_t decay = 0; ///< OPEN ticks without a trip toward a strike decay + // flags + bool ok_seen = false; ///< an ok tick since the last re-arm + bool fell = false; ///< A' below a_db since entering ARMING (a restart needs it) + bool declared = false; + bool latched = false; + bool latch_notok = false; ///< LATCHED: not-ok seen (a fresh edge can follow) + bool timed_out = false; + bool raw_trip = false; ///< this tick, before attribution + bool trip = false; ///< this tick, after attribution + // back-off + size_t strikes = 0; + Sample level = Sample(0); + Sample floor = Sample(0); + // counters + size_t trips = 0; + size_t ducks = 0; + size_t rearms = 0; + size_t hints = 0; + }; + + static config validated(const config& cfg) { + if (cfg.microphones == 0) { + throw std::invalid_argument("howl_guard: microphones must be >= 1"); + } + if (cfg.block_size == 0) { + throw std::invalid_argument("howl_guard: block_size must be >= 1"); + } + if (!std::isfinite(cfg.sample_rate) || !(cfg.sample_rate > 0.0)) { + throw std::invalid_argument("howl_guard: sample_rate must be finite and > 0"); + } + return cfg; + } + + /// p clamped field by field; a non-finite field takes prev's value. + static guard_policy clamped(const guard_policy& p, const guard_policy& prev) noexcept { + const auto pick = [](double v, double old, double lo, double hi) { + return std::isfinite(v) ? std::clamp(v, lo, hi) : old; + }; + constexpr double big = 1e6; + guard_policy q; + q.d_db = pick(p.d_db, prev.d_db, -120.0, 120.0); + q.a_db = pick(p.a_db, prev.a_db, -300.0, 120.0); + q.trip_hold_s = pick(p.trip_hold_s, prev.trip_hold_s, 0.0, big); + q.release_hold_s = pick(p.release_hold_s, prev.release_hold_s, 0.0, big); + q.quiet_s = pick(p.quiet_s, prev.quiet_s, 0.0, big); + q.arming_duck_db = pick(p.arming_duck_db, prev.arming_duck_db, 0.0, 120.0); + q.arming_timeout_s = pick(p.arming_timeout_s, prev.arming_timeout_s, 0.0, big); + q.duck_db = pick(p.duck_db, prev.duck_db, 0.0, 120.0); + q.attack_ms = pick(p.attack_ms, prev.attack_ms, 0.0, 1e5); + q.release_ramp_ms = pick(p.release_ramp_ms, prev.release_ramp_ms, 0.0, 1e5); + q.rearm_timeout_s = pick(p.rearm_timeout_s, prev.rearm_timeout_s, 0.0, big); + q.probation_s = pick(p.probation_s, prev.probation_s, 0.0, big); + q.strike_db = pick(p.strike_db, prev.strike_db, 0.0, 60.0); + q.max_strikes = std::clamp(p.max_strikes, 1, 16); + q.strike_decay_s = pick(p.strike_decay_s, prev.strike_decay_s, 0.0, big); + q.attrib_db = pick(p.attrib_db, prev.attrib_db, 0.0, 120.0); + q.restart_a_db = pick(p.restart_a_db, prev.restart_a_db, -300.0, 120.0); + if (p.cap_db.has_value()) { + const double old = prev.cap_db.value_or(0.0); + q.cap_db = pick(*p.cap_db, old, -120.0, 0.0); + } + return q; + } + + size_t ticks(double seconds) const noexcept { + const double t = std::ceil(seconds / m_tick_s - 1e-6); // 1.5 s at 750 ticks/s is 1125, not 1126 + return t < 1.0 ? size_t{1} : (t > 1e12 ? k_big : static_cast(t)); + } + size_t samples(double ms) const noexcept { + const double n = std::round(ms * 1e-3 * m_rate); + return n < 1.0 ? size_t{0} : static_cast(n); + } + + /// Policy -> ticks, samples and Sample thresholds. + void derive() noexcept { + const guard_policy& p = m_policy; + m_d_db = static_cast(p.d_db); + m_a_db = static_cast(p.a_db); + m_restart_db = static_cast(p.restart_a_db); + m_trip_ticks = ticks(p.trip_hold_s); + m_release_ticks = ticks(p.release_hold_s); + m_quiet_ticks = p.quiet_s > 0.0 ? ticks(p.quiet_s) : 0; + m_timeout_ticks = ticks(p.arming_timeout_s); + m_rearm_ticks = ticks(p.rearm_timeout_s); + m_probation_ticks = p.probation_s > 0.0 ? ticks(p.probation_s) : 0; + m_decay_ticks = ticks(p.strike_decay_s); + m_window_ticks = ticks((p.attack_ms + 50.0) * 1e-3); + m_attack = samples(p.attack_ms); + m_release = samples(p.release_ramp_ms); + m_duck = static_cast(p.duck_db); + m_strike = static_cast(p.strike_db); + m_attrib = static_cast(p.attrib_db); + m_has_cap = p.cap_db.has_value(); + m_cap = static_cast(p.cap_db.value_or(0.0)); + m_arming = static_cast(-p.arming_duck_db); + } + + static Sample db_to_lin(Sample db) noexcept { return std::pow(Sample(10), db / Sample(20)); } + + Sample arming_db() const noexcept { return m_has_cap ? std::min(m_arming, m_cap) : m_arming; } + + /// The gain RELEASING ramps to and OPEN / OPEN_CAPPED / LATCHED hold. + Sample restore_db(const mic_state& s) const noexcept { + if (s.latched) { + return s.floor; + } + if (s.declared) { + return s.level; + } + return m_has_cap ? std::min(m_cap, s.level) : arming_db(); + } + + Sample target_db(const mic_state& s) const noexcept { + switch (s.state) { + case guard_state::arming: + return arming_db(); + case guard_state::ducked: + return restore_db(s) - m_duck; + case guard_state::open: + case guard_state::open_capped: + case guard_state::releasing: + case guard_state::latched: + default: + return restore_db(s); + } + } + + void enter(mic_state& s, guard_state next) noexcept { + s.state = next; + s.in_state = 0; + switch (next) { + case guard_state::ducked: + s.hold = 0; + s.ok_run = 0; + s.ok_seen = false; + ++s.ducks; + break; + case guard_state::open: + case guard_state::open_capped: + s.probation = m_probation_ticks; + s.decay = 0; + break; + case guard_state::latched: + s.latch_notok = false; + break; + case guard_state::arming: + case guard_state::releasing: + default: + break; + } + } + + /// Without a cap, a mic running on one before a declaration re-arms. + void on_cap_change() noexcept { + if (m_has_cap) { + return; + } + for (auto& s : m_mics) { + if (!s.declared && !s.latched && s.state != guard_state::arming) { + enter_arming(s); + } + } + } + + /// ARMING after a restart: the declaration, the latch and the + /// timeout are forgotten; strikes are kept. + void enter_arming(mic_state& s) noexcept { + enter(s, guard_state::arming); + s.fell = false; + s.latched = false; + s.declared = false; + s.ok_seen = false; + s.timed_out = false; + s.ok_run = 0; + } + + /// A strike: lowers the level, doubles the re-arm timeout; outside + /// ARMING, reaching max_strikes latches. + void strike(mic_state& s) noexcept { + s.strikes = std::min(s.strikes + 1, size_t{30}); + s.level = -m_strike * static_cast(std::min(s.strikes, m_policy.max_strikes)); + s.decay = 0; + if (s.state != guard_state::arming && s.strikes >= m_policy.max_strikes && !s.latched) { + s.latched = true; + s.floor = m_has_cap ? m_cap : s.level; + } + } + + /// Attribution of this tick's detector trips (file comment). + void attribute(bool any_trip) noexcept { + const size_t mics = m_mics.size(); + if (!any_trip) { + if (m_episode && ++m_episode_quiet >= std::max(m_quiet_ticks, 1)) { + m_episode = false; + } + return; + } + m_episode_quiet = 0; + if (mics == 1) { + m_mics[0].trip = true; + return; + } + if (m_episode && m_episode_age >= m_window_ticks) { + for (auto& s : m_mics) { + s.trip = true; + } + ++m_fallbacks; + m_episode = false; + return; + } + // The shared peak: the tripping mic with the largest block power. + size_t ref = 0; + Sample best = -std::numeric_limits::infinity(); + for (size_t m = 0; m < mics; ++m) { + if (m_mics[m].raw_trip && m_detectors[m].power_db() > best) { + best = m_detectors[m].power_db(); + ref = m; + } + } + const howl_detector& rd = m_detectors[ref]; + const Sample hz = rd.peak_hz(); + size_t band = 0; + for (size_t b = 0; b < rd.bands(); ++b) { + if (rd.band_hz(b) == hz) { + band = b; + break; + } + } + Sample loudest = -std::numeric_limits::infinity(); + for (size_t m = 0; m < mics; ++m) { + loudest = std::max(loudest, m_detectors[m].band_level_db(band)); + } + bool left_out = false; + for (size_t m = 0; m < mics; ++m) { + const bool in = m_detectors[m].band_level_db(band) >= loudest - m_attrib; + m_mics[m].trip = in; + left_out = left_out || !in; + } + if (left_out && !m_episode) { + m_episode = true; + m_episode_age = 0; + } + } + + /// One tick of one mic's state machine. + void step(mic_state& s) noexcept { + s.ok_run = s.ok ? std::min(s.ok_run + 1, k_big) : 0; + s.notok_run = s.ok ? 0 : std::min(s.notok_run + 1, k_big); + if (s.ok) { + s.ok_seen = true; + } + s.in_state = std::min(s.in_state + 1, k_big); + const bool quiet = s.since >= m_quiet_ticks; + const bool lost = s.declared && s.ok_seen && s.notok_run >= m_trip_ticks; + const bool held = s.ok_run >= m_release_ticks && quiet; + if (s.trip) { + ++s.trips; + } + + if (s.a_db < m_a_db) { + s.fell = true; + } + if (s.state != guard_state::arming && s.fell && s.a_db >= m_restart_db) { + enter_arming(s); + return; + } + switch (s.state) { + case guard_state::arming: + if (s.trip) { + strike(s); + s.ok_run = 0; + } + else if (held) { + s.declared = true; + s.timed_out = false; + enter(s, guard_state::open); + } + else if (s.in_state >= m_timeout_ticks) { + s.timed_out = true; + if (m_has_cap && quiet) { + enter(s, guard_state::open_capped); + } + } + break; + case guard_state::open: + if (s.trip) { + if (s.probation > 0) { + strike(s); + } + enter(s, guard_state::ducked); + } + else if (lost) { + enter(s, guard_state::ducked); + } + else { + if (s.probation > 0) { + --s.probation; + } + if (s.strikes > 0 && ++s.decay >= m_decay_ticks) { + --s.strikes; + s.level = -m_strike * static_cast(std::min(s.strikes, m_policy.max_strikes)); + s.decay = 0; + } + } + break; + case guard_state::open_capped: + if (s.trip) { + if (s.probation > 0) { + strike(s); + } + enter(s, guard_state::ducked); + } + else if (held) { + s.declared = true; + s.timed_out = false; + enter(s, guard_state::releasing); + } + else if (s.probation > 0) { + --s.probation; + } + break; + case guard_state::ducked: + if (s.trip) { + s.hold = 0; + s.ok_run = 0; + } + else { + s.hold = std::min(s.hold + 1, k_big); + if (held) { + s.declared = true; + s.timed_out = false; + enter(s, guard_state::releasing); + } + else if (quiet && s.hold >= rearm_ticks(s)) { + s.ok_seen = false; + s.notok_run = 0; + ++s.rearms; + enter(s, guard_state::releasing); + } + } + break; + case guard_state::releasing: + if (s.trip) { + strike(s); + enter(s, guard_state::ducked); + } + else if (lost) { + enter(s, guard_state::ducked); + } + else if (s.left == 0 && s.cur == restore_db(s)) { + if (s.latched) { + enter(s, guard_state::latched); + } + else { + enter(s, s.declared ? guard_state::open : guard_state::open_capped); + } + } + break; + case guard_state::latched: + if (!s.ok) { + s.latch_notok = true; + } + if (s.trip) { + enter(s, guard_state::ducked); + } + else if (s.latch_notok && held) { + s.latched = false; + s.declared = true; + enter(s, guard_state::releasing); + } + break; + default: + break; + } + } + + size_t rearm_ticks(const mic_state& s) const noexcept { + const size_t shift = std::min(s.strikes, size_t{30}); + return m_rearm_ticks > (k_big >> shift) ? k_big : m_rearm_ticks << shift; + } + + /// This block's per-sample gains: every mic's ramp toward its + /// state's target, and the bus stage's gain. + void render() noexcept { + const size_t b = m_block; + if (m_episode) { + m_episode_age = std::min(m_episode_age + 1, k_big); + } + std::fill(m_bus_gain.begin(), m_bus_gain.end(), Sample(1)); + for (size_t m = 0; m < m_mics.size(); ++m) { + mic_state& s = m_mics[m]; + const Sample tgt = target_db(s); + if (tgt != s.tgt) { + s.tgt = tgt; + s.tgt_l = db_to_lin(tgt); + const size_t n = (tgt < s.cur) ? m_attack : m_release; + if (n == 0) { + s.cur = tgt; + s.left = 0; + } + else { + s.rate = (tgt - s.cur) / static_cast(n); + s.ratio = db_to_lin(s.rate); + s.left = n; + } + } + Sample* g = &m_gain[m * b]; + size_t i = 0; + if (s.left > 0) { + const size_t len = std::min(b, s.left); + // Sample i carries the ramp's (i + 1)-th step; the block + // re-anchors from cur, so rounding never accumulates past + // one block, and the last step lands on the target. + Sample x = db_to_lin(s.cur + s.rate); + for (; i < len; ++i) { + g[i] = x; + x *= s.ratio; + } + s.left -= len; + if (s.left == 0) { + s.cur = s.tgt; + g[len - 1] = s.tgt_l; + } + else { + s.cur += s.rate * static_cast(len); + } + } + for (; i < b; ++i) { + g[i] = s.tgt_l; + } + if (s.state == guard_state::ducked || s.state == guard_state::releasing) { + const Sample inv = Sample(1) / db_to_lin(restore_db(s)); + for (size_t k = 0; k < b; ++k) { + m_bus_gain[k] = std::min(m_bus_gain[k], std::min(g[k] * inv, Sample(1))); + } + } + } + } + + size_t m_block; + double m_rate; + double m_tick_s; + std::vector> m_detectors; + typename howl_detector::config m_det_cfg; + std::vector m_mics; + std::vector m_gain; ///< M x block, linear + std::vector m_bus_gain; ///< block, linear + bus_stage_type m_bus; + guard_policy m_policy; + + // derived from the policy + Sample m_d_db = Sample(0); + Sample m_a_db = Sample(0); + Sample m_restart_db = Sample(0); + Sample m_duck = Sample(0); + Sample m_strike = Sample(0); + Sample m_attrib = Sample(0); + Sample m_cap = Sample(0); + Sample m_arming = Sample(0); + bool m_has_cap = false; + size_t m_trip_ticks = 1; + size_t m_release_ticks = 1; + size_t m_quiet_ticks = 0; + size_t m_timeout_ticks = 1; + size_t m_rearm_ticks = 1; + size_t m_probation_ticks = 0; + size_t m_decay_ticks = 1; + size_t m_window_ticks = 1; + size_t m_attack = 0; + size_t m_release = 0; + + // attribution episode + bool m_episode = false; + size_t m_episode_age = 0; + size_t m_episode_quiet = 0; + size_t m_fallbacks = 0; + }; + +} // namespace tap::mu diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b1894ac..61e7451 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -42,6 +42,7 @@ add_executable(mutap_tests test_fd_kalman.cpp test_frequency_shifter.cpp test_howl_detector.cpp + test_howl_guard.cpp test_convergence_stats.cpp test_lpc.cpp test_pem_afc.cpp @@ -84,6 +85,9 @@ if(NOT MUTAP_BARE_METAL AND NOT CMAKE_CROSSCOMPILING) # The howl detector's acoustic claims (tests/support/howl_runs.h): host-only for # the same reasons, and they drive the vendored faust-icc detector beside it. target_sources(mutap_tests PRIVATE test_howl_detector_host.cpp) + # The guard's live claims (tests/support/guard_runs.h): host-only for the + # same reasons, and one row runs the vendored Dattorro in the reverb slot. + target_sources(mutap_tests PRIVATE test_howl_guard_host.cpp) target_include_directories(mutap_tests PRIVATE ${PROJECT_SOURCE_DIR}/third_party/faust) endif() @@ -112,12 +116,14 @@ else() # the host-only *_host_test suites, see tests/support/rooms.h), the # validation/contract tests, the frequency shifter's float suite (not # its 10-minute drift test) and the howl detector's float suite with its - # canary (its acoustic claims are test_howl_detector_host.cpp, host-only). + # canary (its acoustic claims are test_howl_detector_host.cpp, host-only), + # and the guard's float state-machine suite (its live claims are + # test_howl_guard_host.cpp, host-only). # (The FFT suites left with the FFT: they run in DspTap's own CI.) set(mutap_emulated_selection "") if(CMAKE_CROSSCOMPILING) set(mutap_emulated_selection TEST_FILTER - "fdaf_test/0.*:FdafCrossPrecision.*:FdafConfigValidation.*:FdafRtContract.*:fd_kalman_test/0.*:fd_kalman_test/1.*:kalman_loop_test/0.*:FdKalmanConfigValidation.*:FdKalmanRtContract.*:Levinson.*:LpcPredictor.*:SpeechPredictor.*:WarpedLpcPredictor.*:PredictorConfigValidation.*:pem_afc_test/0.*:PemAfcConfigValidation.*:PemAfcRtContract.*:closed_loop_test/0.*:burst_test/0.*:aec_test/0.*:AdaptationControlConfigValidation.*:nn_suppressor_test/0.*:NnSuppressorCrossPrecision.*:NnChainFloat32.*:FftEngineContract.*:PortableRandom.*:frequency_shifter_test/0.*:FrequencyShifterCrossPrecision.*:FrequencyShifterConfigValidation.*:FrequencyShifterRtContract.*:howl_detector_test/0.*:HowlDetectorCrossPrecision.*:HowlDetectorConfigValidation.*:HowlDetectorRtContract.*") + "fdaf_test/0.*:FdafCrossPrecision.*:FdafConfigValidation.*:FdafRtContract.*:fd_kalman_test/0.*:fd_kalman_test/1.*:kalman_loop_test/0.*:FdKalmanConfigValidation.*:FdKalmanRtContract.*:Levinson.*:LpcPredictor.*:SpeechPredictor.*:WarpedLpcPredictor.*:PredictorConfigValidation.*:pem_afc_test/0.*:PemAfcConfigValidation.*:PemAfcRtContract.*:closed_loop_test/0.*:burst_test/0.*:aec_test/0.*:AdaptationControlConfigValidation.*:nn_suppressor_test/0.*:NnSuppressorCrossPrecision.*:NnChainFloat32.*:FftEngineContract.*:PortableRandom.*:frequency_shifter_test/0.*:FrequencyShifterCrossPrecision.*:FrequencyShifterConfigValidation.*:FrequencyShifterRtContract.*:howl_detector_test/0.*:HowlDetectorCrossPrecision.*:HowlDetectorConfigValidation.*:HowlDetectorRtContract.*:howl_guard_test/0.*:HowlGuardConfigValidation.*:HowlGuardRtContract.*") endif() gtest_discover_tests(mutap_tests DISCOVERY_TIMEOUT 120 ${mutap_emulated_selection} PROPERTIES TIMEOUT 900) diff --git a/tests/bare_metal_main.cpp b/tests/bare_metal_main.cpp index a351beb..b31514b 100644 --- a/tests/bare_metal_main.cpp +++ b/tests/bare_metal_main.cpp @@ -53,17 +53,18 @@ int main() { "FftEngineContract.*:PortableRandom.*:" "frequency_shifter_test/0.*:FrequencyShifterCrossPrecision.*:FrequencyShifterConfigValidation.*:" "FrequencyShifterRtContract.*:" - "howl_detector_test/0.*:HowlDetectorCrossPrecision.*:HowlDetectorConfigValidation.*:HowlDetectorRtContract.*"; + "howl_detector_test/0.*:HowlDetectorCrossPrecision.*:HowlDetectorConfigValidation.*:HowlDetectorRtContract.*:" + "howl_guard_test/0.*:HowlGuardConfigValidation.*:HowlGuardRtContract.*"; ::testing::InitGoogleTest(); const int rc = RUN_ALL_TESTS(); // A filter typo selects zero tests and RUN_ALL_TESTS() returns 0 — an // empty run must not pass green. Checked after the run because gtest // only applies the filter inside RUN_ALL_TESTS. The on-target selection - // is 82 tests (the FFT suites left with the FFT; FftEngineContract, the + // is 101 tests (the FFT suites left with the FFT; FftEngineContract, the // engine-contract rows MuTap owns, joined at the DspTap 0db95b6 bump; // PortableRandom, the fixtures' variates, when they replaced 's; // the frequency shifter's 7 with the shifter; the howl detector's 12 - // with the detector); 30 leaves headroom for + // with the detector; the howl guard's 19 with the guard); 30 leaves headroom for // legitimate removals without masking a typo. const int selected = ::testing::UnitTest::GetInstance()->test_to_run_count(); if (selected < 30) { diff --git a/tests/support/guard_loop.h b/tests/support/guard_loop.h new file mode 100644 index 0000000..48e7bb1 --- /dev/null +++ b/tests/support/guard_loop.h @@ -0,0 +1,419 @@ +/// @file guard_loop.h +/// @brief The safety layer's live loop: afc_chain with a howl_guard attached, +/// M mics in a simulated room, the forward gain (and an optional SSB +/// shift) in the decorrelator slot, and the guard-independent +/// oracles the guard's claims are measured against. +// SPDX-License-Identifier: MIT +// Copyright 2026 MuTap contributors +// +// THE LOOP (afc_chain's timeline, as tests/test_afc_chain.cpp's chain_loop and +// tests/support/two_mic_loop.h): +// +// mic_m[n] = x_m[n] + sum_k F_m(t)[k] clip(speaker[n - d - k]) +// speaker = afc_chain(mics, aux) +// = K shift(sum_m g_m e_m) [-> reverb -> guard bus stage] + aux +// u[n] = speaker[n - block - reference_delay] (the shared reference) +// +// d is the electrical delay (S1 480, S3 960 samples), reference_delay = +// d - block - 32 (two_mic_loop's aligned reference: the direct path lands at +// tap 32), K the forward gain in the decorrelator slot (a gain stage, as +// test_afc_chain.cpp: the cancellers identify the room, not the gain), g_m +// the guard's per-mic gains. F_m(t) changes at `change_block`: to paths2 +// (S2a, a walk to another room) or to change_scale x F (S2b, F -> 2F). +// +// THE ORACLES (guard-independent: they read the true room, never the +// guard's statistics): +// * frozen-estimate margin: the phase-exact MSG (closed_loop.h's +// exact_msg_db, 2^15 grid) of the loop the snapshot would close, +// sum_m g_m (F_m(t) - F_hat_m) at the current gains, minus K (and minus +// the bus stage's gain when one is attached). < 0 dB: the frozen loop +// is unstable. Undefined with a frequency shift or a reverb in the loop +// (the tests report the 40 dB rule alone there). +// * misalignment: 10 log10(|F_m(t) - F_hat_m|^2 / |F_m(t)|^2) over the +// taps the filter covers. +// * the 40 dB rule: a block of a residual e_m (before the guard's gain) +// at RMS >= 100 over the unit-RMS near end (closed_loop.h). +// * the burst oracle (howl_runs.h): a block within 0.5 s of a block at +// RMS >= 10 (+20 dB). +// Double precision: the claims are the algorithm's (rooms.h). +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "closed_loop.h" +#include "decorrelated_loop.h" +#include "howl_materials.h" +#include "karaoke_asg.h" +#include "mutap/afc_chain.h" +#include "mutap/fd_kalman.h" +#include "mutap/howl_guard.h" +#include "mutap/lpc.h" +#include "mutap/pem_afc.h" +#include "rooms.h" + +namespace mutap_test::guard { + + inline constexpr size_t k_block = 64; + inline constexpr size_t k_taps = 1024; + inline constexpr size_t k_parts = k_taps / k_block; + inline constexpr size_t k_margin = 32; ///< the reference's jitter margin (two_mic_loop.h) + inline constexpr double k_fs = 48000.0; + inline constexpr size_t k_s1 = 480; ///< 10 ms + inline constexpr size_t k_s3 = 960; ///< 20 ms + inline constexpr size_t k_oracle = 8; ///< blocks between oracle evaluations + + template + using afc = tap::mu::pem_afc, tap::mu::partitioned_fdkf>; + template + using chain_t = tap::mu::afc_chain>; + template + using guard_t = tap::mu::howl_guard; + + /// The canceller of every guard test: PEM + FD-Kalman with the speech + /// cascade, 1024 taps, the 2-partition shadow (the guard needs D). + template + typename afc::config afc_config() { + typename afc::config c; + c.fdaf.block_size = k_block; + c.fdaf.partitions = k_parts; + c.shadow_partitions = 2; + return c; + } + + constexpr size_t blocks_of(double seconds) { + return static_cast(seconds * k_fs / static_cast(k_block)); + } + + /// The guard's detector for delay d: the default detector, its loop + /// period the electrical delay, its ceiling the harness's (+30 dB re the + /// unit near end, 10 dB under the 40 dB rule; howl_runs.h). + template + typename guard_t::config guard_config(size_t mics, size_t delay) { + typename guard_t::config c; + c.microphones = mics; + c.block_size = k_block; + c.sample_rate = k_fs; + c.detector.loop_period_s = static_cast(static_cast(delay) / k_fs); + c.detector.ceiling_db = Sample(30); + return c; + } + + /// The forward path in the decorrelator slot: an optional SSB shift (the + /// harness's exact-ramp iir_ssb_shifter, decorrelated_loop.h) and the + /// forward gain K. + template + struct forward_stage { + double gain = 1.0; + double shift_hz = 0.0; + long long n = 0; + iir_ssb_shifter shifter; + void process_block(const Sample* in, Sample* out, size_t len) noexcept { + for (size_t i = 0; i < len; ++i) { + double x = static_cast(in[i]); + if (shift_hz != 0.0) { + x = shifter.process(x, 2.0 * std::numbers::pi * shift_hz * static_cast(n) / k_fs); + } + out[i] = static_cast(gain * x); + ++n; + } + } + }; + + /// What one mic's singer and the speaker carry. + struct material_spec { + howl::material mat = howl::material::voiced; + bool aux = false; ///< white backing track into the speaker + double aux_db = -12.0; ///< its level re the unit-RMS singer + }; + + inline std::string material_name(const material_spec& m) { + std::string s; + switch (m.mat) { + case howl::material::voiced: + s = "voiced"; + break; + case howl::material::music: + s = "music"; + break; + case howl::material::speech: + s = "speech"; + break; + default: + s = std::string(howl::name(m.mat)); + break; + } + return m.aux ? s + "+aux" : s; + } + + /// The live loop (file comment). + template + class guarded_loop { + public: + struct config { + std::vector> paths; ///< F_m before the change, k_taps each + std::vector> paths2; ///< F_m after it (empty: change_scale x paths) + double change_scale = 1.0; + long change_block = -1; ///< < 0: no change + size_t delay = k_s1; + double gain_db = 0.0; ///< K + double shift_hz = 0.0; + const std::vector* aux = nullptr; ///< read cyclically; null = none + double aux_db = -12.0; + double speaker_limit = 1000.0; + }; + + /// `guard` (may be null: no guard) is attached with set_guard(); + /// `reverb` rides in the reverb slot; `bus_stage` attaches the + /// guard's post-reverb stage. + guarded_loop(config cfg, guard_t* guard, tap::mu::afc_stage_ref reverb = nullptr, + bool bus_stage = true) + : m_cfg(std::move(cfg)) + , m_chain(chain_config(m_cfg)) + , m_guard(guard) { + const size_t mics = m_cfg.paths.size(); + if (mics == 0 || m_cfg.delay < k_block + k_margin) { + throw std::invalid_argument("guarded_loop: bad config"); + } + for (const auto& p : m_cfg.paths) { + if (p.size() != k_taps) { + throw std::invalid_argument("guarded_loop: paths must have k_taps taps"); + } + } + m_hist.assign(k_history, 0.0); + m_y.assign(mics, std::vector(k_block, Sample(0))); + m_ptrs.resize(mics); + for (size_t m = 0; m < mics; ++m) { + m_ptrs[m] = m_y[m].data(); + } + m_acc.assign(k_block, 0.0); + m_speaker.assign(k_block, Sample(0)); + m_auxb.assign(k_block, Sample(0)); + m_fhat.assign(k_taps, Sample(0)); + m_r.assign(k_taps + k_margin, 0.0); + m_t = k_history; + set_gain_db(m_cfg.gain_db); + m_fwd.shift_hz = m_cfg.shift_hz; + m_chain.set_decorrelator(&m_fwd); + m_chain.set_reverb(reverb); + m_has_reverb = static_cast(reverb); + if (guard != nullptr) { + if (!m_chain.set_guard(guard)) { + throw std::invalid_argument("guarded_loop: set_guard refused the guard"); + } + if (bus_stage) { + m_chain.set_safety(guard->bus_stage()); + m_bus_attached = true; + } + } + } + + guarded_loop(const guarded_loop&) = delete; + guarded_loop& operator=(const guarded_loop&) = delete; + + void set_gain_db(double k_db) { + m_cfg.gain_db = k_db; + m_fwd.gain = std::pow(10.0, k_db / 20.0); + } + double gain_db() const { return m_cfg.gain_db; } + size_t microphones() const { return m_cfg.paths.size(); } + chain_t& chain() { return m_chain; } + guard_t* guard() const { return m_guard; } + long block() const { return m_blk; } + const config& cfg() const { return m_cfg; } + + /// Copy the cancellers' state from `other` (a converged loop). + void load_cancellers(guarded_loop& other) { + for (size_t m = 0; m < microphones(); ++m) { + m_chain.canceller(m) = other.m_chain.canceller(m); + } + } + + /// One block: x holds microphones() pointers to a block of near end + /// each. Returns the largest RMS over the mics' residuals e_m (before + /// the guard's gain; +inf once non-finite). + double step(const double* const* x) { + const size_t mics = microphones(); + const bool changed = m_cfg.change_block >= 0 && m_blk >= m_cfg.change_block; + for (size_t m = 0; m < mics; ++m) { + const auto& f = path(m, changed); + const double scale = (changed && m_cfg.paths2.empty()) ? m_cfg.change_scale : 1.0; + std::fill(m_acc.begin(), m_acc.end(), 0.0); + const size_t base = m_t - m_cfg.delay; + for (size_t k = 0; k < k_taps; ++k) { + const double fk = scale * f[k]; + for (size_t i = 0; i < k_block; ++i) { + m_acc[i] += fk * m_hist[(base + i - k) & k_mask]; + } + } + for (size_t i = 0; i < k_block; ++i) { + m_y[m][i] = static_cast(x[m][i] + m_acc[i]); + } + } + const Sample* aux = nullptr; + if (m_cfg.aux != nullptr && !m_cfg.aux->empty()) { + const double g = std::pow(10.0, m_cfg.aux_db / 20.0); + for (size_t i = 0; i < k_block; ++i) { + m_auxb[i] = static_cast(g * (*m_cfg.aux)[(m_n + i) % m_cfg.aux->size()]); + } + aux = m_auxb.data(); + } + m_chain.process_block(m_ptrs.data(), aux, m_speaker.data(), k_block); + const double lim = m_cfg.speaker_limit; + for (size_t i = 0; i < k_block; ++i) { + double s = static_cast(m_speaker[i]); + if (!(s >= -lim)) { // catches NaN too + s = -lim; + } + if (s > lim) { + s = lim; + } + m_hist[(m_t + i) & k_mask] = s; + } + m_t += k_block; + m_n += k_block; + ++m_blk; + double worst = 0.0; + for (size_t m = 0; m < mics; ++m) { + const Sample* e = m_chain.error_block(m); + double acc = 0.0; + for (size_t i = 0; i < k_block; ++i) { + acc += static_cast(e[i]) * static_cast(e[i]); + } + const double r = std::sqrt(acc / static_cast(k_block)); + worst = std::max(worst, std::isfinite(r) ? r : std::numeric_limits::infinity()); + } + return worst; + } + + /// RMS of mic m's residual in the last block. + double error_rms(size_t m) const { + const Sample* e = m_chain.error_block(m); + double acc = 0.0; + for (size_t i = 0; i < k_block; ++i) { + acc += static_cast(e[i]) * static_cast(e[i]); + } + return std::sqrt(acc / static_cast(k_block)); + } + /// RMS of the speaker feed in the last block. + double speaker_rms() const { + double acc = 0.0; + for (size_t i = 0; i < k_block; ++i) { + acc += static_cast(m_speaker[i]) * static_cast(m_speaker[i]); + } + return std::sqrt(acc / static_cast(k_block)); + } + + /// RMS of the chain's voice in the last speaker block (the speaker + /// minus the backing track). + double voice_rms() const { + const bool has_aux = m_cfg.aux != nullptr && !m_cfg.aux->empty(); + double acc = 0.0; + for (size_t i = 0; i < k_block; ++i) { + const double v = static_cast(m_speaker[i]) - (has_aux ? static_cast(m_auxb[i]) : 0.0); + acc += v * v; + } + return std::sqrt(acc / static_cast(k_block)); + } + + /// Whether the frozen-estimate margin is defined (no shift, no reverb). + bool margin_defined() const { return m_cfg.shift_hz == 0.0 && !m_has_reverb; } + + /// The frozen-estimate margin (file comment), dB. + double frozen_margin_db() { + const bool changed = m_cfg.change_block >= 0 && m_blk >= m_cfg.change_block; + std::fill(m_r.begin(), m_r.end(), 0.0); + for (size_t m = 0; m < microphones(); ++m) { + const double g = mic_gain(m); + m_chain.canceller(m).copy_impulse_response(m_fhat.data()); + const auto& f = path(m, changed); + const double scale = (changed && m_cfg.paths2.empty()) ? m_cfg.change_scale : 1.0; + for (size_t i = 0; i < k_taps + k_margin; ++i) { + const double fi = (i >= k_margin) ? scale * f[i - k_margin] : 0.0; + const double hi = (i < k_taps) ? static_cast(m_fhat[i]) : 0.0; + m_r[i] += g * (fi - hi); + } + } + double bus = 0.0; + if (m_bus_attached && m_guard != nullptr) { + bus = 20.0 * std::log10(std::max(static_cast(m_guard->bus_gain_block()[k_block - 1]), 1e-30)); + } + return exact_msg_db(m_r, m_cfg.delay - k_margin, size_t{1} << 15) - m_cfg.gain_db - bus; + } + + /// Mic m's misalignment over the taps its filter covers, dB. + double misalignment_db(size_t m) { + const bool changed = m_cfg.change_block >= 0 && m_blk >= m_cfg.change_block; + m_chain.canceller(m).copy_impulse_response(m_fhat.data()); + const auto& f = path(m, changed); + const double scale = (changed && m_cfg.paths2.empty()) ? m_cfg.change_scale : 1.0; + double num = 0.0; + double den = 0.0; + for (size_t i = 0; i + k_margin < k_taps; ++i) { + const double fi = scale * f[i]; + const double d = fi - static_cast(m_fhat[i + k_margin]); + num += d * d; + den += fi * fi; + } + return 10.0 * std::log10(num / den + 1e-300); + } + + private: + static constexpr size_t k_history = size_t{1} << 13; + static constexpr size_t k_mask = k_history - 1; + + static typename chain_t::config chain_config(const config& c) { + typename chain_t::config cc; + cc.microphones = c.paths.size(); + cc.canceller = afc_config(); + cc.reference_delay_samples = c.delay - k_block - k_margin; + cc.sample_rate = k_fs; + return cc; + } + + const std::vector& path(size_t m, bool changed) const { + return (changed && !m_cfg.paths2.empty()) ? m_cfg.paths2[m] : m_cfg.paths[m]; + } + + double mic_gain(size_t m) const { + return m_guard != nullptr ? static_cast(m_guard->gain_block(m)[k_block - 1]) : 1.0; + } + + config m_cfg; + chain_t m_chain; + guard_t* m_guard; + forward_stage m_fwd; + bool m_has_reverb = false; + bool m_bus_attached = false; + std::vector m_hist; ///< clipped speaker history, a ring + size_t m_t = 0; + size_t m_n = 0; + long m_blk = 0; + std::vector> m_y; + std::vector m_ptrs; + std::vector m_acc; + std::vector m_speaker; + std::vector m_auxb; + std::vector m_fhat; + std::vector m_r; + }; + + /// n samples of material m's singer at seed `seed` (howl_materials.h). + inline std::vector singer(const material_spec& m, size_t n, unsigned seed) { + return howl::make(m.mat, n, seed); + } + + /// The backing track: white noise at unit RMS (two_mic_loop.h's), read + /// cyclically. + inline std::vector backing_track(unsigned seed) { + return white_near_end(120000, seed + 777); + } + +} // namespace mutap_test::guard diff --git a/tests/support/guard_runs.h b/tests/support/guard_runs.h new file mode 100644 index 0000000..4133d23 --- /dev/null +++ b/tests/support/guard_runs.h @@ -0,0 +1,478 @@ +/// @file guard_runs.h +/// @brief The safety layer's measurement runs on guard_loop.h: the +/// canceller's bisected limit (the operating point), and live runs +/// from reset with per-block traces of the guard and the oracles. +// SPDX-License-Identifier: MIT +// Copyright 2026 MuTap contributors +// +// Host-only: shared by tests/test_howl_guard_host.cpp's gated rows and its +// MUTAP_SLOW sweep, so a row means the same measurement in both. +// +// THE OPERATING POINT. limit_db() bisects the converged canceller's runaway +// limit in guard_loop's loop as karaoke_asg.h / two_mic_loop.h do: 1500 +// blocks of convergence at exact_msg_db - 6 dB on the material's seed, then +// the forward gain bisected (0.25 dB) on 10 s of seed + 10 with a fresh copy +// of the converged cancellers and a fresh loop per probe; howling = a +// residual block at RMS >= 100. The guard's protection claims run at that +// limit - 6 dB; the stress rows at exact_msg_db + 3 / + 6. cap_db is set as +// the protocol would: the dry limit - 6 dB relative to the operating gain, +// cap = (exact_msg_db - 6) - K. +// +// A LIVE RUN starts from reset (cold cancellers, the guard in ARMING) at the +// operating gain and records, per block: the largest residual RMS (the 40 dB +// rule), each mic's guard state, gain and verdict, and (every k_oracle +// blocks, where defined) the frozen-estimate margin and the misalignment. +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "guard_loop.h" +#include "two_mic_loop.h" + +namespace mutap_test::guard { + + inline const char* state_name(tap::mu::guard_state s) { + switch (s) { + case tap::mu::guard_state::arming: + return "ARMING"; + case tap::mu::guard_state::open: + return "OPEN"; + case tap::mu::guard_state::open_capped: + return "OPEN_CAPPED"; + case tap::mu::guard_state::ducked: + return "DUCKED"; + case tap::mu::guard_state::releasing: + return "RELEASING"; + case tap::mu::guard_state::latched: + default: + return "LATCHED"; + } + } + + /// Converged-canceller runaway limit, dB (file comment). + inline double limit_db(const std::string& room, const material_spec& mat, size_t delay, unsigned seed, + double tol_db = 0.25) { + const auto path = karaoke::room(room); + const double exact = exact_msg_db(path, delay); + const size_t conv = 1500; + const size_t probe = blocks_of(10.0); + const auto vc = singer(mat, conv * k_block, seed); + const auto vp = singer(mat, probe * k_block, seed + 10); + const auto aux = backing_track(seed); + typename guarded_loop::config cfg; + cfg.paths = {path}; + cfg.delay = delay; + cfg.gain_db = exact - 6.0; + if (mat.aux) { + cfg.aux = &aux; + cfg.aux_db = mat.aux_db; + } + guarded_loop converged(cfg, nullptr); + for (size_t blk = 0; blk < conv; ++blk) { + const double* x[1] = {&vc[blk * k_block]}; + converged.step(x); + } + auto howls = [&](double g) { + auto c = cfg; + c.gain_db = g; + guarded_loop loop(c, nullptr); + loop.load_cancellers(converged); + for (size_t blk = 0; blk < probe; ++blk) { + const double* x[1] = {&vp[blk * k_block]}; + if (loop.step(x) >= 100.0) { + return true; + } + } + return false; + }; + return two_mic::bisect(howls, exact - 15.0, exact + 30.0, tol_db); + } + + /// One single-mic live run's set-up. + struct run_spec { + std::string room = "cabin"; + std::string room2; ///< S2a: the walk's destination ("" = none) + double scale2 = 1.0; ///< S2b: F -> scale2 x F at t_change (room2 empty) + double t_change_s = -1.0; ///< < 0: no change + material_spec mat; + unsigned seed = 1; + size_t delay = k_s1; + double gain_db = 0.0; ///< K, absolute + double shift_hz = 0.0; + double seconds = 20.0; + bool guarded = true; + std::optional cap_db; ///< relative; none = unset + tap::mu::guard_policy policy; + bool oracle = true; + double t_step_s = -1.0; ///< >= 0: K steps by step_db here (a forced howl) + double step_db = 0.0; + tap::mu::afc_stage_ref reverb; ///< the reverb slot (caller-owned); null = none + bool bus_stage = true; ///< attach the guard's post-reverb stage + double gap_from_s = -1.0; ///< >= 0: singer and backing track silent (digital zero) ... + double gap_s = 0.0; ///< ... for this long (a gap between songs) + }; + + /// Per-block traces of one run (mic 0 for the guard columns). + struct run_trace { + std::vector e_rms; ///< largest residual RMS + std::vector spk_rms; ///< speaker RMS + std::vector voice_rms; ///< speaker minus the backing track + std::vector state; + std::vector gain_db; + std::vector ok; + std::vector a_db; ///< A', dB + std::vector d_db; ///< D, dB + std::vector trip; ///< a TRIP applied this block + std::vector rearm; ///< a timer re-arm this block + std::vector o_blk; ///< oracle blocks + std::vector o_margin; + std::vector o_mis; + long change = -1; ///< the change block + size_t strikes = 0; + bool latched = false; + size_t rearms = 0; + size_t hints = 0; + size_t size() const { return e_rms.size(); } + }; + + inline run_trace live_run(const run_spec& s) { + const size_t n_blk = blocks_of(s.seconds); + auto v = singer(s.mat, n_blk * k_block, s.seed); + auto aux = backing_track(s.seed); + if (s.gap_from_s >= 0.0) { + // The track unrolled to the run's length (the loop reads it + // cyclically: the same samples), then both silenced in the gap. + std::vector full(n_blk * k_block); + for (size_t i = 0; i < full.size(); ++i) { + full[i] = aux[i % aux.size()]; + } + aux = std::move(full); + const size_t g0 = blocks_of(s.gap_from_s) * k_block; + const size_t g1 = std::min(v.size(), blocks_of(s.gap_from_s + s.gap_s) * k_block); + for (size_t i = g0; i < g1; ++i) { + v[i] = 0.0; + aux[i] = 0.0; + } + } + typename guarded_loop::config cfg; + cfg.paths = {karaoke::room(s.room)}; + cfg.delay = s.delay; + cfg.gain_db = s.gain_db; + cfg.shift_hz = s.shift_hz; + if (s.mat.aux) { + cfg.aux = &aux; + cfg.aux_db = s.mat.aux_db; + } + if (s.t_change_s >= 0.0) { + cfg.change_block = static_cast(blocks_of(s.t_change_s)); + if (!s.room2.empty()) { + cfg.paths2 = {karaoke::room(s.room2)}; + } + else { + cfg.change_scale = s.scale2; + } + } + auto gc = guard_config(1, s.delay); + gc.policy = s.policy; + gc.policy.cap_db = s.cap_db; + guard_t g(gc); + guarded_loop loop(cfg, s.guarded ? &g : nullptr, s.reverb, s.bus_stage); + const auto step_blk = s.t_step_s >= 0.0 ? static_cast(blocks_of(s.t_step_s)) : -1L; + run_trace t; + t.change = cfg.change_block; + t.e_rms.reserve(n_blk); + size_t rearms = 0; + for (size_t blk = 0; blk < n_blk; ++blk) { + if (static_cast(blk) == step_blk) { + loop.set_gain_db(s.gain_db + s.step_db); + } + const double* x[1] = {&v[blk * k_block]}; + t.e_rms.push_back(static_cast(loop.step(x))); + t.spk_rms.push_back(static_cast(loop.speaker_rms())); + t.voice_rms.push_back(static_cast(loop.voice_rms())); + t.state.push_back(g.state(0)); + t.gain_db.push_back(s.guarded ? static_cast(g.gain_db(0)) : 0.0F); + t.ok.push_back(g.verdict_ok(0) ? 1 : 0); + t.a_db.push_back(static_cast(g.uncertainty_db(0))); + t.d_db.push_back(static_cast(g.shadow_db(0))); + t.trip.push_back(g.tripped(0) ? 1 : 0); + t.rearm.push_back(g.rearms(0) > rearms ? 1 : 0); + rearms = g.rearms(0); + if (s.oracle && loop.margin_defined() && (blk % k_oracle == 0)) { + t.o_blk.push_back(static_cast(blk)); + t.o_margin.push_back(static_cast(loop.frozen_margin_db())); + t.o_mis.push_back(static_cast(loop.misalignment_db(0))); + } + } + t.strikes = g.strikes(0); + t.latched = g.latched(0); + t.rearms = g.rearms(0); + t.hints = g.hints(0); + return t; + } + + // ------------------------------------------------------------ two mics + + /// The two-mic loop's material: two_mic_loop.h's separated singers + /// (300 / 200 Hz) with -10 dB cross leakage, optionally the backing + /// track at -12 dB. + struct two_mic_material { + std::vector> x; ///< per mic: own singer + the other's leakage + }; + + inline two_mic_material two_mic_singers(size_t blocks, unsigned seed) { + namespace tm = two_mic; + const auto s0 = tm::singer(tm::singers::separated, 0, blocks, seed); + const auto s1 = tm::singer(tm::singers::separated, 1, blocks, seed); + return {{tm::near_end_at(0, s0, s1, -10.0), tm::near_end_at(1, s1, s0, -10.0)}}; + } + + /// The two-mic loop's converged-chain limit, bisected as limit_db() on + /// guard_loop with both mics. + inline double limit2_db(const std::string& pair, bool aux, size_t delay, unsigned seed, double tol_db = 0.25) { + const auto paths = two_mic::room_pair(pair); + const double exact = exact_msg_db(two_mic::path_sum(paths), delay); + const size_t conv = 1500; + const size_t probe = blocks_of(10.0); + const auto vc = two_mic_singers(conv, seed); + const auto vp = two_mic_singers(probe, seed + 10); + const auto track = backing_track(seed); + typename guarded_loop::config cfg; + cfg.paths = paths; + cfg.delay = delay; + cfg.gain_db = exact - 6.0; + if (aux) { + cfg.aux = &track; + } + guarded_loop converged(cfg, nullptr); + for (size_t blk = 0; blk < conv; ++blk) { + const double* x[2] = {&vc.x[0][blk * k_block], &vc.x[1][blk * k_block]}; + converged.step(x); + } + auto howls = [&](double g) { + auto c = cfg; + c.gain_db = g; + guarded_loop loop(c, nullptr); + loop.load_cancellers(converged); + for (size_t blk = 0; blk < probe; ++blk) { + const double* x[2] = {&vp.x[0][blk * k_block], &vp.x[1][blk * k_block]}; + if (loop.step(x) >= 100.0) { + return true; + } + } + return false; + }; + return two_mic::bisect(howls, exact - 15.0, exact + 30.0, tol_db); + } + + /// One two-mic live run: from reset at `gain_db`, mic 1's path scaled by + /// `force_scale` at `t_force_s` (a single-mic howl: that mic moved toward + /// the speaker). + struct two_mic_spec { + std::string pair = "cabin"; + bool aux = false; + unsigned seed = 1; + size_t delay = k_s1; + double gain_db = 0.0; + double t_force_s = 10.0; + double force_scale = 4.0; + double seconds = 20.0; + std::optional cap_db; + }; + + struct two_mic_trace { + std::vector e_rms; ///< largest residual RMS + std::vector> state; ///< per mic + std::vector> trip; ///< per mic: a TRIP applied + std::vector> ok; ///< per mic: verdict + long force = 0; + size_t fallbacks = 0; + size_t size() const { return e_rms.size(); } + }; + + inline two_mic_trace two_mic_run(const two_mic_spec& s) { + const size_t n_blk = blocks_of(s.seconds); + const auto v = two_mic_singers(n_blk, s.seed); + const auto track = backing_track(s.seed); + const auto paths = two_mic::room_pair(s.pair); + typename guarded_loop::config cfg; + cfg.paths = paths; + cfg.paths2 = paths; + cfg.change_block = static_cast(blocks_of(s.t_force_s)); + for (auto& f : cfg.paths2[1]) { + f *= s.force_scale; + } + cfg.delay = s.delay; + cfg.gain_db = s.gain_db; + if (s.aux) { + cfg.aux = &track; + } + auto gc = guard_config(2, s.delay); + gc.policy.cap_db = s.cap_db; + guard_t g(gc); + guarded_loop loop(cfg, &g); + two_mic_trace t; + t.force = cfg.change_block; + t.state.resize(2); + t.trip.resize(2); + t.ok.resize(2); + for (size_t blk = 0; blk < n_blk; ++blk) { + const double* x[2] = {&v.x[0][blk * k_block], &v.x[1][blk * k_block]}; + t.e_rms.push_back(static_cast(loop.step(x))); + for (size_t m = 0; m < 2; ++m) { + t.state[m].push_back(g.state(m)); + t.trip[m].push_back(g.tripped(m) ? 1 : 0); + t.ok[m].push_back(g.verdict_ok(m) ? 1 : 0); + } + } + t.fallbacks = g.fallbacks(); + return t; + } + + // ------------------------------------------------------------ metrics + + inline double block_s() { + return static_cast(k_block) / k_fs; + } + + /// First block at or after `from` in state `st` (-1: none). + inline long first_state(const run_trace& t, tap::mu::guard_state st, long from = 0) { + for (auto i = static_cast(std::max(from, 0L)); i < t.size(); ++i) { + if (t.state[i] == st) { + return static_cast(i); + } + } + return -1; + } + + /// [from, to) clamped to [0, v.size()] as iterators. The [from, to) + /// helpers below take an open-ended `to` (SIZE_MAX by default); walking + /// iterators bounded by the vector's own end, not an index loop against + /// min(to, size), keeps GCC 13's -Waggressive-loop-optimizations from + /// proving an out-of-range iteration (it bounds size() only by the + /// pointer difference / sizeof(T), 2^62 for a float). + template + std::pair::const_iterator, typename std::vector::const_iterator> + clamped_range(const std::vector& v, size_t from, size_t to) { + const size_t end = std::min(to, v.size()); + const size_t begin = std::min(from, end); + return {v.begin() + static_cast(begin), v.begin() + static_cast(end)}; + } + + /// Blocks in state `st` over [from, to). + inline size_t blocks_in(const run_trace& t, tap::mu::guard_state st, size_t from = 0, size_t to = ~size_t{0}) { + const auto [b, e] = clamped_range(t.state, from, to); + return static_cast(std::count(b, e, st)); + } + + /// Blocks at the 40 dB rule over [from, to). + inline size_t howl_blocks(const run_trace& t, size_t from = 0, size_t to = ~size_t{0}) { + const auto [b, e] = clamped_range(t.e_rms, from, to); + return static_cast(std::count_if(b, e, [](float r) { return r >= 100.0F; })); + } + + /// Seconds the frozen-estimate margin was below 0 dB over [from, to) + /// (each oracle sample stands for k_oracle blocks). + inline double unstable_s(const run_trace& t, size_t from = 0, size_t to = ~size_t{0}) { + size_t n = 0; + for (size_t k = 0; k < t.o_blk.size(); ++k) { + const auto b = static_cast(t.o_blk[k]); + if (b >= from && b < to && t.o_margin[k] < 0.0F) { + ++n; + } + } + return static_cast(n * k_oracle) * block_s(); + } + + /// Longest run of consecutive blocks at the 40 dB rule, seconds. + inline double longest_howl_s(const run_trace& t, size_t from = 0) { + const auto [b, e] = clamped_range(t.e_rms, from, t.e_rms.size()); + size_t best = 0; + size_t cur = 0; + for (auto it = b; it != e; ++it) { + cur = *it >= 100.0F ? cur + 1 : 0; + best = std::max(best, cur); + } + return static_cast(best) * block_s(); + } + + /// Entries into state `st` over [from, to): blocks in `st` whose + /// predecessor is not. + inline size_t entries(const run_trace& t, tap::mu::guard_state st, size_t from = 0, size_t to = ~size_t{0}) { + const auto [b, e] = clamped_range(t.state, std::max(from, 1), to); + size_t n = 0; + for (auto it = b; it != e; ++it) { + n += (*it == st && *(it - 1) != st) ? 1U : 0U; + } + return n; + } + + /// The burst oracle (howl_runs.h): block i lies within 0.5 s of a block + /// at RMS >= 10. + inline std::vector bursts(const run_trace& t) { + constexpr long k_tail = 375; + const size_t n = t.size(); + std::vector out(n, 0); + long last = -1000000; + for (size_t i = 0; i < n; ++i) { + if (t.e_rms[i] >= 10.0F) { + last = static_cast(i); + } + out[i] = (static_cast(i) - last <= k_tail) ? 1 : 0; + } + long next = 1L << 40; + for (size_t i = n; i-- > 0;) { + if (t.e_rms[i] >= 10.0F) { + next = static_cast(i); + } + out[i] = static_cast(out[i] | ((next - static_cast(i) <= k_tail) ? 1 : 0)); + } + return out; + } + + /// The misalignment oracle after a change at block c: mismatch when the + /// misalignment exceeds the median over [c - 2 s, c) + 3 dB; returns the + /// first block after c starting a 0.3 s run back under it (-1: never; + /// -2: never detected the change). + inline long reconverged(const run_trace& t, long c) { + std::vector base; + for (size_t k = 0; k < t.o_blk.size(); ++k) { + if (t.o_blk[k] >= c - static_cast(blocks_of(2.0)) && t.o_blk[k] < c) { + base.push_back(t.o_mis[k]); + } + } + if (base.empty()) { + return -1; + } + const double thr = median(base) + 3.0; + const auto debounce = static_cast(blocks_of(0.3)); + bool detected = false; + long start = -1; + for (size_t k = 0; k < t.o_blk.size(); ++k) { + if (t.o_blk[k] < c) { + continue; + } + const bool mis = t.o_mis[k] > thr; + if (mis) { + detected = true; + start = -1; + } + else if (detected) { + if (start < 0) { + start = t.o_blk[k]; + } + if (t.o_blk[k] - start >= debounce) { + return start; + } + } + } + return detected ? -1 : -2; + } + +} // namespace mutap_test::guard diff --git a/tests/test_howl_guard.cpp b/tests/test_howl_guard.cpp new file mode 100644 index 0000000..fb5a493 --- /dev/null +++ b/tests/test_howl_guard.cpp @@ -0,0 +1,682 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 MuTap contributors +// +// The safety layer's guard (mutap/howl_guard.h) and its afc_chain hook: the +// state machine, the ramps, the strikes and latch, the attribution and the +// bus stage, driven tick by tick with synthetic residuals and synthetic +// verdicts, in float and double. Every expectation here is a transition +// time in ticks or an exact gain the policy defines (a tick is one 64-sample +// block at 48 kHz: 750 ticks per second), not a measured number; the live +// claims are host-only: tests/test_howl_guard_host.cpp. Both emulated +// selections run the float suite (howl_guard_test/0) and the float +// validation, contract and chain-hook tests. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "mutap/afc_chain.h" +#include "mutap/fd_kalman.h" +#include "mutap/howl_guard.h" +#include "mutap/lpc.h" +#include "mutap/pem_afc.h" + +namespace { + + using tap::mu::guard_policy; + using tap::mu::guard_state; + using tap::mu::howl_guard; + + constexpr size_t k_block = 64; + constexpr double k_fs = 48000.0; + // Policy times in ticks at the defaults (750 ticks per second). + constexpr long k_release = 1125; ///< release_hold_s 1.5 + constexpr long k_trip = 225; ///< trip_hold_s 0.3 + constexpr long k_quiet = 750; ///< quiet_s 1.0 + constexpr long k_timeout = 7500; ///< arming_timeout_s 10 + constexpr long k_rearm = 3750; ///< rearm_timeout_s 5 + constexpr long k_ramp_up = 150; ///< release_ramp_ms 200 = 9600 samples + constexpr long k_probate = 7500; ///< probation_s 10 + constexpr long k_window = 42; ///< attack_ms + 50 ms = 41.25 ticks, rounded up + + /// Deterministic uniform noise in [-1, 1) (a 64-bit LCG: the same stream + /// on every target). + class lcg_noise { + public: + explicit lcg_noise(std::uint64_t seed) + : m_state(seed) {} + double next() { + m_state = m_state * 6364136223846793005ULL + 1442695040888963407ULL; + return static_cast(m_state >> 11) * 0x1.0p-52 - 1.0; + } + + private: + std::uint64_t m_state; + }; + + /// What a mic's residual carries on one tick. + enum class sig { + quiet, ///< white noise at -60 dB re 1 + loud, ///< a 1 kHz tone at amplitude 2 (+3 dB re 1: over the default -6 dB ceiling) + tone ///< the same tone at `tone_amp` + }; + + /// Drives a guard tick by tick: per mic a residual block and a verdict. + template + class driver { + public: + explicit driver(const typename howl_guard::config& cfg) + : g(cfg) + , m_noise(cfg.microphones, lcg_noise(7)) + , m_phase(cfg.microphones, 0.0) + , m_x(k_block) { + for (size_t m = 0; m < cfg.microphones; ++m) { + m_noise[m] = lcg_noise(7 + m); + } + } + + /// One tick for every mic: `kinds[m]`, `ok[m]`. + void tick(const std::vector& kinds, const std::vector& ok, double a_override = -1.0) { + for (size_t m = 0; m < g.microphones(); ++m) { + fill(m, kinds[m]); + const Sample a = a_override >= 0.0 ? static_cast(a_override) + : (ok[m] ? Sample(1e-3) : Sample(0.1)); // -30 / -10 dB + const Sample d = ok[m] ? Sample(0.5) : Sample(1); // -3.01 / 0 dB + g.analyze(m, m_x.data(), a, d); + } + g.update(); + ++ticks; + } + /// One tick on a single-mic guard. + void tick1(sig kind, bool ok) { tick({kind}, {ok}); } + /// n ticks of the same input on a single-mic guard; returns the + /// first tick (counted from this call) at which the state became + /// `until`, or -1. + long run1(long n, sig kind, bool ok, std::optional until = std::nullopt) { + for (long i = 0; i < n; ++i) { + tick1(kind, ok); + if (until.has_value() && g.state(0) == *until) { + return i; + } + } + return -1; + } + + howl_guard g; + long ticks = 0; + double tone_amp = 2.0; + + private: + void fill(size_t m, sig kind) { + const double w = 2.0 * std::numbers::pi * 1000.0 / k_fs; + for (size_t i = 0; i < k_block; ++i) { + double x = 1e-3 * m_noise[m].next(); + if (kind != sig::quiet) { + x = (kind == sig::loud ? 2.0 : tone_amp) * std::sin(m_phase[m]); + } + m_phase[m] += w; + m_x[i] = static_cast(x); + } + } + + std::vector m_noise; + std::vector m_phase; + std::vector m_x; + }; + + template + typename howl_guard::config guard_config(size_t mics = 1) { + typename howl_guard::config c; + c.microphones = mics; + c.block_size = k_block; + c.sample_rate = k_fs; + return c; + } + + template + Sample db_to_lin(double db) { + return std::pow(Sample(10), static_cast(db) / Sample(20)); + } + + /// Drive a fresh single-mic guard to OPEN (ok, quiet) and through its + /// ramp; with `past_probation`, also through probation. + template + void open_it(driver& d, bool past_probation) { + ASSERT_EQ(d.run1(k_release + 10, sig::quiet, true, guard_state::open), k_release - 1); + d.run1(k_ramp_up, sig::quiet, true); + if (past_probation) { + d.run1(k_probate, sig::quiet, true); + } + ASSERT_EQ(d.g.state(0), guard_state::open); + ASSERT_EQ(d.g.gain_db(0), Sample(0)); + } + + template + class howl_guard_test : public ::testing::Test {}; + using sample_types = ::testing::Types; + TYPED_TEST_SUITE(howl_guard_test, sample_types); + + // From construction the guard is ARMING at -arming_duck_db, exactly, and + // declares (-> OPEN) on the tick the verdict has been ok for + // release_hold_s with the detector quiet; the gain then ramps to 0 dB + // linearly in dB over release_ramp_ms and lands on exactly 1. + TYPED_TEST(howl_guard_test, ArmsThenDeclaresAfterTheReleaseHold) { + driver d(guard_config()); + EXPECT_EQ(d.g.state(0), guard_state::arming); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-30)); + EXPECT_EQ(d.g.gain_block(0)[0], db_to_lin(-30.0)); + // ok but not yet held: ARMING + EXPECT_EQ(d.run1(k_release - 1, sig::quiet, true, guard_state::open), -1); + d.tick1(sig::quiet, true); + ASSERT_EQ(d.g.state(0), guard_state::open); + EXPECT_TRUE(d.g.declared(0)); + // the entry block carries the first 64 of 9600 ramp steps + const TypeParam* g = d.g.gain_block(0); + for (size_t i = 0; i < k_block; ++i) { + const double want = std::pow(10.0, (-30.0 + 30.0 * static_cast(i + 1) / 9600.0) / 20.0); + EXPECT_NEAR(static_cast(g[i]), want, want * 1e-5) << i; + } + d.run1(k_ramp_up / 2 - 1, sig::quiet, true); + EXPECT_NEAR(static_cast(d.g.gain_db(0)), -15.0, 1e-3); + d.run1(k_ramp_up / 2 - 1, sig::quiet, true); + EXPECT_LT(d.g.gain_db(0), TypeParam(0)); + d.tick1(sig::quiet, true); + EXPECT_EQ(d.g.gain_db(0), TypeParam(0)); + EXPECT_EQ(d.g.gain_block(0)[k_block - 1], TypeParam(1)); + d.tick1(sig::quiet, true); + for (size_t i = 0; i < k_block; ++i) { + EXPECT_EQ(d.g.gain_block(0)[i], TypeParam(1)) << i; + } + } + + // A level-catch trip (one block over the ceiling) ducks in the same tick; + // the gain ramps down duck_db over attack_ms (240 samples: 3.75 blocks) + // and lands on exactly -20 dB. Outside probation it is not a strike. + TYPED_TEST(howl_guard_test, CeilingTripDucksAndTheAttackLandsExactly) { + driver d(guard_config()); + open_it(d, true); + d.tick1(sig::loud, true); + EXPECT_TRUE(d.g.tripped(0)); + EXPECT_EQ(d.g.state(0), guard_state::ducked); + EXPECT_EQ(d.g.strikes(0), 0U); + d.run1(2, sig::quiet, true); + EXPECT_GT(d.g.gain_db(0), TypeParam(-20)); + d.tick1(sig::quiet, true); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-20)); + EXPECT_EQ(d.g.gain_block(0)[47], db_to_lin(-20.0)); + EXPECT_EQ(d.g.gain_block(0)[k_block - 1], db_to_lin(-20.0)); + } + + // The walk path: DUCKED releases on the tick the verdict has been ok for + // release_hold_s since the duck AND the detector has been quiet for + // quiet_s; trips restart the hold. Then RELEASING ramps back and OPENs. + TYPED_TEST(howl_guard_test, WalkReleaseNeedsTheHoldAndQuiet) { + driver d(guard_config()); + open_it(d, true); + d.tick1(sig::loud, true); + ASSERT_EQ(d.g.state(0), guard_state::ducked); + // a second trip 0.5 s later restarts the hold + d.run1(374, sig::quiet, true); + d.tick1(sig::loud, true); + EXPECT_EQ(d.g.state(0), guard_state::ducked); + const long rel = d.run1(k_release + 10, sig::quiet, true, guard_state::releasing); + EXPECT_EQ(rel, k_release - 1); + EXPECT_EQ(d.g.rearms(0), 0U); + EXPECT_EQ(d.run1(k_ramp_up + 2, sig::quiet, true, guard_state::open), k_ramp_up - 1); + EXPECT_EQ(d.g.gain_db(0), TypeParam(0)); + EXPECT_EQ(d.g.ducks(0), 1U); + } + + // Quiet binds when the hold is shorter: with release_hold_s 0.5 s the + // walk release waits for quiet_s (1 s) after the last detector verdict. + TYPED_TEST(howl_guard_test, ReleaseWaitsForQuiet) { + auto c = guard_config(); + c.policy.release_hold_s = 0.5; + driver d(c); + ASSERT_EQ(d.run1(k_quiet, sig::quiet, true, guard_state::open), 374); + d.run1(k_ramp_up + k_probate, sig::quiet, true); + d.tick1(sig::loud, true); + ASSERT_EQ(d.g.state(0), guard_state::ducked); + EXPECT_EQ(d.run1(k_quiet + 5, sig::quiet, true, guard_state::releasing), k_quiet - 1); + } + + // F -> 2F: the verdict goes not-ok and stays there. LOST ducks once, + // trip_hold_s after the ok -> not-ok edge; the timer re-arm releases + // rearm_timeout_s after the duck; then, with the verdict still not ok, + // nothing pumps for a minute. One ok tick re-arms LOST. + TYPED_TEST(howl_guard_test, VerdictLostDucksOnceAndTheRearmDoesNotPump) { + driver d(guard_config()); + open_it(d, true); + EXPECT_EQ(d.run1(k_trip + 5, sig::quiet, false, guard_state::ducked), k_trip - 1); + EXPECT_EQ(d.g.strikes(0), 0U); + EXPECT_EQ(d.run1(k_rearm + 5, sig::quiet, false, guard_state::releasing), k_rearm - 1); + EXPECT_EQ(d.g.rearms(0), 1U); + EXPECT_EQ(d.run1(k_ramp_up + 2, sig::quiet, false, guard_state::open), k_ramp_up - 1); + EXPECT_EQ(d.run1(750 * 60, sig::quiet, false, guard_state::ducked), -1); + EXPECT_EQ(d.g.ducks(0), 1U); + d.tick1(sig::quiet, true); + EXPECT_EQ(d.run1(k_trip + 5, sig::quiet, false, guard_state::ducked), k_trip - 1); + EXPECT_EQ(d.g.ducks(0), 2U); + } + + // Strikes: a trip within probation of entering OPEN lowers the restore + // level by strike_db; the third (max_strikes) latches at the level then + // (no cap: -9 dB) through DUCKED and RELEASING. The latch holds while the + // verdict stays ok and leaves on a FRESH ok edge held release_hold_s. + TYPED_TEST(howl_guard_test, StrikesBackOffAndTheThirdLatches) { + driver d(guard_config()); + open_it(d, false); + for (int k = 1; k <= 3; ++k) { + d.tick1(sig::loud, true); + ASSERT_EQ(d.g.state(0), guard_state::ducked) << k; + EXPECT_EQ(d.g.strikes(0), static_cast(k)); + EXPECT_EQ(d.g.level_db(0), TypeParam(-3 * k)); + EXPECT_EQ(d.g.latched(0), k == 3); + d.run1(10, sig::quiet, true); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-3 * k - 20)) << k; + ASSERT_GE(d.run1(k_release + 10, sig::quiet, true, guard_state::releasing), 0) << k; + const guard_state arrive = (k == 3) ? guard_state::latched : guard_state::open; + ASSERT_GE(d.run1(k_ramp_up + 2, sig::quiet, true, arrive), 0) << k; + EXPECT_EQ(d.g.gain_db(0), TypeParam(-3 * k)) << k; + } + EXPECT_TRUE(d.g.unprotected(0)); + // ok all along: no fresh edge, still latched + EXPECT_EQ(d.run1(750 * 5, sig::quiet, true, guard_state::releasing), -1); + d.tick1(sig::quiet, false); + EXPECT_EQ(d.run1(k_release + 5, sig::quiet, true, guard_state::releasing), k_release - 1); + EXPECT_FALSE(d.g.latched(0)); + EXPECT_GE(d.run1(k_ramp_up + 2, sig::quiet, true, guard_state::open), 0); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-9)); + EXPECT_EQ(d.g.strikes(0), 3U); + } + + // The re-arm timeout doubles per strike: one strike, verdict not ok -> + // RELEASING 2 x rearm_timeout_s after the duck. + TYPED_TEST(howl_guard_test, RearmTimeoutDoublesPerStrike) { + driver d(guard_config()); + open_it(d, false); + d.tick1(sig::loud, false); + ASSERT_EQ(d.g.state(0), guard_state::ducked); + ASSERT_EQ(d.g.strikes(0), 1U); + EXPECT_EQ(d.run1(2 * k_rearm + 5, sig::quiet, false, guard_state::releasing), 2 * k_rearm - 1); + } + + // clear() releases a latch and the back-off: ramps up to 0 dB, OPEN. + TYPED_TEST(howl_guard_test, ClearReleasesTheLatch) { + driver d(guard_config()); + open_it(d, false); + for (int k = 1; k <= 3; ++k) { + d.tick1(sig::loud, true); + d.run1(k_release + 10, sig::quiet, true, guard_state::releasing); + d.run1(k_ramp_up + 2, sig::quiet, true, k == 3 ? guard_state::latched : guard_state::open); + } + ASSERT_EQ(d.g.state(0), guard_state::latched); + d.g.clear(); + EXPECT_EQ(d.g.state(0), guard_state::releasing); + EXPECT_EQ(d.g.strikes(0), 0U); + EXPECT_GE(d.run1(k_ramp_up + 2, sig::quiet, true, guard_state::open), 0); + EXPECT_EQ(d.g.gain_db(0), TypeParam(0)); + EXPECT_FALSE(d.g.unprotected(0)); + } + + // ARMING's liveness: without a cap a run that never declares stays ARMING + // at the arming gain and raises `unprotected` at arming_timeout_s; with + // the host's cap it ramps to the cap (OPEN_CAPPED), and a late + // declaration from there ramps to 0 dB and OPENs. A cap below the arming + // duck limits ARMING's gain. + TYPED_TEST(howl_guard_test, ArmingTimesOutUnprotectedOrToTheCap) { + { + driver d(guard_config()); + d.run1(k_timeout - 1, sig::quiet, false); + EXPECT_FALSE(d.g.unprotected(0)); + d.tick1(sig::quiet, false); + EXPECT_TRUE(d.g.unprotected(0)); + EXPECT_EQ(d.g.state(0), guard_state::arming); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-30)); + } + { + auto c = guard_config(); + c.policy.cap_db = -12.0; + driver d(c); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-30)); + EXPECT_EQ(d.run1(k_timeout + 5, sig::quiet, false, guard_state::open_capped), k_timeout - 1); + EXPECT_TRUE(d.g.unprotected(0)); + d.run1(k_ramp_up, sig::quiet, false); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-12)); + // LOST is disarmed before a declaration + EXPECT_EQ(d.run1(750 * 20, sig::quiet, false, guard_state::ducked), -1); + EXPECT_EQ(d.run1(k_release + 5, sig::quiet, true, guard_state::releasing), k_release - 1); + EXPECT_GE(d.run1(k_ramp_up + 2, sig::quiet, true, guard_state::open), 0); + EXPECT_EQ(d.g.gain_db(0), TypeParam(0)); + EXPECT_FALSE(d.g.unprotected(0)); + } + { + auto c = guard_config(); + c.policy.cap_db = -40.0; + driver d(c); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-40)); + } + } + + // A trip in ARMING is a strike, restarts the hold, and does not latch + // or leave ARMING; the declaration then waits for ok held AND quiet. + TYPED_TEST(howl_guard_test, ArmingTripIsAStrikeAndRestartsTheHold) { + driver d(guard_config()); + d.run1(500, sig::quiet, true); + d.tick1(sig::loud, true); + EXPECT_EQ(d.g.state(0), guard_state::arming); + EXPECT_EQ(d.g.strikes(0), 1U); + EXPECT_EQ(d.run1(k_release + 5, sig::quiet, true, guard_state::open), k_release - 1); + d.run1(k_ramp_up + 1, sig::quiet, true); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-3)); + } + + // The growth path ducks only while the verdict is not ok: a growing + // tone (the level catch off, so only growth can fire) in OPEN with the + // verdict ok is a hint; in OPEN_CAPPED (no declaration) it is a TRIP. + TYPED_TEST(howl_guard_test, GrowthDucksOnlyWhileTheVerdictIsNotOk) { + for (const bool ok : {true, false}) { + auto c = guard_config(); + c.detector.level_catch = false; + c.policy.cap_db = -12.0; + driver d(c); + if (ok) { + open_it(d, true); + } + else { + d.run1(k_timeout + k_ramp_up + 10, sig::quiet, false); + ASSERT_EQ(d.g.state(0), guard_state::open_capped); + } + // a tone growing 600 dB/s from -60 dB re 1 over the -60 dB floor + bool fired = false; + for (int k = 0; k < 300 && !fired; ++k) { + d.tone_amp = std::pow(10.0, (-60.0 + 600.0 * static_cast(k) / 750.0) / 20.0); + d.tick1(sig::tone, ok); + fired = d.g.detector(0).trigger() == tap::mu::howl_trigger::growth; + } + ASSERT_TRUE(fired) << ok; + if (ok) { + EXPECT_EQ(d.g.state(0), guard_state::open); + EXPECT_EQ(d.g.hints(0), 1U); + EXPECT_EQ(d.g.ducks(0), 0U); + } + else { + EXPECT_EQ(d.g.state(0), guard_state::ducked); + EXPECT_EQ(d.g.hints(0), 0U); + } + } + } + + // A canceller restart (A' back to 0 dB) sends an OPEN mic to ARMING: + // the gain ramps down over attack_ms and the declaration is forgotten. + TYPED_TEST(howl_guard_test, RestartSendsAnOpenMicToArming) { + driver d(guard_config()); + open_it(d, true); + d.tick({sig::quiet}, {false}, 1.0); + EXPECT_EQ(d.g.state(0), guard_state::arming); + EXPECT_FALSE(d.g.declared(0)); + d.run1(4, sig::quiet, false); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-30)); + } + + // Attribution: a howl tripping mic 1 with mic 0 carrying the line 10.5 dB + // lower ducks mic 1 alone; trips continuing attack_ms + 50 ms later duck + // every mic (the fallback). Equal lines duck both at once. The bus + // stage carries the deepest duck (relative to the restore level). + TYPED_TEST(howl_guard_test, AttributionDucksTheLoudestThenFallsBack) { + auto open2 = [](driver& d) { + for (long i = 0; i < k_release + k_ramp_up + k_probate; ++i) { + d.tick({sig::quiet, sig::quiet}, {true, true}); + } + ASSERT_EQ(d.g.state(0), guard_state::open); + ASSERT_EQ(d.g.state(1), guard_state::open); + }; + { + driver d(guard_config(2)); + open2(d); + d.tone_amp = 0.6; + d.tick({sig::tone, sig::loud}, {true, true}); + EXPECT_EQ(d.g.state(0), guard_state::open); + EXPECT_EQ(d.g.state(1), guard_state::ducked); + EXPECT_EQ(d.g.fallbacks(), 0U); + for (int k = 0; k < 4; ++k) { + d.tick({sig::quiet, sig::quiet}, {true, true}); + } + // the bus stage: mic 1's duck, landed; mic 0 contributes nothing + const TypeParam* bus = d.g.bus_gain_block(); + EXPECT_NEAR(static_cast(bus[k_block - 1]), 0.1, 1e-5); + std::vector in(k_block, TypeParam(1)); + std::vector out(k_block, TypeParam(0)); + d.g.bus_stage()->process_block(in.data(), out.data(), k_block); + EXPECT_EQ(out[k_block - 1], bus[k_block - 1]); + // the howl continues on mic 1 + long fell = -1; + for (long k = 0; k < 2 * k_window && fell < 0; ++k) { + d.tick({sig::tone, sig::loud}, {true, true}); + if (d.g.state(0) == guard_state::ducked) { + fell = d.ticks; + } + } + EXPECT_GE(fell, 0); + EXPECT_EQ(d.g.fallbacks(), 1U); + } + { + driver d(guard_config(2)); + open2(d); + d.tick({sig::loud, sig::loud}, {true, true}); + EXPECT_EQ(d.g.state(0), guard_state::ducked); + EXPECT_EQ(d.g.state(1), guard_state::ducked); + } + { + driver d(guard_config(2)); + open2(d); + for (size_t i = 0; i < k_block; ++i) { + EXPECT_EQ(d.g.bus_gain_block()[i], TypeParam(1)); + } + } + } + + // reset() returns every mic to the constructed ARMING state. + TYPED_TEST(howl_guard_test, ResetRestoresTheConstructedState) { + driver d(guard_config()); + open_it(d, false); + d.tick1(sig::loud, true); + ASSERT_EQ(d.g.strikes(0), 1U); + d.g.reset(); + EXPECT_EQ(d.g.state(0), guard_state::arming); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-30)); + EXPECT_EQ(d.g.strikes(0), 0U); + EXPECT_EQ(d.g.ducks(0), 0U); + EXPECT_FALSE(d.g.declared(0)); + EXPECT_EQ(d.run1(k_release + 5, sig::quiet, true, guard_state::open), k_release - 1); + } + + // set_policy() clamps (a non-finite field keeps its value; ranges are + // enforced) and keeps the state; set_cap_db() moves ARMING's gain and, + // removed, sends a capped mic back to ARMING. + TYPED_TEST(howl_guard_test, PolicyClampsAndTheCapMoves) { + driver d(guard_config()); + guard_policy p = d.g.policy(); + p.duck_db = std::numeric_limits::quiet_NaN(); + p.attack_ms = -5.0; + p.max_strikes = 0; + p.strike_db = 1e9; + d.g.set_policy(p); + EXPECT_EQ(d.g.policy().duck_db, 20.0); + EXPECT_EQ(d.g.policy().attack_ms, 0.0); + EXPECT_EQ(d.g.policy().max_strikes, 1U); + EXPECT_EQ(d.g.policy().strike_db, 60.0); + EXPECT_EQ(d.g.microphones(), 1U); + EXPECT_EQ(d.g.block_size(), k_block); + d.g.set_cap_db(-45.0); + d.tick1(sig::quiet, false); + d.run1(4, sig::quiet, false); + EXPECT_EQ(d.g.gain_db(0), TypeParam(-45)); + d.g.set_cap_db(5.0); + EXPECT_EQ(d.g.policy().cap_db.value_or(-1.0), 0.0); + d.g.set_cap_db(-12.0); + d.run1(k_timeout + 10, sig::quiet, false); + ASSERT_EQ(d.g.state(0), guard_state::open_capped); + d.g.set_cap_db(std::nullopt); + EXPECT_EQ(d.g.state(0), guard_state::arming); + } + + // ------------------------------------------------- afc_chain's hook + + template + using kalman_afc = tap::mu::pem_afc, tap::mu::partitioned_fdkf>; + template + using chain = tap::mu::afc_chain>; + + template + typename chain::config chain_config(size_t mics, size_t shadow) { + typename chain::config c; + c.microphones = mics; + c.canceller.fdaf.block_size = k_block; + c.canceller.fdaf.partitions = 4; + c.canceller.shadow_partitions = shadow; + return c; + } + + // set_guard() refuses a canceller without the shadow and a guard of the + // wrong shape (leaving the chain unguarded), attaches otherwise; nullptr + // detaches. The guard runs on every block: one mic, no aux, no stages, + // the speaker is exactly the guard's gain times the canceller's residual + // (ARMING's -30 dB here), and error_block() stays the residual. + // afc_chain::reset() returns the guard to ARMING. + TYPED_TEST(howl_guard_test, ChainHookAttachesRunsAndResets) { + using sample = TypeParam; + { + chain c(chain_config(1, 0)); + howl_guard g(guard_config(1)); + EXPECT_FALSE(c.set_guard(&g)); + EXPECT_EQ(c.guard(), nullptr); + } + { + chain c(chain_config(2, 2)); + howl_guard g(guard_config(1)); + EXPECT_FALSE(c.set_guard(&g)); + EXPECT_TRUE(c.set_guard(nullptr)); + } + chain c(chain_config(1, 2)); + howl_guard g(guard_config(1)); + ASSERT_TRUE(c.set_guard(&g)); + EXPECT_EQ(c.guard(), &g); + lcg_noise noise(3); + std::vector mic(k_block); + std::vector spk(k_block); + for (int blk = 0; blk < 50; ++blk) { + for (auto& x : mic) { + x = static_cast(0.1 * noise.next()); // -25 dB re 1: under the default ceiling + } + const sample* mics[1] = {mic.data()}; + c.process_block(mics, nullptr, spk.data(), k_block); + const sample* e = c.error_block(0); + const sample* gb = g.gain_block(0); + for (size_t i = 0; i < k_block; ++i) { + ASSERT_EQ(spk[i], gb[i] * e[i]) << blk << " " << i; + } + } + EXPECT_EQ(g.state(0), guard_state::arming); + // A permissive policy declares at once; the chain's reset re-arms. + guard_policy p = g.policy(); + p.d_db = 100.0; + p.a_db = 100.0; + p.release_hold_s = 0.0; + p.quiet_s = 0.0; + p.restart_a_db = 120.0; + g.set_policy(p); + for (int blk = 0; blk < 3; ++blk) { + const sample* mics[1] = {mic.data()}; + c.process_block(mics, nullptr, spk.data(), k_block); + } + EXPECT_EQ(g.state(0), guard_state::open); + c.reset(); + EXPECT_EQ(g.state(0), guard_state::arming); + EXPECT_EQ(g.gain_db(0), sample(-30)); + } + + // Without a guard the chain is unchanged: a chain that had a guard and + // detached it matches one that never had one, bit for bit. + TYPED_TEST(howl_guard_test, ChainWithoutAGuardIsUnchanged) { + using sample = TypeParam; + chain a(chain_config(2, 2)); + chain b(chain_config(2, 2)); + howl_guard g(guard_config(2)); + ASSERT_TRUE(b.set_guard(&g)); + ASSERT_TRUE(b.set_guard(nullptr)); + lcg_noise noise(5); + std::vector m0(k_block); + std::vector m1(k_block); + std::vector aux(k_block); + std::vector sa(k_block); + std::vector sb(k_block); + for (int blk = 0; blk < 40; ++blk) { + for (size_t i = 0; i < k_block; ++i) { + m0[i] = static_cast(noise.next()); + m1[i] = static_cast(noise.next()); + aux[i] = static_cast(0.25 * noise.next()); + } + const sample* mics[2] = {m0.data(), m1.data()}; + a.process_block(mics, aux.data(), sa.data(), k_block); + b.process_block(mics, aux.data(), sb.data(), k_block); + for (size_t i = 0; i < k_block; ++i) { + ASSERT_EQ(sa[i], sb[i]) << blk << " " << i; + } + } + } + + TEST(HowlGuardConfigValidation, RejectsBadConfigs) { + using cfg_t = howl_guard::config; + auto bad = [](auto mutate) { + cfg_t c; + mutate(c); + return c; + }; + EXPECT_NO_THROW(howl_guard(cfg_t{})); + EXPECT_NO_THROW(howl_guard(howl_guard::config{})); + EXPECT_THROW(howl_guard(bad([](cfg_t& c) { c.microphones = 0; })), std::invalid_argument); + EXPECT_THROW(howl_guard(bad([](cfg_t& c) { c.block_size = 0; })), std::invalid_argument); + EXPECT_THROW(howl_guard(bad([](cfg_t& c) { c.sample_rate = 0.0; })), std::invalid_argument); + EXPECT_THROW(howl_guard(bad([](cfg_t& c) { c.sample_rate = std::numeric_limits::quiet_NaN(); })), + std::invalid_argument); + EXPECT_THROW(howl_guard(bad([](cfg_t& c) { c.detector.bands = 2; })), std::invalid_argument); + // the detector's block size and rate are the guard's + cfg_t c; + c.detector.block_size = 7; + c.detector.sample_rate = 8000.0; + howl_guard g(c); + EXPECT_EQ(g.detector(0).cfg().block_size, 64U); + EXPECT_EQ(g.detector(0).cfg().sample_rate, 48000.0); + } + + TEST(HowlGuardRtContract, PostConstructionEntryPointsAreNoexcept) { + using gf = howl_guard; + using gd = howl_guard; + static_assert(noexcept(std::declval().analyze(0, nullptr, 1.0F, 1.0F))); + static_assert(noexcept(std::declval().update())); + static_assert(noexcept(std::declval().apply(0, nullptr, nullptr))); + static_assert(noexcept(std::declval().accumulate(0, nullptr, nullptr))); + static_assert(noexcept(std::declval().reset())); + static_assert(noexcept(std::declval().clear())); + static_assert(noexcept(std::declval().set_policy(guard_policy{}))); + static_assert(noexcept(std::declval().set_cap_db(std::nullopt))); + static_assert(noexcept(std::declval().bus_stage())); + static_assert(noexcept(std::declval().state(0))); + static_assert(noexcept(std::declval().unprotected(0))); + static_assert(noexcept(std::declval().update())); + static_assert(tap::mu::afc_stage); + static_assert(tap::mu::afc_stage); + static_assert(noexcept(std::declval&>().set_guard(nullptr))); + SUCCEED(); + } + +} // namespace diff --git a/tests/test_howl_guard_host.cpp b/tests/test_howl_guard_host.cpp new file mode 100644 index 0000000..d82c985 --- /dev/null +++ b/tests/test_howl_guard_host.cpp @@ -0,0 +1,1485 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 MuTap contributors +// +// The safety layer's live claims (mutap/howl_guard.h), host-only: the guard +// in afc_chain's loop (tests/support/guard_loop.h), from reset, against +// guard-independent oracles (tests/support/guard_runs.h): the harness's 40 dB +// rule on each residual, the frozen-estimate margin, the misalignment oracle +// and the burst oracle. Double precision, PEM + FD-Kalman with the 2-partition +// shadow, 1024 taps, block 64 at 48 kHz, S1 (480 samples) unless a row says +// S3 (960). +// +// OPERATING POINTS, stated per row: the converged canceller's limit - 6 dB +// (k_ops below: the median over seed sets of gd::limit_db, re-measured by +// HowlGuardSweep.OperatingPoints) for the protection claims; exact_msg_db +// + 3 / + 6 dB (over the dry limit) and the limit + 6 dB for the stress rows; +// exact_msg_db - 6 dB (the release experiment's point) for the walk timing. +// The host's cap is set as the protocol would: the dry limit - 6 dB, relative. +// +// The gated rows (HowlGuardHost.*) run seeds 1, 21, 41 (or 1, 21) on cabin +// and mt5 (both generator families); each takes under 90 s on 4 threads +// (596.71 s for all 13 in a full ctest run on the Intel Mac). +// HowlGuardSweep.* (MUTAP_SLOW=1) re-measures the operating points and runs +// the long grids (six rooms, five seed sets) that docs/howl-guard.md quotes. Every threshold below is a +// measured number with margin, the measurement in the comment beside it; +// chaotic rows are gated as directions and medians, never single runs. +// +// Host for every number here: macOS 15.7 x86_64 (i9-8950HK), AppleClang 17, +// Release, a shared machine. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "faust_generated.h" +#include "support/guard_runs.h" + +namespace { + + namespace gd = mutap_test::guard; + namespace hw = mutap_test::howl; + using mutap_test::median; + using tap::mu::guard_state; + + bool slow_enabled() { + const char* v = std::getenv("MUTAP_SLOW"); + return v != nullptr && std::string(v) == "1"; + } + + /// At most 4 workers by default (the suites share their machines); + /// MUTAP_SLOW_THREADS overrides. + unsigned worker_count() { + if (const char* v = std::getenv("MUTAP_SLOW_THREADS")) { + return std::max(1U, static_cast(std::strtoul(v, nullptr, 10))); + } + const unsigned hw_threads = std::thread::hardware_concurrency(); + return std::clamp(hw_threads > 1 ? hw_threads - 1 : 1U, 1U, 4U); + } + + void run_parallel(const std::vector>& jobs) { + std::atomic next{0}; + std::vector pool; + for (unsigned t = 0; t < worker_count(); ++t) { + pool.emplace_back([&] { + for (size_t j = next++; j < jobs.size(); j = next++) { + jobs[j](); + } + }); + } + for (auto& t : pool) { + t.join(); + } + } + + double max_of(const std::vector& v) { + return v.empty() ? std::nan("") : *std::max_element(v.begin(), v.end()); + } + double min_of(const std::vector& v) { + return v.empty() ? std::nan("") : *std::min_element(v.begin(), v.end()); + } + double med(const std::vector& v) { + return v.empty() ? std::nan("") : median(v); + } + double secs(long blocks) { + return static_cast(blocks) * gd::block_s(); + } + + // ---------------------------------------------------- materials + + gd::material_spec mat(hw::material m, bool aux) { + gd::material_spec s; + s.mat = m; + s.aux = aux; + return s; + } + const gd::material_spec k_v = mat(hw::material::voiced, true); ///< V: voiced + backing track + const gd::material_spec k_h = mat(hw::material::voiced, false); ///< H: the held note alone + const gd::material_spec k_m = mat(hw::material::music, false); ///< M: music, no backing track + const gd::material_spec k_m_aux = mat(hw::material::music, true); + const gd::material_spec k_sp = mat(hw::material::speech, false); + const gd::material_spec k_sp_aux = mat(hw::material::speech, true); + const std::vector k_cold_mats = {k_v, k_h, k_m, k_m_aux, k_sp, k_sp_aux}; + + // ---------------------------------------------------- operating points + + /// The converged canceller's runaway limit over exact_msg_db (dB), the + /// median over seed sets 1, 21, 41, 61, 81 (gd::limit_db; printed by + /// HowlGuardSweep.OperatingPoints, which re-measures it). The guard's + /// protection claims run at this - 6 dB. NaN: not measured. + struct op_row { + const char* room; + const char* material; + double s1; + double s3; + }; + const double k_nan = std::numeric_limits::quiet_NaN(); + // OPERATING_POINTS_TABLE + const op_row k_ops[] = { + {"cabin", "voiced+aux", 11.89, 13.12}, {"cabin", "voiced", 9.26, 14.88}, + {"cabin", "music", 10.14, 15.76}, {"cabin", "music+aux", 9.96, 13.48}, + {"cabin", "speech", 19.63, 20.16}, {"cabin", "speech+aux", 19.45, 19.98}, + {"mt5", "voiced+aux", 11.02, 12.42}, {"mt5", "voiced", 11.02, 15.76}, + {"mt5", "music", 11.19, 16.99}, {"mt5", "music+aux", 11.89, 16.11}, + {"mt5", "speech", 22.62, 22.62}, {"mt5", "speech+aux", 21.56, 22.62}, + {"studio", "voiced+aux", 9.08, k_nan}, {"studio", "voiced", 10.84, k_nan}, + {"studio", "music", 10.84, k_nan}, {"studio", "music+aux", 11.02, k_nan}, + {"studio", "speech", 19.28, k_nan}, {"studio", "speech+aux", 18.93, k_nan}, + {"rehearsal", "voiced+aux", 9.61, k_nan}, {"rehearsal", "voiced", 10.66, k_nan}, + {"rehearsal", "music", 12.07, k_nan}, {"rehearsal", "music+aux", 11.54, k_nan}, + {"rehearsal", "speech", 15.06, k_nan}, {"rehearsal", "speech+aux", 13.83, k_nan}, + {"hall", "voiced+aux", 9.26, k_nan}, {"hall", "voiced", 7.32, k_nan}, + {"hall", "music", 11.89, k_nan}, {"hall", "music+aux", 11.37, k_nan}, + {"hall", "speech", 19.63, k_nan}, {"hall", "speech+aux", 19.98, k_nan}, + {"mt9", "voiced+aux", 8.73, k_nan}, {"mt9", "voiced", 10.31, k_nan}, + {"mt9", "music", 11.19, k_nan}, {"mt9", "music+aux", 11.02, k_nan}, + {"mt9", "speech", 21.39, k_nan}, {"mt9", "speech+aux", 20.51, k_nan}, + }; + + double asg_db(const std::string& room, const gd::material_spec& m, size_t delay) { + const std::string name = gd::material_name(m); + for (const auto& r : k_ops) { + if (room == r.room && name == r.material) { + return delay == gd::k_s1 ? r.s1 : r.s3; + } + } + return std::nan(""); + } + + double exact_db(const std::string& room, size_t delay) { + return mutap_test::exact_msg_db(mutap_test::karaoke::room(room), delay); + } + + /// limit - 6 dB, absolute. + double operating_gain(const std::string& room, const gd::material_spec& m, size_t delay) { + return exact_db(room, delay) + asg_db(room, m, delay) - 6.0; + } + + /// The protocol's cap: the dry limit - 6 dB, relative to the operating gain. + double cap_for(double exact, double gain_db) { + return (exact - 6.0) - gain_db; + } + + /// The two-mic loop's limit over exact_msg_db(sum F) (dB), the median + /// over the same seed sets (gd::limit2_db), without / with the backing + /// track. + struct op2_row { + const char* pair; + double plain; + double aux; + }; + // OPERATING_POINTS_TWO_MIC + const op2_row k_ops2[] = { + {"cabin", 9.43, 11.72}, + {"mt5+mt9", 8.55, 8.73}, + }; + + const std::vector k_seeds3 = {1, 21, 41}; + const std::vector k_seeds5 = {1, 21, 41, 61, 81}; + + // ---------------------------------------------------- cold start + + struct cold_stats { + std::string label; + size_t runs = 0; + std::vector t_open; ///< declared runs: first OPEN, s + size_t never = 0; ///< never left ARMING + size_t capped = 0; ///< reached OPEN_CAPPED + std::vector arming_s; ///< time in ARMING, s + size_t howl = 0; ///< blocks at the 40 dB rule + size_t howl_runs = 0; + std::vector unstable_s; ///< frozen-estimate margin < 0, s (where defined) + size_t ungrd_howl = 0; + size_t ungrd_howl_runs = 0; + size_t ungrd_runs = 0; + std::mutex mu; + + void add(const gd::run_trace& t) { + std::lock_guard lock(mu); + ++runs; + const long open = gd::first_state(t, guard_state::open); + if (open >= 0) { + t_open.push_back(secs(open)); + } + if (gd::blocks_in(t, guard_state::arming) == t.size()) { + ++never; + } + capped += gd::first_state(t, guard_state::open_capped) >= 0 ? 1U : 0U; + arming_s.push_back(secs(static_cast(gd::blocks_in(t, guard_state::arming)))); + const size_t h = gd::howl_blocks(t); + howl += h; + howl_runs += h > 0 ? 1U : 0U; + if (!t.o_blk.empty()) { + unstable_s.push_back(gd::unstable_s(t)); + } + } + void add_unguarded(const gd::run_trace& t) { + std::lock_guard lock(mu); + ++ungrd_runs; + const size_t h = gd::howl_blocks(t); + ungrd_howl += h; + ungrd_howl_runs += h > 0 ? 1U : 0U; + } + void print() const { + std::printf( + " %-26s %4zu | %3zu %6.2f %6.2f | %3zu %3zu | %6.2f | %5zu %3zu | %6.2f %6.2f | %5zu %3zu/%zu\n", + label.c_str(), runs, t_open.size(), med(t_open), max_of(t_open), never, capped, med(arming_s), howl, + howl_runs, med(unstable_s), max_of(unstable_s), ungrd_howl, ungrd_howl_runs, ungrd_runs); + } + }; + + void print_cold_header() { + std::printf(" %-26s %4s | %3s %6s %6s | %3s %3s | %6s | %5s %3s | %6s %6s | %5s %s\n", "row", "runs", "dec", + "open", "max", "nvr", "cap", "arm s", "howl", "run", "unst", "max", "ungd", "runs"); + } + + struct cold_spec { + std::vector rooms; + std::vector mats; + std::vector seeds; + size_t delay = gd::k_s1; + double rel = std::nan(""); ///< dB over exact_msg_db; NaN: over_limit + double over_limit = -6.0; ///< dB over the canceller's limit + double shift_hz = 0.0; + double seconds = 20.0; + std::vector twins; ///< materials also run unguarded (oracle off) + bool cap = true; + }; + + std::vector cold_rows(const cold_spec& c) { + std::vector out(c.mats.size()); + std::vector> jobs; + for (size_t mi = 0; mi < c.mats.size(); ++mi) { + std::string label = gd::material_name(c.mats[mi]) + (c.delay == gd::k_s1 ? " S1" : " S3"); + if (c.shift_hz > 0.0) { + label += " " + std::to_string(static_cast(c.shift_hz)) + " Hz"; + } + if (!std::isnan(c.rel)) { + label += " dry+" + std::to_string(static_cast(c.rel)); + } + else if (c.over_limit != -6.0) { + label += " lim+" + std::to_string(static_cast(c.over_limit)); + } + out[mi].label = label; + for (const auto& room : c.rooms) { + for (const unsigned seed : c.seeds) { + gd::run_spec s; + s.room = room; + s.mat = c.mats[mi]; + s.seed = seed; + s.delay = c.delay; + const double ex = exact_db(room, c.delay); + s.gain_db = std::isnan(c.rel) ? ex + asg_db(room, c.mats[mi], c.delay) + c.over_limit : ex + c.rel; + s.shift_hz = c.shift_hz; + if (c.cap) { + s.cap_db = cap_for(ex, s.gain_db); + } + s.seconds = c.seconds; + jobs.emplace_back([s, &st = out[mi]] { st.add(gd::live_run(s)); }); + const std::string name = gd::material_name(c.mats[mi]); + if (std::find(c.twins.begin(), c.twins.end(), name) != c.twins.end()) { + auto u = s; + u.guarded = false; + u.oracle = false; + jobs.emplace_back([u, &st = out[mi]] { st.add_unguarded(gd::live_run(u)); }); + } + } + } + } + run_parallel(jobs); + return out; + } + + // ---------------------------------------------------- walks (S2a) + + struct walk_stats { + std::string label; + size_t runs = 0; + size_t open_at_change = 0; + size_t ducked = 0; ///< runs with a DUCKED after the change + size_t by_lost = 0; ///< ... whose first duck was LOST + std::vector duck_s; ///< first duck - change, s + std::vector delta_s; ///< first release (ramp start) - reconvergence, s + size_t early = 0; ///< first releases before the oracle reconverged + size_t releases = 0; ///< every post-change release + size_t early_all = 0; ///< ... before the oracle reconverged + size_t never_reconv = 0; + size_t rehowl = 0; ///< releases with a howl block within 3 s + size_t howl_post = 0; ///< howl blocks after the change + std::vector longest_s; + size_t strikes = 0; + size_t latched = 0; + std::vector d_pre; ///< median D over the 2 s before the change, dB + std::vector notok_s; ///< longest not-ok run in the 3 s after the change, s + std::mutex mu; + + void add(const gd::run_trace& t) { + std::lock_guard lock(mu); + ++runs; + const long c = t.change; + const auto from = static_cast(static_cast(c) - gd::blocks_of(2.0)); + const size_t to = std::min(t.size(), static_cast(c) + gd::blocks_of(3.0)); + d_pre.push_back(median(std::vector(t.d_db.begin() + from, t.d_db.begin() + c))); + size_t best = 0; + size_t cur = 0; + for (auto i = static_cast(c); i < to; ++i) { + cur = t.ok[i] != 0 ? 0 : cur + 1; + best = std::max(best, cur); + } + notok_s.push_back(secs(static_cast(best))); + open_at_change += t.state[static_cast(c)] == guard_state::open ? 1U : 0U; + const long reconv = gd::reconverged(t, c); + never_reconv += reconv < 0 ? 1U : 0U; + const long duck = gd::first_state(t, guard_state::ducked, c); + if (duck >= 0) { + ++ducked; + by_lost += t.trip[static_cast(duck)] == 0 ? 1U : 0U; + duck_s.push_back(secs(duck - c)); + const long rel = gd::first_state(t, guard_state::releasing, duck); + if (rel >= 0) { + if (reconv >= 0) { + delta_s.push_back(secs(rel - reconv)); + } + early += (reconv < 0 || rel < reconv) ? 1U : 0U; + } + } + for (size_t i = static_cast(c) + 1; i < t.size(); ++i) { + if (t.state[i] == guard_state::releasing && t.state[i - 1] != guard_state::releasing) { + ++releases; + early_all += (reconv < 0 || static_cast(i) < reconv) ? 1U : 0U; + const size_t end = std::min(t.size(), i + gd::blocks_of(3.0)); + rehowl += gd::howl_blocks(t, i, end) > 0 ? 1U : 0U; + } + } + howl_post += gd::howl_blocks(t, static_cast(c)); + longest_s.push_back(gd::longest_howl_s(t, static_cast(c))); + strikes += t.strikes; + latched += t.latched ? 1U : 0U; + } + void merge(const walk_stats& o) { + runs += o.runs; + open_at_change += o.open_at_change; + ducked += o.ducked; + by_lost += o.by_lost; + duck_s.insert(duck_s.end(), o.duck_s.begin(), o.duck_s.end()); + delta_s.insert(delta_s.end(), o.delta_s.begin(), o.delta_s.end()); + early += o.early; + releases += o.releases; + early_all += o.early_all; + never_reconv += o.never_reconv; + rehowl += o.rehowl; + howl_post += o.howl_post; + longest_s.insert(longest_s.end(), o.longest_s.begin(), o.longest_s.end()); + strikes += o.strikes; + latched += o.latched; + d_pre.insert(d_pre.end(), o.d_pre.begin(), o.d_pre.end()); + notok_s.insert(notok_s.end(), o.notok_s.begin(), o.notok_s.end()); + } + void print() const { + std::printf(" %-26s %3zu | %3zu %3zu %3zu | %5.2f | %6.2f %6.2f %3zu | %3zu %3zu %3zu | %3zu %5zu %5.2f | " + "%3zu %3zu | %6.2f %5.2f %5.2f\n", + label.c_str(), runs, open_at_change, ducked, by_lost, med(duck_s), med(delta_s), + min_of(delta_s), early, releases, early_all, never_reconv, rehowl, howl_post, max_of(longest_s), + strikes, latched, med(d_pre), med(notok_s), max_of(notok_s)); + } + }; + + void print_walk_header() { + std::printf( + " %-26s %3s | %3s %3s %3s | %5s | %6s %6s %3s | %3s %3s %3s | %3s %5s %5s | %3s %3s | %6s %5s %5s\n", + "row", "n", "opn", "dck", "lst", "duck", "d med", "d min", "erl", "rel", "ear", "nrc", "rhw", "howl", + "long", "stk", "lat", "D pre", "nok", "max"); + } + +} // namespace + +namespace { + + // ---------------------------------------------------- the stress rows + + /// Walks (S2a) or louder couplings (S2b, room2 empty): rows of (room, + /// room2) x seeds, V, at `rel` over exact_msg_db (NaN: the start room's + /// limit - 6 dB), the change at 10 s; with `twin`, unguarded twins too. + std::vector walk_rows(const std::vector>& rows, + const std::vector& seeds, double rel, double seconds, bool twin, + std::vector* unguarded = nullptr) { + std::vector out(rows.size()); + std::vector ug(rows.size()); + std::vector> jobs; + for (size_t r = 0; r < rows.size(); ++r) { + const auto& [room, room2] = rows[r]; + out[r].label = room + (room2.empty() ? std::string(" x2") : " -> " + room2); + ug[r].label = out[r].label + " (none)"; + for (const unsigned seed : seeds) { + gd::run_spec s; + s.room = room; + s.room2 = room2; + s.scale2 = room2.empty() ? 2.0 : 1.0; + s.t_change_s = 10.0; + s.mat = k_v; + s.seed = seed; + const double ex = exact_db(room, gd::k_s1); + s.gain_db = std::isnan(rel) ? operating_gain(room, k_v, gd::k_s1) : ex + rel; + s.cap_db = cap_for(ex, s.gain_db); + s.seconds = seconds; + jobs.emplace_back([s, &st = out[r]] { st.add(gd::live_run(s)); }); + if (twin) { + auto u = s; + u.guarded = false; + jobs.emplace_back([u, &st = ug[r]] { st.add(gd::live_run(u)); }); + } + } + } + run_parallel(jobs); + if (unguarded != nullptr) { + *unguarded = std::move(ug); + } + return out; + } + + /// F -> 2F live: per run, the first duck after the change, the first + /// timer re-arm, LOST-ducks after it (the pump), howl blocks after it. + struct rearm_stats { + std::string label; + size_t runs = 0; + size_t ducked = 0; + std::vector duck_s; ///< first duck - change + size_t rearmed = 0; + std::vector rearm_s; ///< first re-arm - first duck + size_t pumps = 0; ///< LOST-ducks after the first re-arm + size_t pump_runs = 0; + size_t lost_ducks = 0; ///< LOST-ducks after the change + size_t trip_ducks = 0; ///< TRIP-ducks after the change + size_t howl_after = 0; ///< howl blocks after the change + std::vector ducked_frac; ///< share of the post-change time ducked or releasing + std::mutex mu; + + void add(const gd::run_trace& t) { + std::lock_guard lock(mu); + ++runs; + const auto c = static_cast(t.change); + const long duck = gd::first_state(t, guard_state::ducked, t.change); + if (duck >= 0) { + ++ducked; + duck_s.push_back(secs(duck - t.change)); + } + long first_rearm = -1; + for (size_t i = c; i < t.size() && first_rearm < 0; ++i) { + if (t.rearm[i] != 0) { + first_rearm = static_cast(i); + } + } + size_t ducked_blocks = 0; + size_t p = 0; + for (size_t i = c; i < t.size(); ++i) { + const bool entry = t.state[i] == guard_state::ducked && t.state[i - 1] != guard_state::ducked; + trip_ducks += (entry && t.trip[i] != 0) ? 1U : 0U; + lost_ducks += (entry && t.trip[i] == 0) ? 1U : 0U; + if (entry && t.trip[i] == 0 && first_rearm >= 0 && static_cast(i) > first_rearm) { + ++p; + } + ducked_blocks += (t.state[i] == guard_state::ducked || t.state[i] == guard_state::releasing) ? 1U : 0U; + } + pumps += p; + pump_runs += p > 0 ? 1U : 0U; + if (first_rearm >= 0) { + ++rearmed; + if (duck >= 0) { + rearm_s.push_back(secs(first_rearm - duck)); + } + } + howl_after += gd::howl_blocks(t, c); + ducked_frac.push_back(static_cast(ducked_blocks) / static_cast(t.size() - c)); + } + void print() const { + std::printf(" %-22s %3zu | %3zu %5.2f | %3zu %5.2f | %4zu %3zu | %3zu %3zu | %5zu | %5.2f\n", + label.c_str(), runs, ducked, med(duck_s), rearmed, med(rearm_s), pumps, pump_runs, lost_ducks, + trip_ducks, howl_after, med(ducked_frac)); + } + }; + + void print_rearm_header() { + std::printf(" %-22s %3s | %3s %5s | %3s %5s | %4s %3s | %3s %3s | %5s | %5s\n", "row", "n", "dck", "duck", + "rea", "rearm", "pump", "run", "lst", "trp", "howl", "dfrac"); + } + + // ---------------------------------------------------- two mics + + struct two_mic_stats { + std::string label; + size_t runs = 0; + size_t tripped = 0; ///< runs with a TRIP after the force + size_t mic1_only = 0; ///< ... whose first TRIP ducked mic 1 alone (the forced one) + size_t both = 0; ///< ... ducked both at once + size_t mic0_only = 0; ///< ... ducked the wrong mic alone + size_t fallbacks = 0; + size_t cascade = 0; ///< mic 0 ducks after the force by LOST (not a TRIP) + size_t cascade_runs = 0; + size_t howl = 0; ///< howl blocks after the force + std::vector longest_s; + std::mutex mu; + + void add(const gd::two_mic_trace& t) { + std::lock_guard lock(mu); + ++runs; + const auto f = static_cast(t.force); + for (size_t i = f; i < t.size(); ++i) { + if (t.trip[0][i] != 0 || t.trip[1][i] != 0) { + ++tripped; + mic1_only += (t.trip[1][i] != 0 && t.trip[0][i] == 0) ? 1U : 0U; + both += (t.trip[1][i] != 0 && t.trip[0][i] != 0) ? 1U : 0U; + mic0_only += (t.trip[0][i] != 0 && t.trip[1][i] == 0) ? 1U : 0U; + break; + } + } + fallbacks += t.fallbacks; + size_t c = 0; + for (size_t i = f; i < t.size(); ++i) { + const bool entry = t.state[0][i] == guard_state::ducked && t.state[0][i - 1] != guard_state::ducked; + c += (entry && t.trip[0][i] == 0) ? 1U : 0U; + } + cascade += c; + cascade_runs += c > 0 ? 1U : 0U; + size_t h = 0; + size_t cur = 0; + size_t best = 0; + for (size_t i = f; i < t.size(); ++i) { + const bool howling = t.e_rms[i] >= 100.0F; + h += howling ? 1U : 0U; + cur = howling ? cur + 1 : 0; + best = std::max(best, cur); + } + howl += h; + longest_s.push_back(secs(static_cast(best))); + } + void print() const { + std::printf(" %-20s %3zu | %3zu %3zu %3zu %3zu | %3zu | %3zu %3zu | %5zu %5.2f\n", label.c_str(), runs, + tripped, mic1_only, both, mic0_only, fallbacks, cascade, cascade_runs, howl, max_of(longest_s)); + } + }; + + // ---------------------------------------------------- audible cost + + struct cost_stats { + std::string label; + size_t runs = 0; + size_t opened = 0; + size_t ducks = 0; ///< DUCKED entries after the first OPEN + size_t on_burst = 0; ///< ... within 0.5 s of a block at +20 dB + size_t clean_runs = 0; ///< runs with a duck off any burst + std::vector ducked_s; ///< ducked or releasing after the first OPEN, s + size_t bursts = 0; ///< runs with a +20 dB block after the first OPEN + size_t howl = 0; + std::vector max_e_db; ///< loudest residual block after the first OPEN, dB re the unit near end + std::mutex mu; + + void add(const gd::run_trace& t) { + std::lock_guard lock(mu); + ++runs; + const long open = gd::first_state(t, guard_state::open); + if (open < 0) { + return; + } + ++opened; + const auto b = gd::bursts(t); + size_t cl = 0; + size_t d = 0; + bool burst = false; + for (auto i = static_cast(open) + 1; i < t.size(); ++i) { + const bool entry = t.state[i] == guard_state::ducked && t.state[i - 1] != guard_state::ducked; + if (entry) { + ++ducks; + if (b[i] != 0) { + ++on_burst; + } + else { + ++cl; + } + } + d += (t.state[i] == guard_state::ducked || t.state[i] == guard_state::releasing) ? 1U : 0U; + burst = burst || t.e_rms[i] >= 10.0F; + } + clean_runs += cl > 0 ? 1U : 0U; + ducked_s.push_back(secs(static_cast(d))); + float peak = 0.0F; + for (auto i = static_cast(open) + 1; i < t.size(); ++i) { + peak = std::max(peak, t.e_rms[i]); + } + max_e_db.push_back(20.0 * std::log10(static_cast(peak))); + bursts += burst ? 1U : 0U; + howl += gd::howl_blocks(t, static_cast(open)); + } + void print() const { + std::printf(" %-26s %3zu %3zu | %4zu %4zu %3zu | %6.2f %6.2f | %3zu | %4zu | %6.2f %6.2f\n", label.c_str(), + runs, opened, ducks, on_burst, clean_runs, med(ducked_s), max_of(ducked_s), bursts, howl, + med(max_e_db), max_of(max_e_db)); + } + }; + + void print_cost_header() { + std::printf(" %-26s %3s %3s | %4s %4s %3s | %6s %6s | %3s | %4s | %6s %6s\n", "row", "n", "opn", "duck", + "brst", "cln", "dk s", "max", "brs", "howl", "e dB", "max"); + } + + void print_two_mic_header() { + std::printf(" %-20s %3s | %3s %3s %3s %3s | %3s | %3s %3s | %5s %5s\n", "row", "n", "trp", "m1", "bth", "m0", + "fbk", "cas", "run", "howl", "long"); + } + + /// A gap between songs: the singer and the backing track at digital + /// zero from gap_from_s for gap_s. + struct gap_stats { + std::string label; + size_t runs = 0; + std::vector max_a_db; ///< A' peak in the gap, dB + size_t gap_ducks = 0; ///< DUCKED entries in the gap + size_t restarts = 0; ///< ARMING entries after the first OPEN + size_t open_at_end = 0; ///< runs OPEN when the singer returns + size_t howl = 0; ///< howl blocks after the gap + std::vector reentry_db; ///< loudest residual in the 1 s after the gap, dB re the unit near end + std::mutex mu; + + void add(const gd::run_trace& t, const gd::run_spec& s) { + std::lock_guard lock(mu); + ++runs; + const size_t g0 = gd::blocks_of(s.gap_from_s); + const size_t g1 = std::min(t.size(), gd::blocks_of(s.gap_from_s + s.gap_s)); + float amax = -1e9F; + for (size_t i = g0; i < g1; ++i) { + amax = std::max(amax, t.a_db[i]); + gap_ducks += (t.state[i] == guard_state::ducked && t.state[i - 1] != guard_state::ducked) ? 1U : 0U; + } + max_a_db.push_back(static_cast(amax)); + const long open = gd::first_state(t, guard_state::open); + if (open >= 0) { + restarts += gd::entries(t, guard_state::arming, static_cast(open)); + } + open_at_end += (g1 > 0 && t.state[g1 - 1] == guard_state::open) ? 1U : 0U; + howl += gd::howl_blocks(t, g1); + float peak = 0.0F; + for (size_t i = g1; i < std::min(t.size(), g1 + gd::blocks_of(1.0)); ++i) { + peak = std::max(peak, t.e_rms[i]); + } + reentry_db.push_back(20.0 * std::log10(static_cast(peak))); + } + void print() const { + std::printf(" %-20s %3zu | %7.2f %7.2f | %3zu %3zu %3zu | %4zu | %6.2f %6.2f\n", label.c_str(), runs, + med(max_a_db), max_of(max_a_db), gap_ducks, restarts, open_at_end, howl, med(reentry_db), + max_of(reentry_db)); + } + }; + + void print_gap_header() { + std::printf(" %-20s %3s | %7s %7s | %3s %3s %3s | %4s | %6s %6s\n", "row", "n", "A' max", "max", "dck", "rst", + "opn", "howl", "re dB", "max"); + } + + /// The vendored Dattorro (FAUST's re.dattorro_rev, paper defaults: T30 + /// 1.18 s) as a reverb-slot stage: out = in + wet x (L + R) / 2. + struct dattorro_stage { + explicit dattorro_stage(double wet_gain) + : rev(48000) + , wet(wet_gain) {} + mutap_faust::faust_block rev; + double wet; + std::vector l = std::vector(gd::k_block); + std::vector r = std::vector(gd::k_block); + std::vector x = std::vector(gd::k_block); + void process_block(const double* in, double* out, size_t n) noexcept { + for (size_t i = 0; i < n; ++i) { + x[i] = in[i]; + } + double* outs[2] = {l.data(), r.data()}; + rev.process_mono(x.data(), outs, static_cast(n)); + for (size_t i = 0; i < n; ++i) { + out[i] = in[i] + wet * 0.5 * (l[i] + r[i]); + } + } + }; + +} // namespace + +namespace { + + /// Median ns per call of f over `reps` calls after `warm` warm-up calls, + /// over 5 repetitions. + template + double ns_per_call(F f, size_t warm, size_t reps) { + for (size_t i = 0; i < warm; ++i) { + f(i); + } + std::vector runs; + size_t k = warm; + for (int r = 0; r < 5; ++r) { + const auto t0 = std::chrono::steady_clock::now(); + for (size_t i = 0; i < reps; ++i) { + f(k++); + } + const auto t1 = std::chrono::steady_clock::now(); + runs.push_back(std::chrono::duration(t1 - t0).count() / static_cast(reps)); + } + return median(runs); + } + + /// The guard's own cost per block (M mics: M analyses, one update, M + /// gain applications) and one canceller's process_block, on white noise + /// at unit RMS (no trip in the timed loop). + template + std::pair guard_and_canceller_ns(size_t mics) { + constexpr size_t k_blocks = 256; + std::vector x(k_blocks * gd::k_block); + const auto w = mutap_test::white_near_end(x.size(), 99); + for (size_t i = 0; i < x.size(); ++i) { + x[i] = static_cast(w[i]); + } + gd::guard_t g(gd::guard_config(mics, gd::k_s1)); + std::vector out(gd::k_block); + const double guard_ns = ns_per_call( + [&](size_t i) { + const Sample* e = &x[(i % k_blocks) * gd::k_block]; + for (size_t m = 0; m < mics; ++m) { + g.analyze(m, e, Sample(1e-3), Sample(0.5)); + } + g.update(); + for (size_t m = 0; m < mics; ++m) { + g.apply(m, e, out.data()); + } + }, + 2000, 20000); + gd::afc afc(gd::afc_config()); + std::vector e(gd::k_block); + const double afc_ns = ns_per_call( + [&](size_t i) { + const Sample* u = &x[(i % k_blocks) * gd::k_block]; + const Sample* y = &x[((i + 7) % k_blocks) * gd::k_block]; + afc.process_block(u, y, e.data()); + }, + 500, 2000); + return {guard_ns, afc_ns}; + } + +} // namespace + +// ============================================================ gated rows + +TEST(HowlGuardHost, ColdStartWithBackingTrack) { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = {k_v, k_m_aux, k_sp_aux}; + c.seeds = k_seeds3; + c.twins = {"voiced+aux"}; + print_cold_header(); + const auto rows = cold_rows(c); + for (const auto& r : rows) { + r.print(); + } + // Measured (cabin, mt5; seeds 1, 21, 41): 0 howl blocks in every row; + // 6 of 6 declared in every row, median time to OPEN 1.78 / 1.77 / + // 2.07 s (voiced+aux / music+aux / speech+aux); the unguarded voiced+aux + // twins: 0 howl blocks in 6. + for (const auto& r : rows) { + EXPECT_EQ(r.howl, 0U) << r.label; + EXPECT_EQ(r.t_open.size(), r.runs) << r.label; + EXPECT_LT(med(r.t_open), 3.0) << r.label; + } +} + +TEST(HowlGuardHost, ColdStartWithoutBackingTrack) { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = {k_h, k_m, k_sp}; + c.seeds = k_seeds3; + c.twins = {"speech"}; + print_cold_header(); + const auto rows = cold_rows(c); + for (const auto& r : rows) { + r.print(); + } + // Measured: 0 howl blocks in every row; no run declared in 20 s (the + // canceller at the arming gain sees no excitation without the backing + // track); every run left ARMING through the cap at arming_timeout_s + // (6 of 6 OPEN_CAPPED per row). The unguarded speech twins: 1 run of 6 + // reached the 40 dB rule (1 block). + for (const auto& r : rows) { + EXPECT_EQ(r.howl, 0U) << r.label; + EXPECT_EQ(r.never, 0U) << r.label; + EXPECT_EQ(r.capped, r.runs) << r.label; + } +} + +TEST(HowlGuardHost, ColdStartAboveTheDryLimit) { + print_cold_header(); + for (const double rel : {3.0, 6.0}) { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = {k_v, k_h}; + c.seeds = k_seeds3; + c.rel = rel; + c.twins = {"voiced+aux"}; + const auto rows = cold_rows(c); + for (const auto& r : rows) { + r.print(); + } + // Measured at +3 and +6: 0 howl blocks guarded (and 0 in the 6 + // unguarded voiced+aux twins at each gain); voiced+aux declared 6 of + // 6 (median 1.77 / 1.78 s); voiced left ARMING through the cap in 6 + // of 6. + for (const auto& r : rows) { + EXPECT_EQ(r.howl, 0U) << r.label; + EXPECT_EQ(r.never, 0U) << r.label; + } + EXPECT_EQ(rows[0].t_open.size(), rows[0].runs); + } +} + +TEST(HowlGuardHost, ColdStartAboveTheCancellerLimit) { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = {k_v, k_h}; + c.seeds = k_seeds3; + c.over_limit = 6.0; + c.twins = {"voiced+aux", "voiced"}; + print_cold_header(); + const auto rows = cold_rows(c); + for (const auto& r : rows) { + r.print(); + } + // Measured: guarded 0 howl blocks in both rows; the unguarded + // voiced+aux twins howled in 3 of 6 runs (575 blocks), the voiced twins + // in 0 of 6. + for (const auto& r : rows) { + EXPECT_EQ(r.howl, 0U) << r.label; + } + EXPECT_GE(rows[0].ungrd_howl_runs, 1U) << "the stress row must howl unguarded (measured 3 of 6)"; +} + +TEST(HowlGuardHost, ColdStartShiftedAndAtS3) { + print_cold_header(); + std::vector> sets; + for (const double hz : {2.0, 5.0}) { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = {k_v, k_h}; + c.seeds = k_seeds3; + c.shift_hz = hz; + sets.push_back(cold_rows(c)); + } + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = {k_v, k_h}; + c.seeds = k_seeds3; + c.delay = gd::k_s3; + sets.push_back(cold_rows(c)); + for (const auto& set : sets) { + for (const auto& r : set) { + r.print(); + } + } + // Measured: 0 howl blocks in every row; voiced+aux declared 6 of 6 + // (median 1.77 / 1.78 / 1.77 s at 2 Hz / 5 Hz / S3); voiced left ARMING + // through the cap in 6 of 6. + for (const auto& set : sets) { + for (const auto& r : set) { + EXPECT_EQ(r.howl, 0U) << r.label; + EXPECT_EQ(r.never, 0U) << r.label; + } + EXPECT_EQ(set[0].t_open.size(), set[0].runs) << set[0].label; + } +} + +TEST(HowlGuardHost, WalkReleasesAfterTheMisalignmentOracle) { + const std::vector> walks = {{"studio", "rehearsal"}, {"rehearsal", "hall"}, + {"hall", "cabin"}, {"cabin", "studio"}, + {"mt5", "mt105"}, {"mt9", "mt109"}}; + print_walk_header(); + std::printf(" at exact_msg_db - 6 dB (the release experiment's operating point)\n"); + const auto low = walk_rows(walks, {1, 21}, -6.0, 25.0, false); + for (const auto& r : low) { + r.print(); + } + walk_stats lo; + lo.label = "pooled"; + for (const auto& r : low) { + lo.merge(r); + } + lo.print(); + std::printf(" at the canceller's limit - 6 dB\n"); + const auto high = walk_rows(walks, {1, 21}, std::nan(""), 25.0, false); + for (const auto& r : high) { + r.print(); + } + walk_stats hi; + hi.label = "pooled"; + for (const auto& r : high) { + hi.merge(r); + } + hi.print(); + // Measured at exact_msg_db - 6: 3 of 12 walks ducked (all on LOST, + // 0.36 s after the change), 3 releases, 0 before the misalignment + // oracle reconverged; release (ramp start) - reconvergence median + // 1.59 s, minimum 1.38 s. At the canceller's limit - 6: 0 of 12 walks + // ducked (D's median over the 2 s before the walk -8.46 dB; the verdict + // lost for at most 0.22 s after it, under the 0.3 s trip hold; at + // exact_msg_db - 6, -7.91 dB and at most 0.47 s). 0 howl blocks after + // any walk at either point. + ASSERT_GE(lo.delta_s.size(), 1U) << "the walk gate needs at least one release"; + EXPECT_GT(med(lo.delta_s), 0.5); + EXPECT_EQ(lo.howl_post, 0U); + EXPECT_EQ(hi.howl_post, 0U); +} + +TEST(HowlGuardHost, LateHowlAfterAWalkIsCaught) { + print_walk_header(); + std::vector ug6; + std::vector ugl; + const auto dry6 = walk_rows({{"rehearsal", "hall"}}, k_seeds3, 6.0, 25.0, true, &ug6); + std::printf(" exact_msg_db + 6 dB\n"); + dry6[0].print(); + ug6[0].print(); + const double over = asg_db("rehearsal", k_v, gd::k_s1) + 6.0; + const auto lim6 = walk_rows({{"rehearsal", "hall"}}, k_seeds3, over, 25.0, true, &ugl); + std::printf(" the canceller's limit + 6 dB (exact_msg_db %+.2f dB)\n", over); + lim6[0].print(); + ugl[0].print(); + // Measured: at exact_msg_db + 6, 0 howl blocks guarded or unguarded + // (this loop's canceller holds +6); at the limit + 6, guarded 0 howl + // blocks (5 strikes over 3 runs, no latch), unguarded 1203 blocks, the + // longest howl 0.55 s. + EXPECT_EQ(dry6[0].howl_post, 0U); + EXPECT_EQ(lim6[0].howl_post, 0U); + EXPECT_GE(ugl[0].howl_post, 1U) << "the stress row must howl unguarded (measured 1203 blocks)"; +} + +TEST(HowlGuardHost, LouderCouplingRearms) { + std::vector rows(4); + const char* rooms[] = {"cabin", "mt5", "studio", "hall"}; + std::vector> jobs; + for (size_t r = 0; r < 4; ++r) { + rows[r].label = std::string(rooms[r]) + " x2"; + for (const unsigned seed : {1U, 21U}) { + gd::run_spec s; + s.room = rooms[r]; + s.scale2 = 2.0; + s.t_change_s = 10.0; + s.mat = k_v; + s.seed = seed; + s.gain_db = operating_gain(s.room, k_v, gd::k_s1); + s.cap_db = cap_for(exact_db(s.room, gd::k_s1), s.gain_db); + s.seconds = 30.0; + s.oracle = false; + jobs.emplace_back([s, &st = rows[r]] { st.add(gd::live_run(s)); }); + } + } + run_parallel(jobs); + print_rearm_header(); + for (const auto& r : rows) { + r.print(); + } + // Measured: every run ducked after F -> 2F (2 of 2 per room; LOST 0.37 + // to 0.39 s after the change, cabin 4.65 s); the timer re-arm at 5.00 s + // (10.01 s after a strike); 0 howl blocks after the change; the post- + // change time ducked or releasing, median 0.56 / 0.96 / 0.94 / 0.93. + // The edge rule does NOT stop the duck / open cycle: the verdict reads + // ok again after the re-arm, which re-arms LOST: 16 LOST-ducks after a + // re-arm in 6 of 8 runs (docs/howl-guard.md). Printed, not gated. + for (const auto& r : rows) { + EXPECT_EQ(r.ducked, r.runs) << r.label; + EXPECT_EQ(r.howl_after, 0U) << r.label; + EXPECT_GT(med(r.ducked_frac), 0.5) << r.label; + } +} + +TEST(HowlGuardHost, SongGapDoesNotRestart) { + std::vector rows(2); + std::vector> jobs; + const char* rooms[] = {"cabin", "mt5"}; + for (size_t r = 0; r < 2; ++r) { + rows[r].label = std::string(rooms[r]) + " 20 s gap"; + for (const unsigned seed : k_seeds3) { + gd::run_spec s; + s.room = rooms[r]; + s.mat = k_v; + s.seed = seed; + s.gain_db = operating_gain(s.room, k_v, gd::k_s1); + s.cap_db = cap_for(exact_db(s.room, gd::k_s1), s.gain_db); + s.seconds = 34.0; + s.gap_from_s = 8.0; + s.gap_s = 20.0; + s.oracle = false; + jobs.emplace_back([s, &st = rows[r]] { st.add(gd::live_run(s), s); }); + } + } + run_parallel(jobs); + print_gap_header(); + for (const auto& r : rows) { + r.print(); + } + // Measured: A' rose to -15.21 / -15.30 dB (median peak; max -14.96) in + // the gap - far under restart_a_db (-1), so 0 restarts; LOST ducked in + // the gap in 3 of 3 runs per room and the timer re-arm opened again + // before the singer returned (3 of 3 OPEN); 0 howl blocks after the + // gap; the loudest residual block in the 1 s after it +3.91 dB. + for (const auto& r : rows) { + EXPECT_EQ(r.restarts, 0U) << r.label; + EXPECT_LT(max_of(r.max_a_db), -5.0) << r.label; + EXPECT_EQ(r.howl, 0U) << r.label; + } +} + +TEST(HowlGuardHost, TwoMicsAttributeASingleMicHowl) { + std::vector rows(2 * std::size(k_ops2)); + std::vector> jobs; + size_t k = 0; + for (const auto& op : k_ops2) { + for (const bool aux : {false, true}) { + rows[k].label = std::string(op.pair) + (aux ? " +aux" : ""); + for (const unsigned seed : {1U, 21U}) { + gd::two_mic_spec s; + s.pair = op.pair; + s.aux = aux; + s.seed = seed; + const double ex = mutap_test::exact_msg_db( + mutap_test::two_mic::path_sum(mutap_test::two_mic::room_pair(op.pair)), gd::k_s1); + s.gain_db = ex + (aux ? op.aux : op.plain) - 6.0; + s.cap_db = cap_for(ex, s.gain_db); + jobs.emplace_back([s, &st = rows[k]] { st.add(gd::two_mic_run(s)); }); + } + ++k; + } + } + run_parallel(jobs); + print_two_mic_header(); + for (const auto& r : rows) { + r.print(); + } + // Measured, two hosts (the loop is chaotic: single runs move between + // platforms, so the gates are counts with margin, not zeros): + // macOS x86_64 (Intel): every run tripped after the force; the first + // TRIP ducked mic 1 (the forced one) alone in 7 of 8, both in 1, mic 0 + // alone in 0 of 8; 0 fallbacks; 0 cascades; 1 howl block in total, + // the longest howl one block. The MUTAP_SLOW sweep (20 runs): mic 0 + // alone in 1 of 20, 3 howl blocks. + // macOS arm64 (CI run 36828198214): mic 1 alone 7 of 8, mic 0 alone + // 1 of 8 (cabin, no aux); 0 fallbacks; 3 howl blocks, the longest one + // block. + // The claim that matters is that the forced howl is stopped: the + // longest run of howl blocks stays a block or two (gate 0.1 s, 75 + // blocks) and the total stays small (gate 24 over 8 runs). The wrong mic + // alone is gated at a margin over the sweep's rate (1 of 20): <= 2 of 8. + size_t tripped = 0; + size_t mic1 = 0; + size_t wrong = 0; + size_t howl = 0; + double longest_s = 0.0; + for (const auto& r : rows) { + EXPECT_EQ(r.tripped, r.runs) << r.label; + tripped += r.tripped; + mic1 += r.mic1_only; + wrong += r.mic0_only; + howl += r.howl; + longest_s = std::max(longest_s, max_of(r.longest_s)); + } + std::printf("two mics: forced mic alone %zu, wrong mic alone %zu of %zu; howl blocks %zu, longest %.4f s\n", mic1, + wrong, tripped, howl, longest_s); + EXPECT_LE(wrong, 2U) << "measured 0 of 8 (Intel), 1 of 8 (arm64); sweep 1 of 20"; + EXPECT_GT(2 * mic1, tripped) + << "attribution picks the forced mic alone in most runs (measured 7 of 8 on both hosts)"; + EXPECT_LE(howl, 24U) << "measured 1 (Intel), 3 (arm64)"; + EXPECT_LT(longest_s, 0.1) << "measured one block on both hosts"; +} + +TEST(HowlGuardHost, AudibleCostOnStableMaterial) { + const std::vector mats = {k_v, k_sp_aux}; + std::vector rows(mats.size() * 3); + std::vector> jobs; + size_t k = 0; + for (const auto& m : mats) { + for (const double hz : {0.0, 2.0, 5.0}) { + rows[k].label = gd::material_name(m) + " " + std::to_string(static_cast(hz)) + " Hz"; + for (const char* room : {"cabin", "mt5"}) { + for (const unsigned seed : {1U, 21U}) { + gd::run_spec s; + s.room = room; + s.mat = m; + s.seed = seed; + s.gain_db = operating_gain(room, m, gd::k_s1); + s.cap_db = cap_for(exact_db(room, gd::k_s1), s.gain_db); + s.shift_hz = hz; + s.seconds = 30.0; + s.oracle = false; + jobs.emplace_back([s, &st = rows[k]] { st.add(gd::live_run(s)); }); + } + } + ++k; + } + } + run_parallel(jobs); + print_cost_header(); + for (const auto& r : rows) { + r.print(); + } + // Measured (cabin, mt5; seeds 1, 21; 30 s): every run declared, 0 ducks + // after the first OPEN in all 24 runs, 0 blocks at +20 dB; the loudest + // residual block after OPEN +8.82 / +7.54 / +9.42 dB (voiced+aux, 0 / 2 / + // 5 Hz) and +19.56 / +15.56 / +18.12 dB (speech+aux), against the + // harness's +30 dB ceiling. + for (const auto& r : rows) { + EXPECT_EQ(r.opened, r.runs) << r.label; + EXPECT_EQ(r.ducks, 0U) << r.label; + EXPECT_LT(max_of(r.max_e_db), 25.0) << r.label; + } +} + +TEST(HowlGuardHost, BusStageCutsTheReverbRing) { + double voice_at[2][6] = {}; + const double offsets[6] = {0.05, 0.1, 0.25, 0.5, 1.0, 1.5}; + double trip_after[2] = {-1.0, -1.0}; + for (const bool bus : {true, false}) { + dattorro_stage rev(0.5); + gd::run_spec s; + s.room = "cabin"; + s.mat = k_v; + s.seed = 1; + s.gain_db = operating_gain("cabin", k_v, gd::k_s1); + s.cap_db = cap_for(exact_db("cabin", gd::k_s1), s.gain_db); + s.seconds = 14.0; + s.oracle = false; + s.reverb = &rev; + s.bus_stage = bus; + s.t_step_s = 10.0; + s.step_db = 12.0; + const auto t = gd::live_run(s); + const auto step = static_cast(gd::blocks_of(10.0)); + long trip = -1; + for (auto i = static_cast(step); i < t.size() && trip < 0; ++i) { + if (t.trip[i] != 0) { + trip = static_cast(i); + } + } + const int b = bus ? 0 : 1; + std::printf("bus stage %s: first OPEN %.3f s; howl blocks before the step %zu; first TRIP %.3f s after the " + "+12 dB step\n", + bus ? "on " : "off", secs(gd::first_state(t, guard_state::open)), + gd::howl_blocks(t, 0, static_cast(step)), trip >= 0 ? secs(trip - step) : -1.0); + ASSERT_GE(trip, 0); + trip_after[b] = secs(trip - step); + const double at = t.voice_rms[static_cast(trip)]; + for (size_t k = 0; k < 6; ++k) { + const auto i = static_cast(trip) + gd::blocks_of(offsets[k]); + voice_at[b][k] = 20.0 * std::log10(t.voice_rms[i] / at); + std::printf(" +%.2f s: voice %.2f dB re the trip block, %s\n", offsets[k], voice_at[b][k], + gd::state_name(t.state[i])); + } + } + // Measured (cabin, voiced+aux, Dattorro wet 0.5, +12 dB at 10 s): the + // first TRIP 0.115 s after the step with and without the bus stage; the + // voice 0.5 s after it -66.53 dB re the trip block with the bus stage, + // -47.13 without (19.40 dB apart); 1 s after it -81.39 and -66.25. + EXPECT_LT(trip_after[0], 0.5); + EXPECT_LT(voice_at[0][3], voice_at[1][3] - 10.0); + EXPECT_LT(voice_at[0][4], voice_at[1][4] - 10.0); +} + +TEST(HowlGuardHost, CostPerBlock) { + for (const size_t mics : {size_t{1}, size_t{2}}) { + const auto [gf, af] = guard_and_canceller_ns(mics); + const auto [gdb, ad] = guard_and_canceller_ns(mics); + std::printf("guard, %zu mic(s): float %.0f ns per block (%.2f %% of one canceller's %.0f ns), " + "double %.0f ns (%.2f %% of %.0f ns)\n", + mics, gf, 100.0 * gf / af, af, gdb, 100.0 * gdb / ad, ad); + // Measured (1 / 2 mics): float 2246 / 3962 ns, 0.60 / 1.02 % of a + // canceller block; double 3180 / 6311 ns, 0.88 / 1.59 %. Timing on a + // shared machine: the bound has 3x margin over the 2-mic double row. + EXPECT_LT(gf / af, 0.05 * static_cast(mics) / 2.0 + 0.02); + EXPECT_LT(gdb / ad, 0.05 * static_cast(mics) / 2.0 + 0.02); + RecordProperty("guard_ns_float_m" + std::to_string(mics), std::to_string(gf)); + RecordProperty("guard_ns_double_m" + std::to_string(mics), std::to_string(gdb)); + } +} + +// ============================================================ the sweep + +TEST(HowlGuardSweep, OperatingPoints) { + if (!slow_enabled()) { + GTEST_SKIP() << "set MUTAP_SLOW=1 for the guard sweep"; + } + struct job { + std::string room; + size_t mat; + size_t delay; + unsigned seed; + double asg = 0.0; + }; + const std::vector names = {"voiced+aux", "voiced", "music", "music+aux", "speech", "speech+aux"}; + std::vector jobs; + for (const char* room : {"cabin", "mt5", "studio", "rehearsal", "hall", "mt9"}) { + for (size_t m = 0; m < k_cold_mats.size(); ++m) { + for (const unsigned seed : k_seeds5) { + jobs.push_back({room, m, gd::k_s1, seed}); + } + } + } + for (const char* room : {"cabin", "mt5"}) { + for (size_t m = 0; m < k_cold_mats.size(); ++m) { + for (const unsigned seed : k_seeds5) { + jobs.push_back({room, m, gd::k_s3, seed}); + } + } + } + struct job2 { + std::string pair; + bool aux; + unsigned seed; + double asg = 0.0; + }; + std::vector jobs2; + for (const auto& op : k_ops2) { + for (const bool aux : {false, true}) { + for (const unsigned seed : k_seeds5) { + jobs2.push_back({op.pair, aux, seed}); + } + } + } + std::vector> fns; + for (auto& j : jobs) { + fns.emplace_back( + [&j] { j.asg = gd::limit_db(j.room, k_cold_mats[j.mat], j.delay, j.seed) - exact_db(j.room, j.delay); }); + } + for (auto& j : jobs2) { + fns.emplace_back([&j] { + const double ex = mutap_test::exact_msg_db( + mutap_test::two_mic::path_sum(mutap_test::two_mic::room_pair(j.pair)), gd::k_s1); + j.asg = gd::limit2_db(j.pair, j.aux, gd::k_s1, j.seed) - ex; + }); + } + run_parallel(fns); + std::printf("operating points: converged-canceller limit over exact_msg_db, dB, per seed and the median\n"); + for (const char* room : {"cabin", "mt5", "studio", "rehearsal", "hall", "mt9"}) { + for (size_t m = 0; m < k_cold_mats.size(); ++m) { + std::vector s1; + std::vector s3; + for (const auto& j : jobs) { + if (j.room == room && j.mat == m) { + (j.delay == gd::k_s1 ? s1 : s3).push_back(j.asg); + } + } + char s3_text[32] = "k_nan"; + if (!s3.empty()) { + std::snprintf(s3_text, sizeof(s3_text), "%.2f", med(s3)); + } + std::printf(" {\"%s\", \"%s\", %.2f, %s}, // S1", room, gd::material_name(k_cold_mats[m]).c_str(), + med(s1), s3_text); + for (const double v : s1) { + std::printf(" %+.2f", v); + } + if (!s3.empty()) { + std::printf("; S3"); + for (const double v : s3) { + std::printf(" %+.2f", v); + } + } + std::printf("\n"); + } + } + for (const auto& op : k_ops2) { + std::vector plain; + std::vector aux; + for (const auto& j : jobs2) { + if (j.pair == op.pair) { + (j.aux ? aux : plain).push_back(j.asg); + } + } + std::printf(" {\"%s\", %.2f, %.2f}, // plain", op.pair, med(plain), med(aux)); + for (const double v : plain) { + std::printf(" %+.2f", v); + } + std::printf("; aux"); + for (const double v : aux) { + std::printf(" %+.2f", v); + } + std::printf("\n"); + } +} + +TEST(HowlGuardSweep, ColdStart) { + if (!slow_enabled()) { + GTEST_SKIP() << "set MUTAP_SLOW=1 for the guard sweep"; + } + const std::vector rooms6 = {"cabin", "mt5", "studio", "rehearsal", "hall", "mt9"}; + print_cold_header(); + { + cold_spec c; + c.rooms = rooms6; + c.mats = k_cold_mats; + c.seeds = k_seeds5; + c.twins = {"voiced+aux", "speech"}; + for (const auto& r : cold_rows(c)) { + r.print(); + } + } + { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = k_cold_mats; + c.seeds = k_seeds5; + c.delay = gd::k_s3; + for (const auto& r : cold_rows(c)) { + r.print(); + } + } + for (const double hz : {2.0, 5.0}) { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = k_cold_mats; + c.seeds = k_seeds5; + c.shift_hz = hz; + for (const auto& r : cold_rows(c)) { + r.print(); + } + } + for (const double rel : {3.0, 6.0}) { + cold_spec c; + c.rooms = {"cabin", "mt5", "rehearsal", "hall"}; + c.mats = k_cold_mats; + c.seeds = k_seeds5; + c.rel = rel; + c.twins = {"voiced+aux", "speech"}; + for (const auto& r : cold_rows(c)) { + r.print(); + } + } + { + cold_spec c; + c.rooms = {"cabin", "mt5"}; + c.mats = {k_h, k_m}; + c.seeds = k_seeds5; + c.cap = false; + std::printf(" (no cap)\n"); + for (const auto& r : cold_rows(c)) { + r.print(); + } + } +} + +TEST(HowlGuardSweep, WalksAndLouderCoupling) { + if (!slow_enabled()) { + GTEST_SKIP() << "set MUTAP_SLOW=1 for the guard sweep"; + } + const std::vector> walks = {{"studio", "rehearsal"}, {"rehearsal", "hall"}, + {"hall", "cabin"}, {"cabin", "studio"}, + {"mt5", "mt105"}, {"mt9", "mt109"}}; + print_walk_header(); + for (const double rel : {-6.0, std::nan("")}) { + std::printf(std::isnan(rel) ? " at the canceller's limit - 6 dB\n" : " at exact_msg_db - 6 dB\n"); + walk_stats pool; + pool.label = "pooled"; + for (const auto& r : walk_rows(walks, k_seeds5, rel, 25.0, false)) { + r.print(); + pool.merge(r); + } + pool.print(); + } + std::vector ug; + const auto hot = walk_rows({{"rehearsal", "hall"}, {"hall", "cabin"}}, k_seeds5, 6.0, 25.0, true, &ug); + for (size_t i = 0; i < hot.size(); ++i) { + hot[i].print(); + ug[i].print(); + } + std::vector rows(6); + const char* rooms[] = {"cabin", "mt5", "studio", "rehearsal", "hall", "mt9"}; + std::vector> jobs; + for (size_t r = 0; r < 6; ++r) { + rows[r].label = std::string(rooms[r]) + " x2"; + for (const unsigned seed : k_seeds5) { + gd::run_spec s; + s.room = rooms[r]; + s.scale2 = 2.0; + s.t_change_s = 10.0; + s.mat = k_v; + s.seed = seed; + s.gain_db = operating_gain(s.room, k_v, gd::k_s1); + s.cap_db = cap_for(exact_db(s.room, gd::k_s1), s.gain_db); + s.seconds = 30.0; + s.oracle = false; + jobs.emplace_back([s, &st = rows[r]] { st.add(gd::live_run(s)); }); + } + } + run_parallel(jobs); + print_rearm_header(); + for (const auto& r : rows) { + r.print(); + } +} + +TEST(HowlGuardSweep, TwoMicsAndAudibleCost) { + if (!slow_enabled()) { + GTEST_SKIP() << "set MUTAP_SLOW=1 for the guard sweep"; + } + { + std::vector rows(2 * std::size(k_ops2)); + std::vector> jobs; + size_t k = 0; + for (const auto& op : k_ops2) { + for (const bool aux : {false, true}) { + rows[k].label = std::string(op.pair) + (aux ? " +aux" : ""); + for (const unsigned seed : k_seeds5) { + gd::two_mic_spec s; + s.pair = op.pair; + s.aux = aux; + s.seed = seed; + const double ex = mutap_test::exact_msg_db( + mutap_test::two_mic::path_sum(mutap_test::two_mic::room_pair(op.pair)), gd::k_s1); + s.gain_db = ex + (aux ? op.aux : op.plain) - 6.0; + s.cap_db = cap_for(ex, s.gain_db); + jobs.emplace_back([s, &st = rows[k]] { st.add(gd::two_mic_run(s)); }); + } + ++k; + } + } + run_parallel(jobs); + print_two_mic_header(); + for (const auto& r : rows) { + r.print(); + } + } + const std::vector mats = {k_v, k_sp_aux}; + std::vector rows(mats.size() * 3); + std::vector> jobs; + size_t k = 0; + for (const auto& m : mats) { + for (const double hz : {0.0, 2.0, 5.0}) { + rows[k].label = gd::material_name(m) + " " + std::to_string(static_cast(hz)) + " Hz"; + for (const char* room : {"cabin", "mt5", "studio", "rehearsal", "hall", "mt9"}) { + for (const unsigned seed : k_seeds5) { + gd::run_spec s; + s.room = room; + s.mat = m; + s.seed = seed; + s.gain_db = operating_gain(room, m, gd::k_s1); + s.cap_db = cap_for(exact_db(room, gd::k_s1), s.gain_db); + s.shift_hz = hz; + s.seconds = 30.0; + s.oracle = false; + jobs.emplace_back([s, &st = rows[k]] { st.add(gd::live_run(s)); }); + } + } + ++k; + } + } + run_parallel(jobs); + print_cost_header(); + for (const auto& r : rows) { + r.print(); + } +}