Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,10 @@ jobs:
# Cortex-M33 (Raspberry Pi Pico 2 W / RP2350 class: single-precision FPU,
# no FP64, no MVE) on QEMU's MPS2+ AN505 model — the wake-word plan's named
# embedded target. Same Armv8-M startup as the M55 leg, Ooura float32 FFT
# (no Helium here), the M55's float-profile selection minus the long float
# PEM scenarios (their test harness simulates the room in double: soft-float
# here, ~17 min for one of them — see tests/bare_metal_main.cpp). Ported
# from RatioTap's cortex-m33-qemu job.
# (no Helium here), the same float-profile selection as the M55 leg (about
# 3 minutes of emulation once the speech predictor's pitch search stopped
# accumulating in double — see tests/bare_metal_main.cpp). Ported from
# RatioTap's cortex-m33-qemu job.
cortex-m33-qemu:
name: Cortex-M33 cross (QEMU)
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,6 @@ Resolved since rev 1: ~~license~~ (MIT), ~~core language~~ (header-only C++20),
Resolved since rev 3: ~~Max external naming~~ — settled (Rev 4): **`mutap.afc~`** (rename from the `mutap.defeed~` placeholder) and **`mutap.aec~`** for the new echo canceller, an acronym pair matching the literature. The rename executes in Stage 2 of "The next effort" above.

Still open:
- **Wake-word detection — in progress (M0–M2 done).** A background briefing, a staged implementation proposal (rev 2) and the adversarial audit that produced it landed in [`docs/wake-word-briefing.md`](docs/wake-word-briefing.md), [`docs/wake-word-plan.md`](docs/wake-word-plan.md) and [`docs/wake-word-audit.md`](docs/wake-word-audit.md). The proposal reuses the `nn_suppressor` / `tools/ml` patterns rather than importing a runtime; the audit found those patterns sound but the learned path's *oracles* missing (the suppressor is double-only everywhere, never on-target, never instruction-counted, parity not in CI), so rev 2 adds an M2 that builds them before the kernels are promoted — a milestone worth doing whether or not the spotter ships. The named M33 target is the Raspberry Pi Pico 2 W (RP2350: single-precision FPU, so float32 is its profile; the M33 QEMU rig is ported from RatioTap in M2, and M7 adds a `pico2w` CI job that builds the board example against a pinned Pico SDK, uploads the UF2 and asserts its footprint — detection itself is a bench step with a loudspeaker-playback protocol and committed numbers, since QEMU has no RP2350 model), and the docs plan includes a user guide to training a phrase whose commands CI runs on a toy corpus. **M0 is decided (4 September 2026):** repository MuTap, charter widened; host rate fixed at 16 kHz in `kws.h` with conversion as an `@resample` option on the Max external, backed by a new DspTap `decimate.h` (2/3/6) and composed with RatioTap for 44.1 kHz, since `poly~` is powers-of-two only and neither RatioTap nor SampleRateTap covers 48 → 16; release shape runtime-first, no bundled phrase, the training guide as the primary document; development phrase `marvin` from Speech Commands for M5 bring-up, a synthesized four-syllable phrase from M4, never shipped; TTS voices lineage-verified from the Piper model cards — `en_US-libritts-high` (from scratch, CC BY 4.0, 904 speakers), `en_US-kristin-medium`, `en_GB-cori-high` (from scratch, public domain), `en_US-john-medium` (from Kristin), with every Lessac-derived voice (most of the English set, `libritts_r` and `vctk` included) and the sample generator's bundled `.pt` generator excluded; training on an Apple Silicon Mac via `--device mps`, under an hour per run on the development set. **M1 is done** on DspTap's `claude/mutap-wake-word-plan-2i63pe`: `log_mel.h` and `decimate.h` with their typed batteries, the numpy reference generator (`tools/reference/make_frontend_reference.py` — the family's single numpy copy of the formulas), C ABI and bridge; every tolerance in the plan's M1 record is a measured number. **M2 is done** on MuTap's `claude/mutap-wake-word-plan-2i63pe`: the learned suppressor has its oracles — typed float/double tests with a −120 dB cross-precision pin, a float32 chain gate, a Python↔C++ parity CI job in both profiles on random and exported weights, a Cortex-M33 QEMU leg (mps2-an505, Ooura float32 FFT) with the float suppressor suite on-target on every leg, and `nn_suppressor` icount scenarios with baselines on m55, m33 and hexagon. The oracles found and fixed a Nyquist-bin contract defect at 48 kHz (C++ notched bin N/2, numpy did not; fixed on both sides). Next: M3, promoting the dense/GRU kernels into DspTap's `tap::dsp::nn`.
- **Wake-word detection — in progress (M0–M3 done).** A background briefing, a staged implementation proposal (rev 2) and the adversarial audit that produced it landed in [`docs/wake-word-briefing.md`](docs/wake-word-briefing.md), [`docs/wake-word-plan.md`](docs/wake-word-plan.md) and [`docs/wake-word-audit.md`](docs/wake-word-audit.md). The proposal reuses the `nn_suppressor` / `tools/ml` patterns rather than importing a runtime; the audit found those patterns sound but the learned path's *oracles* missing (the suppressor is double-only everywhere, never on-target, never instruction-counted, parity not in CI), so rev 2 adds an M2 that builds them before the kernels are promoted — a milestone worth doing whether or not the spotter ships. The named M33 target is the Raspberry Pi Pico 2 W (RP2350: single-precision FPU, so float32 is its profile; the M33 QEMU rig is ported from RatioTap in M2, and M7 adds a `pico2w` CI job that builds the board example against a pinned Pico SDK, uploads the UF2 and asserts its footprint — detection itself is a bench step with a loudspeaker-playback protocol and committed numbers, since QEMU has no RP2350 model), and the docs plan includes a user guide to training a phrase whose commands CI runs on a toy corpus. **M0 is decided (4 September 2026):** repository MuTap, charter widened; host rate fixed at 16 kHz in `kws.h` with conversion as an `@resample` option on the Max external, backed by a new DspTap `decimate.h` (2/3/6) and composed with RatioTap for 44.1 kHz, since `poly~` is powers-of-two only and neither RatioTap nor SampleRateTap covers 48 → 16; release shape runtime-first, no bundled phrase, the training guide as the primary document; development phrase `marvin` from Speech Commands for M5 bring-up, a synthesized four-syllable phrase from M4, never shipped; TTS voices lineage-verified from the Piper model cards — `en_US-libritts-high` (from scratch, CC BY 4.0, 904 speakers), `en_US-kristin-medium`, `en_GB-cori-high` (from scratch, public domain), `en_US-john-medium` (from Kristin), with every Lessac-derived voice (most of the English set, `libritts_r` and `vctk` included) and the sample generator's bundled `.pt` generator excluded; training on an Apple Silicon Mac via `--device mps`, under an hour per run on the development set. **M1 is done** on DspTap's `claude/mutap-wake-word-plan-2i63pe`: `log_mel.h` and `decimate.h` with their typed batteries, the numpy reference generator (`tools/reference/make_frontend_reference.py` — the family's single numpy copy of the formulas), C ABI and bridge; every tolerance in the plan's M1 record is a measured number. **M2 is done** on MuTap's `claude/mutap-wake-word-plan-2i63pe`: the learned suppressor has its oracles — typed float/double tests with a −120 dB cross-precision pin, a float32 chain gate, a Python↔C++ parity CI job in both profiles on random and exported weights, a Cortex-M33 QEMU leg (mps2-an505, Ooura float32 FFT) with the float suppressor suite on-target on every leg, and `nn_suppressor` icount scenarios with baselines on m55, m33 and hexagon. The oracles found and fixed a Nyquist-bin contract defect at 48 kHz (C++ notched bin N/2, numpy did not; fixed on both sides). **M3 is done**: the dense/GRU kernels are DspTap's `tap::dsp::nn` (tap/DspTap#15) and `nn_suppressor` consumes them, bit-identically in both profiles on the shipping v2 model, with the M2 battery, the six parity cases and the ratchet unchanged; MuTap's DspTap pin points at the M3 tree (repoint at `main` after the DspTap PR merges) and MuTap-Max's pin follows once this merges. Alongside it, the M33 finding's root cause was fixed in the library: the speech predictor's pitch search now accumulates in `Sample` (the double profile is bit-for-bit unchanged, the float rows unchanged to the MSG bisection's quantum), the tonal PEM headline dropped from 1085 s to 31 s on the M33 emulator, and the M33 leg runs the full float selection (174 s) — the M2 exclusion is gone. Next: M4, the corpus and dataset builder, on the Apple Silicon Mac.
- **Default engine in the external** — `@kalman` off (classic NLMS) is the shipping default purely on seniority; the measured case for flipping it is in `tests/test_fd_kalman.cpp` and book chapter 1. Decide after real-room listening.
- **RIR fixtures, the measured half** — the fixture pipeline is built and three physically-modeled rooms (image-source, documented geometry) are committed baselines with regression tests. What remains yours: which MEASURED rooms join them — an academic dataset room (MYRiAD is the PEM-AFROW group's own database; openAIR is the other usual source; check each room's license allows redistribution in an MIT repo) and/or your own swept-sine measurements. Either way it is one command per room: `python3 tools/fixtures/make_rir_fixtures.py --from-wav room.wav myroom --source "<provenance + license>"`, then a test with a freshly measured threshold. (The dataset hosts are unreachable from the remote dev container's network policy, so the WAVs have to enter via a commit.)
4 changes: 0 additions & 4 deletions cmake/arm-cortex-m33-mps2.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,3 @@ set(TAP_DSP_FFT_CMSIS OFF CACHE BOOL "No MVE on the Cortex-M33: Ooura float32 FF

# One-shot CTest mode (no argv on bare metal; see tests/CMakeLists.txt).
set(MUTAP_BARE_METAL ON)
# Single-precision FPU only: the on-target selection drops the long float
# PEM scenarios, whose test harness simulates the room in double (soft-float
# here; ~17 min for one of them under QEMU). See tests/bare_metal_main.cpp.
set(MUTAP_ON_TARGET_SOFT_FP64 ON)
56 changes: 42 additions & 14 deletions docs/wake-word-plan.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Building `mutap.wake~` — implementation proposal

*Proposal, rev 2 — 4 September 2026; **in progress**: M0 decided, M1 done
(DspTap) and M2 done (MuTap), each with a dated record of measured numbers
under its milestone in §6. Background and
(DspTap), M2 done (MuTap) and M3 done (DspTap · MuTap), each with a dated
record of measured numbers under its milestone in §6. Background and
corpus survey in [`wake-word-briefing.md`](wake-word-briefing.md). Rev 2
carries every amendment from the [adversarial audit](wake-word-audit.md) of
rev 1; the audit refers to rev 1's milestone numbers, and the mapping is given
Expand Down Expand Up @@ -389,18 +389,25 @@ nothing in the float profile touches double. The M33 leg is ported from RatioTap
(`cmake/arm-cortex-m33-mps2.cmake`, `platform/mps2_an505.ld`, the shared
`armv8m_startup.c`, a `cortex-m33-qemu` CI job with the Ooura float32 FFT
pinned since there is no MVE) and the on-target filter on every leg carries the
float `nn_suppressor` suite, the cross-precision pin and the chain test. One
honest limit of the M33 leg, found by running it: the long float PEM
scenarios are driven by a test harness that simulates the room in double on
purpose (the closed-loop convolution and the MSG bisection), and the speech
predictor's pitch search accumulates in double — hardware on the M55,
software on the M33 — so the tonal PEM headline alone took 1030 s under qemu
mps2-an505 against 84 s on mps3-an547. The M33 selection
(`MUTAP_ON_TARGET_SOFT_FP64`) drops those four scenarios, which the M55 and
Hexagon legs and every host still run. That is also the first concrete cost
figure for "double on the RP2350" in this plan, and the reason §5's rule that
nothing on the wake-word path touches double is a budget rule, not a style
rule. Layer 4
float `nn_suppressor` suite, the cross-precision pin and the chain test. The
M33 leg also produced the first concrete cost figure for "double on the
RP2350" in this plan: the tonal PEM headline took 1085 s under qemu
mps2-an505 against 84 s on mps3-an547 and the selection timed out, so M2
first shipped with the four long float PEM scenarios excluded there. An
experiment then separated the two suspects — with the test harness's
deliberate double room simulation switched to float the scenario still took
772 s, so the harness was a third of the cost and the library two thirds: the
speech predictor's pitch search accumulated its normalized correlation in
double for every lag from 32 to 400 over a 1024-sample window, about a
million software double operations per 64-sample block on that core. The
follow-up landed with M3: the search accumulates in `Sample` (`lpc.h`), the
double profile is bit-for-bit unchanged, the float rows' measured numbers
are unchanged to the bisection's 0.5 dB quantum (kalman-loop tonal ASG
+7.81 dB before and after; PEM tonal +10.84 → +11.13; ERLE and misalignment
identical), and the full float selection now runs on the M33 in 174 s (the
tonal headline 31 s), so the exclusion is gone and the M33 leg runs exactly
the M55's selection. That is the reason §5's rule that nothing on the
wake-word path touches double is a budget rule, not a style rule. Layer 4
of `bench/icount` is the suppressor at both trained geometries with xorshift
weights; baselines seeded locally on m55 and m33 (the local ratchet reproduces
every committed m55 baseline to 0.00 %, so local seeding is trustworthy; CI
Expand Down Expand Up @@ -440,6 +447,27 @@ scenario is within the drift gate on both targets; DspTap's typed battery for
`tap::dsp::nn` pins layout, gate order and accumulator precision; MuTap and
MuTap-Max pins bumped.

**Done, 5 September 2026** (DspTap and MuTap branches
`claude/mutap-wake-word-plan-2i63pe`; tap/DspTap#15). DspTap's seventh
primitive, `include/tap/dsp/nn.h`: `basic_dense<Sample>` (row-major
`[out x in]`, linear / tanh / sigmoid) and `basic_gru<Sample>` (PyTorch
`nn.GRU` convention, gates r, z, n), contract version 1, weights stored as
float32 and converted to `Sample` at the point of use, bias-first ascending
accumulation in `Sample`, moved-in owned weights, noexcept processing.
Measured: the GRU against an independent long-double restatement summing in
the opposite order 2.2e-16 double / 1.0e-7 float (pinned 1e-14 / 1e-6);
float vs double at the suppressor geometry 6.3e-7 state / 2.5e-7 gains
(pinned 3e-6 / 1e-6); 156 DspTap tests, warnings as errors, clang-tidy
clean. MuTap's `nn_suppressor` consumes the kernels (its weight arrays are
moved into them at construction, one copy in memory) and the promotion is
**bit-identical** in both profiles on the shipping v2 model against the
pre-refactor binary; the six parity cases pass at the M2 depths; the M2
battery passes unchanged; the `nn_suppressor` ratchet moved −0.12 % / −0.10 %
on m55 and −0.19 % / −0.11 % on m33 (16 k / 48 k), inside the gate, with
every other scenario unchanged to the instruction. Pins: MuTap's DspTap pin
points at the M3 tree (repointed at the identical tree on `main` once
tap/DspTap#15 merges); MuTap-Max's MuTap pin follows once MuTap's PR merges.

### M4 — Corpus, splits and dataset builder *(tools/ml/kws)*

`kws_features.py` as source of truth for the feature *values* (the M1 numpy
Expand Down
36 changes: 24 additions & 12 deletions include/mutap/lpc.h
Original file line number Diff line number Diff line change
Expand Up @@ -400,30 +400,42 @@ namespace tap::mu {
if (n < 2 * m_cfg.min_lag) {
return;
}
// Numeric contract: the correlation sums accumulate in Sample.
// The float profile therefore contains no double arithmetic
// here (on a single-precision core such as the Cortex-M33 this
// search was the canceller's dominant soft-float cost: ~1 M
// double operations per 64-sample block at the default lag
// range); a normalized correlation over at most
// analysis_capacity samples loses ~1e-6 relative in float,
// which is far below the voicing decision's resolution. The
// double profile is unchanged.
const size_t max_lag = (m_cfg.max_lag < n / 2) ? m_cfg.max_lag : n / 2;
double best = 0.0;
Sample best = Sample(0);
for (size_t lag = m_cfg.min_lag; lag <= max_lag; ++lag) {
double cross = 0.0;
double e_now = 0.0;
double e_lag = 0.0;
Sample cross = Sample(0);
Sample e_now = Sample(0);
Sample e_lag = Sample(0);
for (size_t i = lag; i < n; ++i) {
const double x0 = static_cast<double>(m_residual[i]);
const double x1 = static_cast<double>(m_residual[i - lag]);
const Sample x0 = m_residual[i];
const Sample x1 = m_residual[i - lag];
cross += x0 * x1;
e_now += x0 * x0;
e_lag += x1 * x1;
}
if (e_now <= 0.0 || e_lag <= 0.0) {
if (!(e_now > Sample(0)) || !(e_lag > Sample(0))) {
continue;
}
const double rho = cross / std::sqrt(e_now * e_lag);
const Sample denom = std::sqrt(e_now * e_lag);
if (!(denom > Sample(0))) { // product underflow on a near-silent residual
continue;
}
const Sample rho = cross / denom;
if (rho > best) {
best = rho;
if (rho > static_cast<double>(m_cfg.voicing_threshold)) {
const double beta = cross / e_lag;
if (rho > m_cfg.voicing_threshold) {
const Sample beta = cross / e_lag;
m_lag = lag;
m_beta = static_cast<Sample>(std::clamp(beta, -static_cast<double>(m_cfg.max_gain),
static_cast<double>(m_cfg.max_gain)));
m_beta = std::clamp(beta, -m_cfg.max_gain, m_cfg.max_gain);
}
}
}
Expand Down
Loading
Loading