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
89 changes: 89 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,39 @@ jobs:
- name: Test under emulation (Ooura FFT fallback)
run: ctest --test-dir build-ooura --output-on-failure

# 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.
cortex-m33-qemu:
name: Cortex-M33 cross (QEMU)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

- name: Install toolchain and QEMU
run: >
sudo apt-get update -q &&
sudo apt-get install -y -q gcc-arm-none-eabi qemu-system-arm

- name: Configure
run: >
cmake -B build
-DCMAKE_BUILD_TYPE=MinSizeRel
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-cortex-m33-mps2.cmake

- name: Build
run: cmake --build build -j 4

- name: Test under emulation
run: ctest --test-dir build --output-on-failure

# Cross-compile for Qualcomm Hexagon (hexagon-unknown-linux-musl, HVX
# auto-vectorization on) and run the FULL test suite under qemu-hexagon
# user-mode emulation: the third target of the one-core/three-targets
Expand Down Expand Up @@ -362,6 +395,26 @@ jobs:
python3 scripts/icount.py --target m55
--build-dir build-m55 --plugin /tmp/libinsncount.so

# Release (-O2) M33 workloads on the MPS2+ AN505 model: the Pico 2 W
# class core, Ooura float32 FFT (the toolchain file pins CMSIS off —
# no Helium). bench/baselines.json m33 was seeded from this exact
# toolchain/QEMU pair (gcc-arm-none-eabi 13.2.rel1, QEMU 8.2.2).
- name: Build M33 workloads
if: ${{ !cancelled() }}
run: >
cmake -B build-m33
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-cortex-m33-mps2.cmake
-DMUTAP_BUILD_TESTS=OFF
-DMUTAP_BUILD_ICOUNT_BENCH=ON
&& cmake --build build-m33 -j 4

- name: Ratchet M33
if: ${{ !cancelled() }}
run: >
python3 scripts/icount.py --target m33
--build-dir build-m33 --plugin /tmp/libinsncount.so

# qemu-system-arm's plugins cover M55; qemu-hexagon must be built with
# --enable-plugins (linux-user only, ~4 min, cached thereafter).
- name: Cache plugin-enabled qemu-hexagon
Expand Down Expand Up @@ -427,6 +480,42 @@ jobs:
python3 scripts/icount.py --target hexagon \
--build-dir build-hex --plugin /tmp/libinsncount.so

# Python <-> C++ parity of the learned suppressor (tools/ml/test_parity.py):
# the C++ inference against the numpy reference, in BOTH numeric profiles,
# on random weights at both trained geometries and on the shipping
# pretrained model. Previously a hand-run script; a feature definition
# drifting between features.py and nn_suppressor.h now fails here, not in
# a quietly degraded model (wake-word plan, M2).
nn-parity:
name: Suppressor Python/C++ parity
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

- name: Install numpy
run: sudo apt-get update -q && sudo apt-get install -y -q python3-numpy

- name: Build the parity driver
run: >
cmake -B build-ml -DCMAKE_BUILD_TYPE=Release
-DMUTAP_BUILD_TESTS=OFF -DMUTAP_BUILD_ML_TOOLS=ON
&& cmake --build build-ml -j 4 --target nn_infer

- name: Parity, double profile
run: |
python3 tools/ml/test_parity.py --build-dir build-ml --profile double --geometry 16k
python3 tools/ml/test_parity.py --build-dir build-ml --profile double --geometry 48k
python3 tools/ml/test_parity.py --build-dir build-ml --profile double --weights tools/ml/pretrained/suppressor_v2_48k.munn

- name: Parity, float profile
run: |
python3 tools/ml/test_parity.py --build-dir build-ml --profile float --geometry 16k
python3 tools/ml/test_parity.py --build-dir build-ml --profile float --geometry 48k
python3 tools/ml/test_parity.py --build-dir build-ml --profile float --weights tools/ml/pretrained/suppressor_v2_48k.munn

clang-format:
name: clang-format
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,5 +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`.
- **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.)
7 changes: 6 additions & 1 deletion bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ stale, too-high baseline can never let a future regression hide in the
slack — the winning commit must re-record).

Scenarios mirror the wall-clock layers — `fdkf`, `suppressor`, `shadow`,
`chain` — at both certified geometries (`_48k`, `_16k`), all **float32**
`chain` — plus `nn_suppressor` (the learned post engine at its two trained
geometries, hop 256 at 48 kHz and hop 64 at 16 kHz) at both certified
geometries (`_48k`, `_16k`), all **float32**
(the deployment precision; double is soft-float on the M55 and not the
optimization target — the float32 parity gates in
[`tests/test_float32.cpp`](../tests/test_float32.cpp) are the correctness
Expand Down Expand Up @@ -142,6 +144,9 @@ python3 scripts/icount.py --target m55 \
--build-dir build-m55 --plugin /tmp/libinsncount.so
```

Targets: `m55` (MPS3 AN547, CMSIS Helium FFT), `m33` (MPS2+ AN505 — the
Raspberry Pi Pico 2 W class, Ooura FFT, no MVE) and `hexagon`.

**Seeding / re-recording:** a new target starts with an empty dict, so the
job reports each scenario's count and fails with `NO BASELINE`. Capture
those counts by running the same command with `--update` in the target's
Expand Down
16 changes: 16 additions & 0 deletions bench/baselines.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,32 @@
"chain_48k": 470317154,
"fdkf_16k": 92600036,
"fdkf_48k": 162071335,
"nn_suppressor_16k": 465537141,
"nn_suppressor_48k": 154160506,
"shadow_16k": 57863067,
"shadow_48k": 57863050,
"suppressor_16k": 255190544,
"suppressor_48k": 251521827
},
"m33": {
"chain_16k": 707703396,
"chain_48k": 832971429,
"fdkf_16k": 178361160,
"fdkf_48k": 306029768,
"nn_suppressor_16k": 1036433821,
"nn_suppressor_48k": 375501198,
"shadow_16k": 114513960,
"shadow_48k": 114515211,
"suppressor_16k": 435040766,
"suppressor_48k": 431364983
},
"m55": {
"chain_16k": 360481402,
"chain_48k": 417303122,
"fdkf_16k": 80854232,
"fdkf_48k": 139974811,
"nn_suppressor_16k": 572321325,
"nn_suppressor_48k": 202455391,
"shadow_16k": 51274139,
"shadow_48k": 51279103,
"suppressor_16k": 228296904,
Expand Down
4 changes: 3 additions & 1 deletion bench/icount/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ set(_mutap_icount_scenarios
shadow_48k:2:0
shadow_16k:2:1
chain_48k:3:0
chain_16k:3:1)
chain_16k:3:1
nn_suppressor_48k:4:0
nn_suppressor_16k:4:1)

foreach(_sc IN LISTS _mutap_icount_scenarios)
string(REPLACE ":" ";" _parts "${_sc}")
Expand Down
64 changes: 62 additions & 2 deletions bench/icount/icount_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,21 @@
// cross-run determinism.
//
// MUTAP_SC_LAYER: 0 = fdkf core, 1 = suppressor, 2 = shadow canceller,
// 3 = full certified chain
// 3 = full certified chain, 4 = learned nn_suppressor
// MUTAP_SC_RATE: 0 = 48 kHz (2048 taps), 1 = 16 kHz (1024 taps)
//
// Layer 4 is the learned residual suppressor (nn_suppressor.h) at the two
// trained geometries — 48 kHz / hop 256 / 26 bands and 16 kHz / hop 64 /
// 22 bands, dense 64, GRU 96 — with deterministic xorshift weights (no
// file I/O on bare metal; the cost does not depend on the values). This
// is the per-hop cost the wake-word plan's ceilings are stated against.
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <vector>

#include "mutap/fd_kalman.h"
#include "mutap/nn_suppressor.h"
#include "mutap/postfilter.h"

#ifndef MUTAP_SC_LAYER
Expand Down Expand Up @@ -86,10 +93,42 @@ namespace {
const float* yb(std::size_t i) const noexcept { return &y[(i % blocks) * block]; }
};

auto preset() {
// Each binary is one scenario, so the helper the other layers use is
// unreferenced in some of them by design.
[[maybe_unused]] auto preset() {
return tap::mu::aec_chain_preset<float>(k_geo.block, k_geo.partitions, k_geo.fs);
}

// Deterministic weights at the scenario's trained geometry (layer 4).
[[maybe_unused]] tap::mu::nn_suppressor_weights nn_weights() {
#if MUTAP_SC_RATE == 0
const tap::mu::nn_geometry g{48000.0, 256, 26, 64, 96};
#else
const tap::mu::nn_geometry g{16000.0, 64, 22, 64, 96};
#endif
std::uint32_t s = 0x2545F491u;
auto fill = [&s](std::vector<float>& v, std::size_t n) {
v.resize(n);
for (auto& x : v) {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
x = (static_cast<float>(s) / 2147483648.0f - 1.0f) * 0.3f;
}
};
tap::mu::nn_suppressor_weights w;
w.geometry = g;
fill(w.dense_in_w, g.dense * g.features());
fill(w.dense_in_b, g.dense);
fill(w.gru_w_ih, 3 * g.gru * g.dense);
fill(w.gru_w_hh, 3 * g.gru * g.gru);
fill(w.gru_b_ih, 3 * g.gru);
fill(w.gru_b_hh, 3 * g.gru);
fill(w.dense_out_w, g.bands * g.gru);
fill(w.dense_out_b, g.bands);
return w;
}

// Accumulate a checksum over the timed output so nothing is dead code.
double sum_out(const std::vector<float>& e) noexcept {
double s = 0.0;
Expand Down Expand Up @@ -137,6 +176,27 @@ namespace {
shadow.process_block(c.xb(i), c.yb(i), e.data());
sink += sum_out(e);
}
#elif MUTAP_SC_LAYER == 4
// The suppressor's hop is the trained one (256 at 48 kHz, 64 at
// 16 kHz); the corpus block is 256, so the 16 kHz scenario feeds
// four hops per corpus block. Same audio duration per scenario.
tap::mu::nn_suppressor<float>::config cfg;
cfg.weights = nn_weights();
tap::mu::nn_suppressor<float> sup(std::move(cfg));
const std::size_t hop = sup.block_size();
const std::size_t hops = k_geo.block / hop;
std::vector<float> eh(hop);
for (std::size_t i = 0; i < k_warm; ++i) {
for (std::size_t h = 0; h < hops; ++h) {
sup.process_block(c.yb(i) + h * hop, c.xb(i) + h * hop, eh.data());
}
}
for (std::size_t i = k_warm; i < k_warm + k_timed; ++i) {
for (std::size_t h = 0; h < hops; ++h) {
sup.process_block(c.yb(i) + h * hop, c.xb(i) + h * hop, eh.data());
sink += sum_out(eh);
}
}
#else
tap::mu::aec_chain<float> chain(preset());
for (std::size_t i = 0; i < k_warm; ++i) {
Expand Down
47 changes: 47 additions & 0 deletions cmake/arm-cortex-m33-mps2.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Cross-compilation toolchain for Arm Cortex-M33 (bare metal, newlib +
# semihosting), executed on QEMU's MPS2+ AN505 board model. This is the
# Raspberry Pi Pico 2 W (RP2350) class of core — the wake-word plan's named
# embedded target: single-precision FPU only, no FP64, no MVE/Helium. The
# float32 profile is the profile here; anything double is soft-float and
# is excluded from the on-target selection. Ported from RatioTap's
# cmake/arm-cortex-m33-mps2.cmake (which ported it from SampleRateTap's).
#
# Usage:
# cmake -B build-m33 -DCMAKE_TOOLCHAIN_FILE=cmake/arm-cortex-m33-mps2.cmake \
# -DCMAKE_BUILD_TYPE=MinSizeRel
# with arm-none-eabi-g++ and qemu-system-arm on PATH.
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)

set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

set(CMAKE_C_FLAGS_INIT "-mcpu=cortex-m33 -mthumb -mfloat-abi=hard -ffunction-sections -fdata-sections")
set(CMAKE_CXX_FLAGS_INIT "${CMAKE_C_FLAGS_INIT}")

get_filename_component(_mutap_platform "${CMAKE_CURRENT_LIST_DIR}/../platform" ABSOLUTE)
# Same startup as the M55 leg (Armv8-M, shared); the AN505 linker script
# places everything in the board's secure aliases (4 MB code, 4 MB data).
set(CMAKE_EXE_LINKER_FLAGS_INIT
"--specs=rdimon.specs -nostartfiles -Wl,--gc-sections -T${_mutap_platform}/mps2_an505.ld -x c ${_mutap_platform}/armv8m_startup.c -x none")

set(CMAKE_CROSSCOMPILING_EMULATOR
"qemu-system-arm;-M;mps2-an505;-nographic;-semihosting;-kernel")

set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

# No Helium on the M33: DspTap defaults its CMSIS-DSP Helium FFT backend ON
# for any bare-metal Arm profile, so pin the Ooura float32 path here
# (a plain `set` of the cache entry, which DspTap's option() then respects).
set(TAP_DSP_FFT_CMSIS OFF CACHE BOOL "No MVE on the Cortex-M33: Ooura float32 FFT")

# 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)
Loading
Loading