diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1dade9f..9f5fb04 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,8 +1,3 @@
-# The following five lines of boilerplate have to be in your project's
-# CMakeLists in this exact order for cmake to work correctly
-cmake_minimum_required(VERSION 3.22)
-
+cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
-# "Trim" the build. Include the minimal set of components, main, and anything it depends on.
-idf_build_set_property(MINIMAL_BUILD ON)
-project(buzz_detector)
+project(door_buzzer)
diff --git a/README.md b/README.md
index bc33071..6a9f073 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,149 @@
-# ESP32-S3 INMP441 noise indicator
+# Door buzzer detector (ESP32-S3, ESP-IDF, spectral template matching)
-This ESP-IDF sample reads audio from an INMP441 I2S microphone and indicates
-the smoothed sound level (RMS) using the ESP32-S3 board's onboard RGB LED. The
-microphone's left channel is used because its L/R pin is grounded.
+Listens through an I2S MEMS mic, compares the *shape* of the spectrum of every 32 ms frame to a stored
+buzzer template (cosine similarity of a 32-band log-spectrum fingerprint), and sends an ntfy.sh push
+notification when a sliding window says "that was the buzzer". No machine learning.
-The LED fades from off to green as the smoothed RMS rises from 0 to 100,000.
-From 100,000 to 1,000,000 RMS, it smoothly changes from green to red; at or
-above 1,000,000 it remains red. These thresholds are 10 times higher than the
-previous settings to make red less likely during ordinary background noise.
+Written in C (C11), as your file list (`main.c`, ...) implied. ESP-IDF API usage follows v5.x/v6.0 conventions
+(`driver/i2s_std.h`, `esp_driver_*` components, esp_console REPL).
-The thresholds are defined by `GREEN_NOISE_RMS` and `RED_NOISE_RMS` near the
-top of [main/main.c](main/main.c). Adjust them after checking the RMS values in
-the serial monitor for your microphone and environment.
+## Project tree
-## Wiring
+```
+door_buzzer/
+├── CMakeLists.txt
+├── sdkconfig.defaults
+├── README.md
+├── tools/
+│ ├── make_template.py # recording -> template.h + test_vector.h
+│ └── host_test/ # optional PC test of fingerprint.c + detector.c
+└── main/
+ ├── CMakeLists.txt idf_component.yml Kconfig.projbuild
+ ├── feature_params.h # single source of truth (parsed by Python)
+ ├── template.h test_vector.h # placeholders until make_template.py runs
+ ├── board_config.h # pin #defines (from Kconfig)
+ ├── main.c # init, BOOT button
+ ├── audio_in.c/h # I2S std RX, frame pool, capture task (core 1)
+ ├── fingerprint.c/h # window, band edges, FFT, fingerprint
+ ├── matcher.c/h # active template (NVS > built-in > none), cosine score
+ ├── detector.c/h # pure IDLE/TRIGGERED/COOLDOWN state machine
+ ├── calibration.c/h # learn/adapt/selfcheck/test/background/autothresh
+ ├── config_store.c/h # runtime parameters in NVS
+ ├── processing.c/h # features -> score -> detector task, stats, monitor
+ ├── console.c/h # serial commands
+ ├── notifier.c/h # Wi-Fi w/ backoff + ntfy HTTPS task
+ └── led.c/h # WS2812 status LED
+```
-| Module | Pin | ESP32-S3 |
-| --- | --- | --- |
-| INMP441 | SCK / BCLK | GPIO 3 |
-| INMP441 | WS / LRCLK | GPIO 4 |
-| INMP441 | SD | GPIO 5 |
-| INMP441 | L/R | GND |
-| INMP441 | VDD | 3.3V |
-| INMP441 | GND | GND |
-| Onboard RGB LED | Data | GPIO 48 (on supported board variants) |
+Your staged build order maps onto this as: (1) `feature_params.h`, `audio_in`, (2) `fingerprint`, `selftest`,
+`make_template.py`, (3) `matcher`, `calibration`, `config_store`, (4) `detector`, `processing`, (5) `notifier`,
+(6) watchdog / reconnect / LED in the same files. The final tree is what is delivered; each layer only depends on the ones before it.
-## Build and run
+## Wiring (INMP441)
-Open this folder as an ESP-IDF project, select the `esp32s3` target, then build,
-flash, and open the serial monitor. The firmware uses ESP-IDF's I2S driver and
-the `espressif/led_strip` component.
+| INMP441 | ESP32-S3 | Note |
+|---|---|---|
+| VDD | 3V3 | |
+| GND | GND | |
+| SCK | GPIO3 | BCLK (`BUZZ_I2S_BCLK_GPIO`) |
+| WS | GPIO4 | (`BUZZ_I2S_WS_GPIO`) |
+| SD | GPIO5 | data out of mic (`BUZZ_I2S_DATA_GPIO`) |
+| L/R | GND | left slot (to VDD -> change slot to Right in menuconfig) |
+
+On-board: RGB LED on GPIO48 (WS2812), BOOT button GPIO0. LED: red = needs template, dim blue = listening,
+amber = calibration session, magenta = alert/cooldown.
+
+## menuconfig
+
+```
+idf.py set-target esp32s3
+idf.py menuconfig # "Door Buzzer Detector"
+```
+* **Wi-Fi**: SSID + password.
+* **ntfy**: server (default `https://ntfy.sh`), **topic** (pick something unguessable: anyone who knows it can read it),
+ optional heartbeat topic and interval (hours, 0 = off). Subscribe to the topic in the ntfy phone app.
+* **Hardware**: pins, I2S slot, `Right shift` (default 14 = 4x digital gain for the quiet INMP441).
+* **Detector defaults**: threshold, loudness gate, N/M, cooldown (default 5 ms). The existing N-of-M sliding window smooths frame matches; because frames arrive every 32 ms, cooldown/re-arm timing is effectively frame-granular. All can also be changed live with `set` and persist in NVS.
+* Console: defaults to the **UART** USB port. For the native USB port set *Component config > ESP System Settings > Channel for console output*
+ to *USB Serial/JTAG*.
+
+## Build / flash
+
+```
+idf.py set-target esp32s3
+idf.py build flash monitor
+```
+
+## Workflow
+
+**a. Make the template from your recording**
+```
+pip install numpy scipy
+python tools/make_template.py --buzz buzzer.wav --background quiet_room.wav tv_talking.wav
+# optional: --segments 1.2-2.0,5.5-6.4 (otherwise auto-detected: frames > 4x noise floor)
+```
+It prints the selected time ranges, buzzer score distribution, a text bar chart, background score stats, a recommended threshold,
+and an estimated false-trigger rate per hour, then writes `main/template.h` and `main/test_vector.h`. It refuses with < 20 buzzer frames and warns loudly if background and buzzer scores overlap.
+
+**b. Build, flash, verify**
+```
+buzz> selftest # must print PASS (C == Python, tolerance 1e-3)
+buzz> selfcheck # press the real buzzer for 10 s; verdict good / weak / poor
+```
+**c. If the match is weak/poor** (your recording used a different mic):
+```
+buzz> adapt # 60 s: blends live buzzer 50/50 into the PC template (adapt 0.7 = weight of NEW sound)
+ or buzz> learn # 60 s: replace the template completely (also: short-press BOOT)
+buzz> background # 60 s of normal ambient sound, do NOT press the buzzer
+buzz> test # 10 s, press the buzzer
+buzz> autothresh # threshold = midpoint of background p99 and buzzer p5, saved
+```
+**d. Live test**
+```
+buzz> monitor on # timestamp, RMS, score, MATCH flag, state
+buzz> alert # test notification
+buzz> status # template source, timing (must be << 32 ms), Wi-Fi, notification counters
+```
+`learn reset` clears the NVS template and falls back to `template.h`. During any calibration session the detector is
+paused (pressing the buzzer will not send alerts).
+
+### Console reference
+`selftest` `selfcheck` `learn [reset]` `adapt [w]` `background` `test` `autothresh` `monitor on|off`
+`set ` `get [param]` `status` `fingerprint` `alert` `reboot`.
+Parameters: `threshold min_rms win_n win_m cooldown_ms noise_mult adapt_weight monitor_ms heartbeat_h`.
+
+## Design notes
+
+* **Template priority at boot:** NVS-learned > compiled-in `template.h` > "needs template" (instructions printed, LED red). `status` shows which.
+* **Fingerprint:** DC removal per frame -> RMS -> samples/32768 x periodic Hann -> 1024-pt FFT (esp-dsp) -> *mean* power per band over
+ 32 log-spaced bands -> ln(P+1e-10) -> subtract mean -> (FP_DETREND) subtract least-squares line over band index -> L2 normalise.
+ Score = dot product (cosine).
+* **Parity:** `feature_params.h` is parsed by Python; band edges use double math and `floor(x+0.5)` on both sides. I verified on a PC
+ (real `fingerprint.c` + stub FFT vs numpy) a max fingerprint error of ~1e-6, also with other NUM_BANDS / FP_DETREND values.
+* **Concurrency:** audio task (core 1, prio 10) -> pool of 8 frame slots + two index queues (no allocation; drops and logs if processing lags)
+ -> processing task (core 1, prio 8). Network and console tasks are separate; the notifier has its own queue. Mode and session data
+ are guarded by a mutex; template and config by mutexes. Audio and processing tasks are on the task watchdog (5 s, panic -> reboot).
+* **Notifier:** exponential Wi-Fi reconnect (1 s .. 60 s); up to 4 delivery attempts with backoff, then dropped and logged.
+* Simplifications chosen on purpose: one `TEST_CAL` mode serves `test` and `selfcheck`; calibration results (background/buzzer stats) live in RAM only
+ (re-run after reboot before `autothresh`); `TEMPLATE_SUGGESTED_THRESHOLD` is shown in `status`, not applied automatically.
+
+## Assumptions
+
+* INMP441-style mic: 24-bit in a 32-bit Philips slot, mono. 16 kHz, hop 32 ms.
+* `min_rms` is in int16 units *after* the shift/gain, so it depends on `BUZZ_AUDIO_SHIFT`. Default 200 is a guess; check `monitor on` while the buzzer sounds vs. silence and adjust.
+* `learn`/`adapt` gate = max(median RMS of the first second x `noise_mult`, `min_rms`) so stay quiet in the first second. `test`/`selfcheck` count frames with RMS >= `min_rms`.
+* Verdict: **good** if the 5th-percentile score >= threshold, **weak** if only the median passes, **poor** otherwise.
+* GPIO3 is an ESP32-S3 strapping pin (JTAG source); it works as an I2S clock after boot but avoid a heavy pull-up/down on the mic line.
+
+## Tuning tips
+
+* **False alarms:** raise `threshold` (run `background` + `autothresh`), raise `min_rms`, or raise `win_m` (e.g. 12/15). Door slams and speech are loud
+ but rarely hold a buzzer-shaped spectrum for 0.3+ s.
+* **Missed buzzes:** `selfcheck` first. Weak/poor -> `adapt`/`learn`. Then lower `threshold` slightly, lower `win_m` (8/15), lower `min_rms`
+ (the buzzer may be quieter at the mic than you think; use `monitor on`).
+* **Mic mismatch:** the PC recording mic and the INMP441 have different frequency responses. Detrending cancels a tilt but not bumps; `adapt` fixes
+ residual differences without throwing away a good template.
+* **FP_DETREND:** keep on by default. Turn it **off** (`#define FP_DETREND 0`) if the buzzer's own spectral tilt is its most distinctive feature
+ (e.g. a very low, dull buzz vs. bright background noise) and `background`/`test` overlap. Then re-run `make_template.py` and rebuild: it changes the fingerprint, so an old template/NVS template is invalid until relearned (`learn reset` + `learn`).
+ Changing any `feature_params.h` value invalidates stored templates the same way; NVS templates with a different band count are ignored automatically.
+* Typical processing time is far below the 32 ms budget; `status` prints the measured average and maximum.
diff --git a/dependencies.lock b/dependencies.lock
index 66198b1..3e6d8b9 100644
--- a/dependencies.lock
+++ b/dependencies.lock
@@ -1,4 +1,14 @@
dependencies:
+ espressif/esp-dsp:
+ component_hash: ed9ac175b14e0414399a2b33c525579414b5bad19a2a67b12ea212def96f9a98
+ dependencies:
+ - name: idf
+ require: private
+ version: '>=4.2'
+ source:
+ registry_url: https://components.espressif.com/
+ type: service
+ version: 1.8.2
espressif/led_strip:
component_hash: 28621486f77229aaf81c71f5e15d6fbf36c2949cf11094e07090593e659e7639
dependencies:
@@ -14,7 +24,9 @@ dependencies:
type: idf
version: 6.0.0
direct_dependencies:
+- espressif/esp-dsp
- espressif/led_strip
-manifest_hash: cdcc77aff1f56b906c61e51c8f438577513c8d92aa9dfd7bf655f126d2b2d320
+- idf
+manifest_hash: 1ecd46a48423b6609e679fe9f0888e13e7702742f2dcb02c0937daebcd6f4fb2
target: esp32s3
version: 3.0.0
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 419d3f2..4dcd79c 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -1,3 +1,19 @@
-idf_component_register(SRCS "main.c"
- INCLUDE_DIRS "."
- REQUIRES esp_driver_gpio esp_driver_i2s led_strip)
+idf_component_register(
+ SRCS
+ main.c
+ audio_in.c
+ fingerprint.c
+ matcher.c
+ detector.c
+ calibration.c
+ config_store.c
+ processing.c
+ console.c
+ notifier.c
+ led.c
+ INCLUDE_DIRS "."
+ REQUIRES
+ esp_driver_i2s esp_driver_gpio esp_driver_rmt
+ esp_timer esp_system esp_event esp_netif esp_wifi
+ esp_http_client esp-tls mbedtls nvs_flash console
+)
diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild
new file mode 100644
index 0000000..313f484
--- /dev/null
+++ b/main/Kconfig.projbuild
@@ -0,0 +1,110 @@
+menu "Door Buzzer Detector"
+
+ menu "Hardware"
+ config BUZZ_I2S_BCLK_GPIO
+ int "I2S BCLK (mic SCK) GPIO"
+ default 3
+ range 0 48
+ config BUZZ_I2S_WS_GPIO
+ int "I2S WS (mic WS/LRCL) GPIO"
+ default 4
+ range 0 48
+ config BUZZ_I2S_DATA_GPIO
+ int "I2S data-in (mic SD) GPIO"
+ default 5
+ range 0 48
+ choice BUZZ_I2S_SLOT
+ prompt "I2S slot that carries the mic"
+ default BUZZ_I2S_SLOT_LEFT
+ help
+ INMP441: L/R pin tied to GND -> left slot, tied to VDD -> right slot.
+ config BUZZ_I2S_SLOT_LEFT
+ bool "Left"
+ config BUZZ_I2S_SLOT_RIGHT
+ bool "Right"
+ endchoice
+ config BUZZ_AUDIO_SHIFT
+ int "Right shift applied to the 32-bit I2S sample"
+ default 14
+ range 8 20
+ help
+ The INMP441 delivers 24-bit data left-justified in a 32-bit slot, so
+ a shift of 16 keeps the top 16 bits (unity gain). A smaller shift adds
+ digital gain (14 = 4x) which helps with this quiet mic; the result is
+ clamped to int16. Fingerprints are level-independent, but the RMS gate
+ is not.
+ config BUZZ_BOOT_BUTTON_GPIO
+ int "User button GPIO (BOOT)"
+ default 0
+ range 0 48
+ config BUZZ_RGB_LED_ENABLE
+ bool "Drive the on-board WS2812 status LED"
+ default y
+ config BUZZ_RGB_LED_GPIO
+ int "RGB LED GPIO"
+ default 48
+ range 0 48
+ depends on BUZZ_RGB_LED_ENABLE
+ endmenu
+
+ menu "Detector defaults (runtime-adjustable, persisted in NVS)"
+ config BUZZ_THRESHOLD_MILLI
+ int "Cosine-similarity threshold x1000"
+ default 800
+ range 100 999
+ config BUZZ_MIN_RMS
+ int "Loudness gate: minimum frame RMS (int16 units)"
+ default 200
+ range 0 32767
+ config BUZZ_WIN_N
+ int "Sliding window length N (frames, 32 ms each)"
+ default 15
+ range 1 32
+ config BUZZ_WIN_M
+ int "Matches required M within the window"
+ default 10
+ range 1 32
+ config BUZZ_COOLDOWN_MS
+ int "Cooldown after an alert (milliseconds)"
+ default 5
+ range 0 86400000
+ config BUZZ_NOISE_MULT_X10
+ int "Learn/adapt gate: noise floor multiplier x10"
+ default 40
+ range 10 1000
+ config BUZZ_ADAPT_WEIGHT_PCT
+ int "Adapt: weight (percent) of the newly learned template"
+ default 50
+ range 0 100
+ config BUZZ_MONITOR_MS
+ int "Minimum interval between 'monitor' lines (ms)"
+ default 100
+ range 10 60000
+ endmenu
+
+ menu "Wi-Fi"
+ config BUZZ_WIFI_SSID
+ string "Wi-Fi SSID"
+ default ""
+ config BUZZ_WIFI_PASSWORD
+ string "Wi-Fi password"
+ default ""
+ endmenu
+
+ menu "ntfy notifications"
+ config BUZZ_NTFY_SERVER
+ string "ntfy server URL"
+ default "https://ntfy.sh"
+ config BUZZ_NTFY_TOPIC
+ string "Alert topic (anyone who knows it can read it: make it unguessable)"
+ default "change-me-door-buzzer"
+ config BUZZ_NTFY_HB_TOPIC
+ string "Heartbeat topic"
+ default "change-me-door-buzzer-heartbeat"
+ config BUZZ_HEARTBEAT_HOURS
+ int "Heartbeat interval in hours (0 = disabled)"
+ default 0
+ range 0 720
+ endmenu
+
+endmenu
diff --git a/main/audio_in.c b/main/audio_in.c
new file mode 100644
index 0000000..f884a12
--- /dev/null
+++ b/main/audio_in.c
@@ -0,0 +1,175 @@
+// I2S capture: continuous 16 kHz mono read, builds 50%-overlapping frames
+// (previous hop + current hop) and hands them to the processing task through a
+// fixed pool of frame slots. No allocation after init.
+#include
+#include "driver/i2s_std.h"
+#include "esp_log.h"
+#include "esp_task_wdt.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/queue.h"
+#include "freertos/task.h"
+#include "audio_in.h"
+#include "board_config.h"
+
+static const char *TAG = "audio";
+
+#define NUM_SLOTS 8
+#define SETTLE_HOPS 8 // discard the first hops: mic start-up transient
+#define AUDIO_TASK_CORE 1
+#define AUDIO_TASK_PRIO 10
+#define AUDIO_TASK_STACK 4096
+
+static int16_t s_slots[NUM_SLOTS][FRAME_LEN];
+static QueueHandle_t s_free_q;
+static QueueHandle_t s_ready_q;
+static i2s_chan_handle_t s_rx;
+static int32_t s_raw[HOP_LEN];
+static int16_t s_hop_a[HOP_LEN], s_hop_b[HOP_LEN];
+static volatile uint32_t s_captured, s_dropped;
+static uint32_t s_seq;
+
+esp_err_t audio_in_init(void)
+{
+ s_free_q = xQueueCreate(NUM_SLOTS, sizeof(uint8_t));
+ s_ready_q = xQueueCreate(NUM_SLOTS, sizeof(uint8_t));
+ if (!s_free_q || !s_ready_q) {
+ return ESP_ERR_NO_MEM;
+ }
+ for (uint8_t i = 0; i < NUM_SLOTS; i++) {
+ xQueueSend(s_free_q, &i, 0);
+ }
+
+ i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_AUTO, I2S_ROLE_MASTER);
+ chan_cfg.dma_desc_num = 6;
+ chan_cfg.dma_frame_num = 256; // 6 x 256 x 4 B -> ~96 ms of DMA buffering
+ esp_err_t err = i2s_new_channel(&chan_cfg, NULL, &s_rx);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "i2s_new_channel: %s", esp_err_to_name(err));
+ return err;
+ }
+
+ // INMP441: Philips format, 24-bit sample left-justified in a 32-bit slot.
+ i2s_std_config_t std_cfg = {
+ .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(FS_HZ),
+ .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO),
+ .gpio_cfg = {
+ .mclk = I2S_GPIO_UNUSED,
+ .bclk = I2S_BCLK_GPIO,
+ .ws = I2S_WS_GPIO,
+ .dout = I2S_GPIO_UNUSED,
+ .din = I2S_DATA_GPIO,
+ .invert_flags = { .mclk_inv = false, .bclk_inv = false, .ws_inv = false },
+ },
+ };
+#if CONFIG_BUZZ_I2S_SLOT_RIGHT
+ std_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_RIGHT;
+#else
+ std_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_LEFT;
+#endif
+ err = i2s_channel_init_std_mode(s_rx, &std_cfg);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "i2s_channel_init_std_mode: %s", esp_err_to_name(err));
+ return err;
+ }
+ err = i2s_channel_enable(s_rx);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "i2s_channel_enable: %s", esp_err_to_name(err));
+ return err;
+ }
+ ESP_LOGI(TAG, "I2S RX up: %d Hz, BCLK=%d WS=%d DIN=%d, shift=%d",
+ FS_HZ, CONFIG_BUZZ_I2S_BCLK_GPIO, CONFIG_BUZZ_I2S_WS_GPIO,
+ CONFIG_BUZZ_I2S_DATA_GPIO, CONFIG_BUZZ_AUDIO_SHIFT);
+ return ESP_OK;
+}
+
+// 32-bit slot -> int16. The 24-bit sample sits in bits [31:8]; shifting right by 16
+// would keep its top 16 bits (unity gain). CONFIG_BUZZ_AUDIO_SHIFT < 16 adds gain.
+// Arithmetic shift keeps the sign; clamp protects against overflow with gain.
+static inline int16_t to_i16(int32_t raw)
+{
+ int32_t v = raw >> CONFIG_BUZZ_AUDIO_SHIFT;
+ if (v > INT16_MAX) v = INT16_MAX;
+ if (v < INT16_MIN) v = INT16_MIN;
+ return (int16_t)v;
+}
+
+static void audio_task(void *arg)
+{
+ ESP_ERROR_CHECK(esp_task_wdt_add(NULL));
+ int16_t *prev = s_hop_a, *cur = s_hop_b;
+ uint32_t settle = SETTLE_HOPS;
+ bool have_prev = false;
+ uint32_t last_warn_ms = 0;
+
+ for (;;) {
+ esp_task_wdt_reset(); // if I2S stops delivering data the WDT reboots us
+
+ size_t need = sizeof(s_raw), total = 0;
+ while (total < need) {
+ size_t got = 0;
+ esp_err_t err = i2s_channel_read(s_rx, (uint8_t *)s_raw + total, need - total, &got, 200);
+ total += got;
+ if (err == ESP_ERR_TIMEOUT) {
+ ESP_LOGW(TAG, "I2S read timeout (mic wired?)");
+ } else if (err != ESP_OK) {
+ ESP_LOGE(TAG, "i2s_channel_read: %s", esp_err_to_name(err));
+ vTaskDelay(pdMS_TO_TICKS(10));
+ }
+ }
+ for (int i = 0; i < HOP_LEN; i++) {
+ cur[i] = to_i16(s_raw[i]);
+ }
+
+ if (settle > 0) {
+ settle--;
+ } else if (have_prev) {
+ uint8_t idx;
+ if (xQueueReceive(s_free_q, &idx, 0) == pdTRUE) {
+ memcpy(&s_slots[idx][0], prev, HOP_LEN * sizeof(int16_t));
+ memcpy(&s_slots[idx][HOP_LEN], cur, HOP_LEN * sizeof(int16_t));
+ if (xQueueSend(s_ready_q, &idx, 0) != pdTRUE) {
+ xQueueSend(s_free_q, &idx, 0);
+ s_dropped++;
+ } else {
+ s_captured++;
+ }
+ } else {
+ s_dropped++;
+ uint32_t now = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
+ if (now - last_warn_ms > 2000) {
+ last_warn_ms = now;
+ ESP_LOGW(TAG, "processing behind: dropped %lu frames so far", (unsigned long)s_dropped);
+ }
+ }
+ }
+ have_prev = true;
+ int16_t *t = prev; prev = cur; cur = t;
+ }
+}
+
+esp_err_t audio_in_start(void)
+{
+ BaseType_t ok = xTaskCreatePinnedToCore(audio_task, "audio", AUDIO_TASK_STACK, NULL,
+ AUDIO_TASK_PRIO, NULL, AUDIO_TASK_CORE);
+ return ok == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
+}
+
+bool audio_in_get_frame(audio_frame_t *f, TickType_t wait)
+{
+ uint8_t idx;
+ if (xQueueReceive(s_ready_q, &idx, wait) != pdTRUE) {
+ return false;
+ }
+ f->idx = idx;
+ f->pcm = s_slots[idx];
+ f->seq = s_seq++;
+ return true;
+}
+
+void audio_in_release_frame(uint8_t idx)
+{
+ xQueueSend(s_free_q, &idx, 0);
+}
+
+uint32_t audio_in_frames_captured(void) { return s_captured; }
+uint32_t audio_in_frames_dropped(void) { return s_dropped; }
diff --git a/main/audio_in.h b/main/audio_in.h
new file mode 100644
index 0000000..89ae8f0
--- /dev/null
+++ b/main/audio_in.h
@@ -0,0 +1,19 @@
+#pragma once
+#include
+#include
+#include "esp_err.h"
+#include "freertos/FreeRTOS.h"
+#include "feature_params.h"
+
+typedef struct {
+ const int16_t *pcm; // FRAME_LEN samples, valid until audio_in_release_frame()
+ uint8_t idx; // slot index to hand back
+ uint32_t seq; // monotonically increasing frame number
+} audio_frame_t;
+
+esp_err_t audio_in_init(void); // creates the I2S RX channel and the frame pool
+esp_err_t audio_in_start(void); // starts the capture task (pinned to core 1)
+bool audio_in_get_frame(audio_frame_t *f, TickType_t wait);
+void audio_in_release_frame(uint8_t idx);
+uint32_t audio_in_frames_captured(void);
+uint32_t audio_in_frames_dropped(void); // processing fell behind
diff --git a/main/board_config.h b/main/board_config.h
new file mode 100644
index 0000000..1370e16
--- /dev/null
+++ b/main/board_config.h
@@ -0,0 +1,9 @@
+#pragma once
+#include "driver/gpio.h"
+#include "sdkconfig.h"
+// Pin map (defaults 3/4/5/48 set in Kconfig, see "Door Buzzer Detector > Hardware").
+#define I2S_BCLK_GPIO ((gpio_num_t)CONFIG_BUZZ_I2S_BCLK_GPIO)
+#define I2S_WS_GPIO ((gpio_num_t)CONFIG_BUZZ_I2S_WS_GPIO)
+#define I2S_DATA_GPIO ((gpio_num_t)CONFIG_BUZZ_I2S_DATA_GPIO)
+#define RGB_LED_GPIO ((gpio_num_t)CONFIG_BUZZ_RGB_LED_GPIO)
+#define BOOT_BTN_GPIO ((gpio_num_t)CONFIG_BUZZ_BOOT_BUTTON_GPIO)
diff --git a/main/calibration.c b/main/calibration.c
new file mode 100644
index 0000000..d9250d2
--- /dev/null
+++ b/main/calibration.c
@@ -0,0 +1,354 @@
+// learn / adapt / selfcheck / test / background / autothresh.
+// A session is started by a console task, fed by the processing task via
+// calibration_on_frame(), and finished (mode -> NORMAL) by the processing task, which
+// then signals the waiting console task. Session data is guarded by s_mx.
+#include
+#include
+#include
+#include
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "calibration.h"
+#include "config_store.h"
+#include "fingerprint.h"
+#include "matcher.h"
+
+#define LEARN_SECS 60
+#define TEST_SECS 10
+#define BG_SECS 60
+#define SECS_TO_FRAMES(s) ((uint32_t)(s) * FS_HZ / HOP_LEN)
+#define NOISE_FRAMES (FS_HZ / HOP_LEN) // first second -> noise floor
+#define MIN_LEARN_FRAMES 20
+#define MAX_SCORES 2048
+_Static_assert(BG_SECS * FS_HZ / HOP_LEN <= MAX_SCORES, "score buffer too small for background run");
+
+
+static SemaphoreHandle_t s_mx;
+static SemaphoreHandle_t s_done;
+static volatile app_mode_t s_mode = MODE_NORMAL;
+
+static struct {
+ uint32_t frame_idx, total_frames;
+ float min_rms, threshold, noise_mult;
+ // learn / adapt
+ float noise[NOISE_FRAMES];
+ float gate;
+ float fp_sum[NUM_BANDS];
+ uint32_t count;
+ // test / background
+ float scores[MAX_SCORES];
+ uint32_t n_scores;
+ uint32_t n_gated, n_above, n_above_gated;
+} S;
+
+static score_stats_t s_bg, s_buzz;
+
+static int cmp_f(const void *a, const void *b)
+{
+ float x = *(const float *)a, y = *(const float *)b;
+ return (x > y) - (x < y);
+}
+
+static float pct(const float *sorted, uint32_t n, float p)
+{
+ // Linear interpolation, same as numpy.percentile default.
+ float pos = p / 100.0f * (float)(n - 1);
+ uint32_t lo = (uint32_t)pos;
+ uint32_t hi = lo + 1 < n ? lo + 1 : lo;
+ float fr = pos - (float)lo;
+ return sorted[lo] * (1.0f - fr) + sorted[hi] * fr;
+}
+
+static void compute_stats(float *a, uint32_t n, score_stats_t *o)
+{
+ memset(o, 0, sizeof(*o));
+ if (n == 0) return;
+ qsort(a, n, sizeof(float), cmp_f);
+ double sum = 0;
+ for (uint32_t i = 0; i < n; i++) sum += a[i];
+ o->valid = true;
+ o->n = n;
+ o->min = a[0];
+ o->max = a[n - 1];
+ o->mean = (float)(sum / n);
+ o->p5 = pct(a, n, 5);
+ o->p50 = pct(a, n, 50);
+ o->p95 = pct(a, n, 95);
+ o->p99 = pct(a, n, 99);
+}
+
+static void print_stats(const char *label, const score_stats_t *s)
+{
+ printf("%s (n=%lu): min=%.3f p5=%.3f p50=%.3f mean=%.3f p95=%.3f p99=%.3f max=%.3f\n",
+ label, (unsigned long)s->n, s->min, s->p5, s->p50, s->mean, s->p95, s->p99, s->max);
+}
+
+esp_err_t calibration_init(void)
+{
+ s_mx = xSemaphoreCreateMutex();
+ s_done = xSemaphoreCreateBinary();
+ return (s_mx && s_done) ? ESP_OK : ESP_ERR_NO_MEM;
+}
+
+app_mode_t calibration_get_mode(void) { return s_mode; }
+
+const char *calibration_mode_name(app_mode_t m)
+{
+ switch (m) {
+ case MODE_NORMAL: return "NORMAL";
+ case MODE_LEARNING: return "LEARNING";
+ case MODE_ADAPTING: return "ADAPTING";
+ case MODE_BACKGROUND_CAL: return "BACKGROUND_CAL";
+ case MODE_TEST_CAL: return "TEST_CAL";
+ }
+ return "?";
+}
+
+bool calibration_get_background(score_stats_t *o) { *o = s_bg; return s_bg.valid; }
+bool calibration_get_buzz(score_stats_t *o) { *o = s_buzz; return s_buzz.valid; }
+
+// ---- frame feed (processing task) -------------------------------------------
+
+void calibration_on_frame(const float fp[NUM_BANDS], bool fp_valid, float rms, float score)
+{
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ app_mode_t m = s_mode;
+ if (m == MODE_NORMAL) {
+ xSemaphoreGive(s_mx);
+ return;
+ }
+ uint32_t i = S.frame_idx++;
+
+ switch (m) {
+ case MODE_LEARNING:
+ case MODE_ADAPTING:
+ if (i < NOISE_FRAMES) {
+ S.noise[i] = rms;
+ if (i == NOISE_FRAMES - 1) {
+ float tmp[NOISE_FRAMES];
+ memcpy(tmp, S.noise, sizeof(tmp));
+ qsort(tmp, NOISE_FRAMES, sizeof(float), cmp_f);
+ float floor_rms = tmp[NOISE_FRAMES / 2]; // median
+ float g = floor_rms * S.noise_mult;
+ S.gate = g > S.min_rms ? g : S.min_rms; // never below the detector gate
+ }
+ } else if (fp_valid && rms >= S.gate) {
+ for (int b = 0; b < NUM_BANDS; b++) S.fp_sum[b] += fp[b];
+ S.count++;
+ }
+ break;
+ case MODE_TEST_CAL:
+ if (fp_valid && rms >= S.min_rms && S.n_scores < MAX_SCORES) {
+ S.scores[S.n_scores++] = score;
+ }
+ break;
+ case MODE_BACKGROUND_CAL:
+ if (S.n_scores < MAX_SCORES) S.scores[S.n_scores++] = fp_valid ? score : 0.0f;
+ if (rms >= S.min_rms) S.n_gated++;
+ if (score >= S.threshold) {
+ S.n_above++;
+ if (rms >= S.min_rms) S.n_above_gated++;
+ }
+ break;
+ default:
+ break;
+ }
+
+ if (S.frame_idx >= S.total_frames) {
+ s_mode = MODE_NORMAL;
+ xSemaphoreGive(s_done);
+ }
+ xSemaphoreGive(s_mx);
+}
+
+// ---- session control (console / button tasks) --------------------------------
+
+static esp_err_t begin(app_mode_t m, uint32_t secs)
+{
+ app_config_t cfg;
+ config_get(&cfg);
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ if (s_mode != MODE_NORMAL) {
+ xSemaphoreGive(s_mx);
+ printf("busy: a %s session is already running\n", calibration_mode_name(s_mode));
+ return ESP_ERR_INVALID_STATE;
+ }
+ memset(&S, 0, sizeof(S));
+ S.total_frames = SECS_TO_FRAMES(secs);
+ S.min_rms = cfg.min_rms;
+ S.threshold = cfg.threshold;
+ S.noise_mult = cfg.noise_mult;
+ xSemaphoreTake(s_done, 0);
+ s_mode = m; // last: processing task starts feeding after this
+ xSemaphoreGive(s_mx);
+ return ESP_OK;
+}
+
+static esp_err_t wait_done(uint32_t secs)
+{
+ for (uint32_t t = 1; t <= secs + 10; t++) {
+ if (xSemaphoreTake(s_done, pdMS_TO_TICKS(1000)) == pdTRUE) {
+ printf("\n");
+ return ESP_OK;
+ }
+ printf(" %lus", (unsigned long)t);
+ fflush(stdout);
+ }
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ s_mode = MODE_NORMAL;
+ xSemaphoreGive(s_mx);
+ printf("\nsession timed out: no audio frames arriving?\n");
+ return ESP_ERR_TIMEOUT;
+}
+
+esp_err_t calibration_run_learn(bool adapt, float w_new)
+{
+ if (adapt && !matcher_has_template()) {
+ printf("adapt needs an existing template; use `learn` first\n");
+ return ESP_ERR_INVALID_STATE;
+ }
+ if (adapt && !(w_new >= 0.0f && w_new <= 1.0f)) {
+ printf("adapt weight must be 0..1\n");
+ return ESP_ERR_INVALID_ARG;
+ }
+ esp_err_t err = begin(adapt ? MODE_ADAPTING : MODE_LEARNING, LEARN_SECS);
+ if (err != ESP_OK) return err;
+ printf("%s: %d s. Stay QUIET for the first second, then press the buzzer a few times.\n",
+ adapt ? "ADAPT" : "LEARN", LEARN_SECS);
+ err = wait_done(LEARN_SECS);
+ if (err != ESP_OK) return err;
+
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ uint32_t count = S.count;
+ float gate = S.gate;
+ float v[NUM_BANDS];
+ for (int b = 0; b < NUM_BANDS; b++) v[b] = count ? S.fp_sum[b] / (float)count : 0.0f;
+ xSemaphoreGive(s_mx);
+
+ printf("noise-gate RMS %.1f, loud frames collected: %lu\n", gate, (unsigned long)count);
+ if (count < MIN_LEARN_FRAMES) {
+ printf("REFUSED: need >= %d loud frames. Press the buzzer longer / closer, or lower noise_mult/min_rms.\n",
+ MIN_LEARN_FRAMES);
+ return ESP_FAIL;
+ }
+ // |mean of unit vectors| = average cosine of the frames to their own mean direction.
+ float s2 = 0;
+ for (int b = 0; b < NUM_BANDS; b++) s2 += v[b] * v[b];
+ printf("cohesion of collected frames: %.3f (close to 1 = consistent sound)\n", sqrtf(s2));
+ if (!features_l2_normalize(v, NUM_BANDS)) {
+ printf("REFUSED: collected fingerprint is degenerate (flat)\n");
+ return ESP_FAIL;
+ }
+ if (adapt) {
+ float cur[NUM_BANDS];
+ matcher_get_template(cur);
+ printf("similarity new vs current template: %.3f\n", features_dot(v, cur));
+ for (int b = 0; b < NUM_BANDS; b++) v[b] = w_new * v[b] + (1.0f - w_new) * cur[b];
+ if (!features_l2_normalize(v, NUM_BANDS)) {
+ printf("REFUSED: blended template degenerate\n");
+ return ESP_FAIL;
+ }
+ }
+ err = matcher_set_template(v, count);
+ if (err != ESP_OK) {
+ printf("saving template failed: %s\n", esp_err_to_name(err));
+ return err;
+ }
+ printf("%s done: template saved to NVS (source now: %s)%s\n", adapt ? "ADAPT" : "LEARN",
+ matcher_source_name(), adapt ? "" : ". Run `selfcheck` to verify.");
+ return ESP_OK;
+}
+
+esp_err_t calibration_run_test(bool verdict)
+{
+ if (!matcher_has_template()) {
+ printf("no template: run make_template.py or `learn` first\n");
+ return ESP_ERR_INVALID_STATE;
+ }
+ esp_err_t err = begin(MODE_TEST_CAL, TEST_SECS);
+ if (err != ESP_OK) return err;
+ printf("%s: %d s. Press the real buzzer now (several presses).\n", verdict ? "SELFCHECK" : "TEST", TEST_SECS);
+ err = wait_done(TEST_SECS);
+ if (err != ESP_OK) return err;
+
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ uint32_t n = S.n_scores;
+ float thr = S.threshold, gate = S.min_rms;
+ score_stats_t st;
+ compute_stats(S.scores, n, &st);
+ xSemaphoreGive(s_mx);
+
+ printf("loud frames (RMS >= %.0f): %lu\n", gate, (unsigned long)n);
+ if (n < MIN_LEARN_FRAMES) {
+ printf("WARNING: only %lu loud frames; too few for reliable stats (press longer or lower min_rms).\n",
+ (unsigned long)n);
+ if (n == 0) return ESP_FAIL;
+ } else {
+ s_buzz = st; // used by autothresh
+ }
+ print_stats("score vs active template", &st);
+ if (verdict && n >= MIN_LEARN_FRAMES) {
+ if (st.p5 >= thr) {
+ printf("VERDICT: GOOD match. 95%% of loud frames clear threshold %.2f.\n", thr);
+ } else if (st.p50 >= thr) {
+ printf("VERDICT: WEAK match. The median frame clears %.2f but the tail does not.\n"
+ " Try `adapt` (blend with live mic) or `learn`, then `selfcheck` again.\n", thr);
+ } else {
+ printf("VERDICT: POOR match (median %.2f < threshold %.2f).\n"
+ " Was that really the buzzer? If yes: `learn` on this mic, or see README about FP_DETREND\n"
+ " (compile-time, feature_params.h) if mic tilt is the issue.\n", st.p50, thr);
+ }
+ }
+ return ESP_OK;
+}
+
+esp_err_t calibration_run_background(void)
+{
+ if (!matcher_has_template()) {
+ printf("no template: run make_template.py or `learn` first\n");
+ return ESP_ERR_INVALID_STATE;
+ }
+ esp_err_t err = begin(MODE_BACKGROUND_CAL, BG_SECS);
+ if (err != ESP_OK) return err;
+ printf("BACKGROUND: %d s of normal ambient sound (do NOT press the buzzer).\n", BG_SECS);
+ err = wait_done(BG_SECS);
+ if (err != ESP_OK) return err;
+
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ uint32_t n = S.n_scores, gated = S.n_gated, above = S.n_above, above_g = S.n_above_gated;
+ float thr = S.threshold;
+ compute_stats(S.scores, n, &s_bg);
+ xSemaphoreGive(s_mx);
+
+ print_stats("background score vs active template", &s_bg);
+ printf("frames passing the loudness gate: %lu / %lu\n", (unsigned long)gated, (unsigned long)n);
+ printf("frames with score >= threshold %.2f: %lu (of which also loud enough to match: %lu)\n",
+ thr, (unsigned long)above, (unsigned long)above_g);
+ return ESP_OK;
+}
+
+esp_err_t calibration_autothresh(void)
+{
+ if (!s_bg.valid || !s_buzz.valid) {
+ printf("need both: `background` and (`test` or `selfcheck`) with >= %d loud frames first\n", MIN_LEARN_FRAMES);
+ return ESP_ERR_INVALID_STATE;
+ }
+ float lo = s_bg.p99, hi = s_buzz.p5;
+ float thr = 0.5f * (lo + hi);
+ if (thr < 0.50f) thr = 0.50f;
+ if (thr > 0.98f) thr = 0.98f;
+ printf("background p99 = %.3f, buzzer p5 = %.3f -> threshold %.3f\n", lo, hi, thr);
+ if (hi <= lo) {
+ printf("WARNING: distributions OVERLAP. Expect false alarms or missed buzzes. "
+ "Improve the template (`learn`), move the mic, or revisit FP_DETREND.\n");
+ }
+ char msg[96], val[24];
+ snprintf(val, sizeof(val), "%.4f", thr);
+ if (config_set_param("threshold", val, msg, sizeof(msg)) != ESP_OK) {
+ printf("could not set threshold: %s\n", msg);
+ return ESP_FAIL;
+ }
+ printf("threshold set and saved.\n");
+ return ESP_OK;
+}
diff --git a/main/calibration.h b/main/calibration.h
new file mode 100644
index 0000000..1585ee3
--- /dev/null
+++ b/main/calibration.h
@@ -0,0 +1,36 @@
+#pragma once
+#include
+#include
+#include "esp_err.h"
+#include "feature_params.h"
+
+typedef enum {
+ MODE_NORMAL = 0,
+ MODE_LEARNING,
+ MODE_ADAPTING,
+ MODE_BACKGROUND_CAL,
+ MODE_TEST_CAL, // also used by `selfcheck`
+} app_mode_t;
+
+typedef struct {
+ bool valid;
+ uint32_t n;
+ float min, p5, p50, mean, p95, p99, max;
+} score_stats_t;
+
+esp_err_t calibration_init(void);
+app_mode_t calibration_get_mode(void); // lock-free read of an aligned word
+const char *calibration_mode_name(app_mode_t m);
+
+// Called by the processing task for every frame while mode != NORMAL.
+void calibration_on_frame(const float fp[NUM_BANDS], bool fp_valid, float rms, float score);
+
+// Blocking sessions, called from console / button tasks. The detector is paused
+// (no alerts) while a session runs. All return ESP_OK on success.
+esp_err_t calibration_run_learn(bool adapt, float weight_new); // 60 s
+esp_err_t calibration_run_test(bool selfcheck_verdict); // 10 s
+esp_err_t calibration_run_background(void); // 60 s
+esp_err_t calibration_autothresh(void);
+
+bool calibration_get_background(score_stats_t *out);
+bool calibration_get_buzz(score_stats_t *out);
diff --git a/main/config_store.c b/main/config_store.c
new file mode 100644
index 0000000..ce85350
--- /dev/null
+++ b/main/config_store.c
@@ -0,0 +1,190 @@
+#include
+#include
+#include
+#include
+#include
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "nvs.h"
+#include "nvs_flash.h"
+#include "config_store.h"
+
+static const char *TAG = "config";
+#define NVS_NS "buzz"
+#define CFG_KEY "cfg"
+#define CFG_MAGIC 0xB0220002u
+#define CFG_MAGIC_PRE_COOLDOWN_MS 0xB0220001u
+
+typedef struct { uint32_t magic; app_config_t cfg; } cfg_blob_t;
+
+typedef enum { PT_FLOAT, PT_UINT } ptype_t;
+typedef struct {
+ const char *name;
+ ptype_t type;
+ size_t off;
+ double min, max;
+ const char *help;
+} param_t;
+
+#define P(n, t, mn, mx, h) { #n, t, offsetof(app_config_t, n), mn, mx, h }
+static const param_t s_params[] = {
+ P(threshold, PT_FLOAT, 0.10, 0.999, "cosine threshold for a matching frame"),
+ P(min_rms, PT_FLOAT, 0.0, 32767.0, "loudness gate (int16 RMS)"),
+ P(win_n, PT_UINT, 1, 32, "sliding window length N (frames)"),
+ P(win_m, PT_UINT, 1, 32, "matches M needed in window (<= N)"),
+ P(cooldown_ms, PT_UINT, 0, 86400000, "cooldown after alert (ms)"),
+ P(noise_mult, PT_FLOAT, 1.0, 100.0, "learn/adapt gate = noise floor x this"),
+ P(adapt_weight, PT_FLOAT, 0.0, 1.0, "weight of new template in adapt"),
+ P(monitor_ms, PT_UINT, 10, 60000, "min interval between monitor lines (ms)"),
+ P(heartbeat_h, PT_UINT, 0, 720, "heartbeat period (h), 0 = off"),
+};
+#define NPARAMS (sizeof(s_params) / sizeof(s_params[0]))
+
+static app_config_t s_cfg;
+static SemaphoreHandle_t s_mx;
+
+static void load_defaults(app_config_t *c)
+{
+ c->threshold = CONFIG_BUZZ_THRESHOLD_MILLI / 1000.0f;
+ c->min_rms = (float)CONFIG_BUZZ_MIN_RMS;
+ c->win_n = CONFIG_BUZZ_WIN_N;
+ c->win_m = CONFIG_BUZZ_WIN_M;
+ c->cooldown_ms = CONFIG_BUZZ_COOLDOWN_MS;
+ c->noise_mult = CONFIG_BUZZ_NOISE_MULT_X10 / 10.0f;
+ c->adapt_weight = CONFIG_BUZZ_ADAPT_WEIGHT_PCT / 100.0f;
+ c->monitor_ms = CONFIG_BUZZ_MONITOR_MS;
+ c->heartbeat_h = CONFIG_BUZZ_HEARTBEAT_HOURS;
+ if (c->win_m > c->win_n) c->win_m = c->win_n;
+}
+
+static esp_err_t save_locked(void)
+{
+ nvs_handle_t h;
+ esp_err_t err = nvs_open(NVS_NS, NVS_READWRITE, &h);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "nvs_open: %s", esp_err_to_name(err));
+ return err;
+ }
+ cfg_blob_t blob = { .magic = CFG_MAGIC, .cfg = s_cfg };
+ err = nvs_set_blob(h, CFG_KEY, &blob, sizeof(blob));
+ if (err == ESP_OK) err = nvs_commit(h);
+ if (err != ESP_OK) ESP_LOGE(TAG, "save failed: %s", esp_err_to_name(err));
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t config_init(void)
+{
+ s_mx = xSemaphoreCreateMutex();
+ if (!s_mx) return ESP_ERR_NO_MEM;
+
+ esp_err_t err = nvs_flash_init();
+ if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+ ESP_LOGW(TAG, "erasing NVS (%s)", esp_err_to_name(err));
+ ESP_ERROR_CHECK(nvs_flash_erase());
+ err = nvs_flash_init();
+ }
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "nvs_flash_init: %s", esp_err_to_name(err));
+ return err;
+ }
+
+ load_defaults(&s_cfg);
+ nvs_handle_t h;
+ if (nvs_open(NVS_NS, NVS_READONLY, &h) == ESP_OK) {
+ cfg_blob_t blob;
+ size_t len = sizeof(blob);
+ if (nvs_get_blob(h, CFG_KEY, &blob, &len) == ESP_OK && len == sizeof(blob)) {
+ if (blob.magic == CFG_MAGIC) {
+ s_cfg = blob.cfg;
+ ESP_LOGI(TAG, "loaded saved config from NVS");
+ } else if (blob.magic == CFG_MAGIC_PRE_COOLDOWN_MS) {
+ s_cfg = blob.cfg;
+ s_cfg.cooldown_ms = CONFIG_BUZZ_COOLDOWN_MS;
+ ESP_LOGI(TAG, "migrating saved config to millisecond cooldown");
+ err = save_locked();
+ if (err != ESP_OK) {
+ nvs_close(h);
+ return err;
+ }
+ } else {
+ ESP_LOGI(TAG, "no compatible saved config, using Kconfig defaults");
+ }
+ } else {
+ ESP_LOGI(TAG, "no compatible saved config, using Kconfig defaults");
+ }
+ nvs_close(h);
+ }
+ return ESP_OK;
+}
+
+void config_get(app_config_t *out)
+{
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ *out = s_cfg;
+ xSemaphoreGive(s_mx);
+}
+
+esp_err_t config_set_param(const char *name, const char *value, char *err, size_t err_len)
+{
+ for (size_t i = 0; i < NPARAMS; i++) {
+ const param_t *p = &s_params[i];
+ if (strcmp(p->name, name) != 0) continue;
+
+ char *end = NULL;
+ errno = 0;
+ double v = strtod(value, &end);
+ if (end == value || *end != '\0' || errno != 0) {
+ snprintf(err, err_len, "'%s' is not a number", value);
+ return ESP_ERR_INVALID_ARG;
+ }
+ if (v < p->min || v > p->max) {
+ snprintf(err, err_len, "%s must be in [%g, %g]", p->name, p->min, p->max);
+ return ESP_ERR_INVALID_ARG;
+ }
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ app_config_t next = s_cfg;
+ void *dst = (uint8_t *)&next + p->off;
+ if (p->type == PT_FLOAT) *(float *)dst = (float)v;
+ else *(uint32_t *)dst = (uint32_t)(v + 0.5);
+ if (next.win_m > next.win_n) {
+ xSemaphoreGive(s_mx);
+ snprintf(err, err_len, "win_m (%lu) must be <= win_n (%lu)",
+ (unsigned long)next.win_m, (unsigned long)next.win_n);
+ return ESP_ERR_INVALID_ARG;
+ }
+ s_cfg = next;
+ esp_err_t e = save_locked();
+ xSemaphoreGive(s_mx);
+ if (e != ESP_OK) snprintf(err, err_len, "applied but NVS save failed");
+ return e;
+ }
+ snprintf(err, err_len, "unknown parameter '%s' (see `get`)", name);
+ return ESP_ERR_NOT_FOUND;
+}
+
+esp_err_t config_reset_defaults(void)
+{
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ load_defaults(&s_cfg);
+ esp_err_t e = save_locked();
+ xSemaphoreGive(s_mx);
+ return e;
+}
+
+int config_param_count(void) { return (int)NPARAMS; }
+const char *config_param_name(int i) { return s_params[i].name; }
+
+void config_param_format(int i, char *buf, size_t len)
+{
+ const param_t *p = &s_params[i];
+ app_config_t c;
+ config_get(&c);
+ const void *src = (const uint8_t *)&c + p->off;
+ if (p->type == PT_FLOAT) {
+ snprintf(buf, len, "%-13s = %-9.4g (%g..%g) %s", p->name, *(const float *)src, p->min, p->max, p->help);
+ } else {
+ snprintf(buf, len, "%-13s = %-9lu (%g..%g) %s", p->name, (unsigned long)*(const uint32_t *)src, p->min, p->max, p->help);
+ }
+}
diff --git a/main/config_store.h b/main/config_store.h
new file mode 100644
index 0000000..38d09b6
--- /dev/null
+++ b/main/config_store.h
@@ -0,0 +1,26 @@
+#pragma once
+#include
+#include
+#include "esp_err.h"
+
+// All runtime-tunable parameters in one struct. Defaults come from Kconfig, the
+// saved copy (NVS) wins when present and layout-compatible.
+typedef struct {
+ float threshold; // cosine similarity needed for a frame to "match"
+ float min_rms; // loudness gate (int16 RMS); quieter frames never match
+ uint32_t win_n; // sliding window length (frames), 1..32
+ uint32_t win_m; // matches needed inside the window, 1..win_n
+ uint32_t cooldown_ms; // quiet period after an alert
+ float noise_mult; // learn/adapt gate = noise floor * this
+ float adapt_weight; // weight of the NEW template in `adapt` (0..1)
+ uint32_t monitor_ms; // min interval between `monitor` lines
+ uint32_t heartbeat_h; // heartbeat period in hours, 0 = off
+} app_config_t;
+
+esp_err_t config_init(void); // nvs_flash_init + load
+void config_get(app_config_t *out); // thread-safe copy
+esp_err_t config_set_param(const char *name, const char *value, char *err, size_t err_len);
+esp_err_t config_reset_defaults(void);
+int config_param_count(void);
+const char *config_param_name(int i);
+void config_param_format(int i, char *buf, size_t len); // "0.800 (range 0.1..0.999) help"
diff --git a/main/console.c b/main/console.c
new file mode 100644
index 0000000..71c182d
--- /dev/null
+++ b/main/console.c
@@ -0,0 +1,227 @@
+#include
+#include
+#include
+#include
+#include "esp_console.h"
+#include "esp_heap_caps.h"
+#include "esp_log.h"
+#include "esp_system.h"
+#include "esp_timer.h"
+#include "sdkconfig.h"
+#include "audio_in.h"
+#include "calibration.h"
+#include "config_store.h"
+#include "console.h"
+#include "fingerprint.h"
+#include "matcher.h"
+#include "notifier.h"
+#include "processing.h"
+#include "test_vector.h"
+
+static int cmd_selftest(int argc, char **argv)
+{
+#if !TEST_VECTOR_VALID
+ printf("no test vector compiled in. Run tools/make_template.py and rebuild.\n");
+ return 0;
+#else
+ static features_ctx_t ctx; // own scratch: never shares with the processing task
+ float fp[NUM_BANDS], rms = 0;
+ bool ok = features_compute(&ctx, TEST_VECTOR_PCM, fp, &rms);
+ float maxerr = 0;
+ for (int b = 0; b < NUM_BANDS; b++) {
+ float e = fabsf(fp[b] - TEST_VECTOR_FP[b]);
+ if (!(e <= maxerr)) maxerr = e; // NaN-safe
+ }
+ float rms_rel = fabsf(rms - TEST_VECTOR_RMS) / fmaxf(TEST_VECTOR_RMS, 1.0f);
+ printf("fingerprint max abs error: %.3e (tol 1e-3)\n", maxerr);
+ printf("rms: firmware %.3f, expected %.3f (rel err %.2e)\n", rms, TEST_VECTOR_RMS, rms_rel);
+ bool pass = ok && maxerr < 1e-3f && rms_rel < 1e-3f;
+ printf("selftest: %s\n", pass ? "PASS" : "FAIL");
+ if (!pass) printf("Parity broken: feature_params.h changed since make_template.py ran? Re-run it and rebuild.\n");
+ return 0;
+#endif
+}
+
+static int cmd_selfcheck(int argc, char **argv) { calibration_run_test(true); return 0; }
+static int cmd_test(int argc, char **argv) { calibration_run_test(false); return 0; }
+static int cmd_background(int argc, char **argv) { calibration_run_background(); return 0; }
+static int cmd_autothresh(int argc, char **argv) { calibration_autothresh(); return 0; }
+
+static int cmd_learn(int argc, char **argv)
+{
+ if (argc >= 2 && strcmp(argv[1], "reset") == 0) {
+ esp_err_t e = matcher_reset();
+ printf("learn reset: %s. Active template: %s\n", e == ESP_OK ? "NVS template cleared" : esp_err_to_name(e),
+ matcher_source_name());
+ return 0;
+ }
+ calibration_run_learn(false, 0.0f);
+ return 0;
+}
+
+static int cmd_adapt(int argc, char **argv)
+{
+ app_config_t cfg;
+ config_get(&cfg);
+ float w = cfg.adapt_weight;
+ if (argc >= 2) {
+ char *end;
+ w = strtof(argv[1], &end);
+ if (*end != '\0' || !(w >= 0.0f && w <= 1.0f)) {
+ printf("usage: adapt [weight 0..1] (weight of the NEW template)\n");
+ return 0;
+ }
+ }
+ calibration_run_learn(true, w);
+ return 0;
+}
+
+static int cmd_monitor(int argc, char **argv)
+{
+ if (argc < 2 || (strcmp(argv[1], "on") && strcmp(argv[1], "off"))) {
+ printf("usage: monitor on|off (currently %s)\n", processing_get_monitor() ? "on" : "off");
+ return 0;
+ }
+ processing_set_monitor(strcmp(argv[1], "on") == 0);
+ printf("monitor %s\n", argv[1]);
+ return 0;
+}
+
+static int cmd_set(int argc, char **argv)
+{
+ if (argc != 3) {
+ printf("usage: set (see `get`)\n");
+ return 0;
+ }
+ char err[96];
+ if (config_set_param(argv[1], argv[2], err, sizeof(err)) == ESP_OK) {
+ printf("ok, saved\n");
+ } else {
+ printf("error: %s\n", err);
+ }
+ return 0;
+}
+
+static int cmd_get(int argc, char **argv)
+{
+ char line[200];
+ for (int i = 0; i < config_param_count(); i++) {
+ if (argc >= 2 && strcmp(argv[1], config_param_name(i)) != 0) continue;
+ config_param_format(i, line, sizeof(line));
+ printf("%s\n", line);
+ }
+ return 0;
+}
+
+static int cmd_status(int argc, char **argv)
+{
+ proc_stats_t ps;
+ processing_get_stats(&ps);
+ notifier_stats_t ns;
+ notifier_get_stats(&ns);
+ app_config_t cfg;
+ config_get(&cfg);
+ float sug;
+
+ printf("uptime: %llu s\n", (unsigned long long)(esp_timer_get_time() / 1000000));
+ printf("mode / state: %s / %s\n", calibration_mode_name(calibration_get_mode()), detector_state_name(ps.state));
+ printf("template: %s, %lu frames%s\n", matcher_source_name(), (unsigned long)matcher_frame_count(),
+ matcher_has_template() ? "" : " <-- run tools/make_template.py + rebuild, or `learn`");
+ if (matcher_suggested_threshold(&sug)) printf(" template.h suggests threshold %.3f\n", sug);
+ printf("detector: threshold %.3f, min_rms %.0f, window %lu/%lu, cooldown %lu ms\n", cfg.threshold, cfg.min_rms,
+ (unsigned long)cfg.win_m, (unsigned long)cfg.win_n, (unsigned long)cfg.cooldown_ms);
+ printf("last frame: rms %.1f, score %+.3f, match %d\n", ps.last_rms, ps.last_score, ps.last_match);
+ printf("triggers: %lu (last at uptime %lu s)\n", (unsigned long)ps.triggers, (unsigned long)ps.last_trigger_s);
+ printf("frames: processed %lu, captured %lu, dropped %lu\n", (unsigned long)ps.frames,
+ (unsigned long)audio_in_frames_captured(), (unsigned long)audio_in_frames_dropped());
+ printf("proc time: avg %lu us, max %lu us (budget %d us per frame)\n", (unsigned long)ps.proc_avg_us,
+ (unsigned long)ps.proc_max_us, HOP_LEN * 1000000 / FS_HZ);
+ printf("wifi: %s%s%s\n", ns.wifi_configured ? (ns.wifi_connected ? "connected " : "DISCONNECTED") : "not configured",
+ ns.wifi_connected ? ns.ip : "", "");
+ printf("notifications: ok %lu, failed %lu, dropped %lu, topic %s%s\n", (unsigned long)ns.sent_ok,
+ (unsigned long)ns.sent_failed, (unsigned long)ns.dropped, CONFIG_BUZZ_NTFY_TOPIC,
+ cfg.heartbeat_h ? " (heartbeat on)" : "");
+ printf("heap free: %lu bytes\n", (unsigned long)esp_get_free_heap_size());
+ return 0;
+}
+
+static int cmd_fingerprint(int argc, char **argv)
+{
+ float live[NUM_BANDS], tpl[NUM_BANDS];
+ processing_get_last_fp(live);
+ matcher_get_template(tpl);
+ const int *e = features_band_edges();
+ printf("band bins live template (%s)\n", matcher_source_name());
+ for (int b = 0; b < NUM_BANDS; b++) {
+ printf("%3d %3d-%-3d %+.4f %+.4f\n", b, e[b], e[b + 1] - 1, live[b], tpl[b]);
+ }
+ return 0;
+}
+
+static int cmd_alert(int argc, char **argv)
+{
+ printf(notifier_send(NOTIF_TEST, "Test notification") ? "queued\n" : "queue full\n");
+ return 0;
+}
+
+static int cmd_reboot(int argc, char **argv)
+{
+ printf("rebooting...\n");
+ fflush(stdout);
+ esp_restart();
+ return 0;
+}
+
+static void reg(const char *name, const char *help, const char *hint, esp_console_cmd_func_t fn)
+{
+ const esp_console_cmd_t c = { .command = name, .help = help, .hint = hint, .func = fn };
+ esp_err_t err = esp_console_cmd_register(&c);
+ if (err != ESP_OK) ESP_LOGE("console", "register %s: %s", name, esp_err_to_name(err));
+}
+
+esp_err_t console_start(void)
+{
+ esp_console_repl_t *repl = NULL;
+ esp_console_repl_config_t rc = ESP_CONSOLE_REPL_CONFIG_DEFAULT();
+ rc.prompt = "buzz> ";
+ rc.max_cmdline_length = 128;
+ rc.task_stack_size = 8192;
+
+ esp_err_t err;
+#if CONFIG_ESP_CONSOLE_UART_DEFAULT || CONFIG_ESP_CONSOLE_UART_CUSTOM
+ esp_console_dev_uart_config_t hw = ESP_CONSOLE_DEV_UART_CONFIG_DEFAULT();
+ err = esp_console_new_repl_uart(&hw, &rc, &repl);
+#elif CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG
+ esp_console_dev_usb_serial_jtag_config_t hw = ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT();
+ err = esp_console_new_repl_usb_serial_jtag(&hw, &rc, &repl);
+#elif CONFIG_ESP_CONSOLE_USB_CDC
+ esp_console_dev_usb_cdc_config_t hw = ESP_CONSOLE_DEV_CDC_CONFIG_DEFAULT();
+ err = esp_console_new_repl_usb_cdc(&hw, &rc, &repl);
+#else
+#error "Unsupported console transport"
+#endif
+ if (err != ESP_OK) {
+ ESP_LOGE("console", "REPL create: %s", esp_err_to_name(err));
+ return err;
+ }
+
+ esp_console_register_help_command();
+ reg("selftest", "Run firmware feature pipeline on the built-in test vector (C/Python parity)", NULL, cmd_selftest);
+ reg("selfcheck", "10 s: press the real buzzer; score vs ACTIVE template + verdict", NULL, cmd_selfcheck);
+ reg("learn", "60 s: learn template from the real mic (`learn reset` = clear NVS template)", "[reset]", cmd_learn);
+ reg("adapt", "60 s: blend newly heard buzzer into the active template", "[weight 0..1]", cmd_adapt);
+ reg("background", "60 s ambient score measurement vs active template", NULL, cmd_background);
+ reg("test", "10 s: press the buzzer; score distribution of loud frames", NULL, cmd_test);
+ reg("autothresh", "set threshold between background p99 and buzzer p5", NULL, cmd_autothresh);
+ reg("monitor", "per-frame live line on/off", "on|off", cmd_monitor);
+ reg("set", "set a parameter (persisted)", " ", cmd_set);
+ reg("get", "show parameters", "[param]", cmd_get);
+ reg("status", "template source, detector state, timing, Wi-Fi", NULL, cmd_status);
+ reg("fingerprint", "print live fingerprint and active template", NULL, cmd_fingerprint);
+ reg("alert", "send a test notification", NULL, cmd_alert);
+ reg("reboot", "restart", NULL, cmd_reboot);
+
+ err = esp_console_start_repl(repl);
+ if (err != ESP_OK) ESP_LOGE("console", "start_repl: %s", esp_err_to_name(err));
+ return err;
+}
diff --git a/main/console.h b/main/console.h
new file mode 100644
index 0000000..6519030
--- /dev/null
+++ b/main/console.h
@@ -0,0 +1,3 @@
+#pragma once
+#include "esp_err.h"
+esp_err_t console_start(void); // esp_console REPL on the configured console device
diff --git a/main/detector.c b/main/detector.c
new file mode 100644
index 0000000..79f7de1
--- /dev/null
+++ b/main/detector.c
@@ -0,0 +1,51 @@
+#include
+#include "detector.h"
+
+void detector_init(detector_t *d) { memset(d, 0, sizeof(*d)); d->state = DET_IDLE; }
+void detector_reset(detector_t *d) { d->state = DET_IDLE; d->hist = 0; }
+
+bool detector_is_match(float rms, float score, float min_rms, float threshold)
+{
+ return rms >= min_rms && score >= threshold;
+}
+
+bool detector_update(detector_t *d, bool match, uint32_t now_ms, const detector_cfg_t *cfg)
+{
+ uint32_t n = cfg->win_n < 1 ? 1 : (cfg->win_n > 32 ? 32 : cfg->win_n);
+ uint32_t m = cfg->win_m < 1 ? 1 : (cfg->win_m > n ? n : cfg->win_m);
+
+ switch (d->state) {
+ case DET_IDLE: {
+ d->hist = (d->hist << 1) | (match ? 1u : 0u);
+ uint32_t mask = (n == 32) ? 0xFFFFFFFFu : ((1u << n) - 1u);
+ if ((uint32_t)__builtin_popcount(d->hist & mask) >= m) {
+ d->state = DET_TRIGGERED;
+ d->triggers++;
+ return true;
+ }
+ break;
+ }
+ case DET_TRIGGERED:
+ d->state = DET_COOLDOWN;
+ d->cooldown_start_ms = now_ms;
+ d->hist = 0;
+ break;
+ case DET_COOLDOWN:
+ if ((uint32_t)(now_ms - d->cooldown_start_ms) >= cfg->cooldown_ms) {
+ d->state = DET_IDLE;
+ d->hist = 0;
+ }
+ break;
+ }
+ return false;
+}
+
+const char *detector_state_name(det_state_t s)
+{
+ switch (s) {
+ case DET_IDLE: return "IDLE";
+ case DET_TRIGGERED: return "TRIGGERED";
+ case DET_COOLDOWN: return "COOLDOWN";
+ }
+ return "?";
+}
diff --git a/main/detector.h b/main/detector.h
new file mode 100644
index 0000000..d1c962e
--- /dev/null
+++ b/main/detector.h
@@ -0,0 +1,31 @@
+#pragma once
+// Pure state machine: no ESP-IDF dependencies, can be compiled and tested on a PC.
+#include
+#include
+
+typedef enum { DET_IDLE = 0, DET_TRIGGERED, DET_COOLDOWN } det_state_t;
+
+typedef struct {
+ uint32_t win_n; // window length in frames (clamped to 1..32)
+ uint32_t win_m; // matches required (clamped to 1..win_n)
+ uint32_t cooldown_ms;
+} detector_cfg_t;
+
+typedef struct {
+ det_state_t state;
+ uint32_t hist; // bit 0 = newest frame, 1 = matched
+ uint32_t cooldown_start_ms;
+ uint32_t triggers;
+} detector_t;
+
+void detector_init(detector_t *d);
+void detector_reset(detector_t *d); // back to IDLE with empty history
+
+// Loudness gate AND score >= threshold.
+bool detector_is_match(float rms, float score, float min_rms, float threshold);
+
+// Feed one frame. Returns true exactly once per trigger (IDLE -> TRIGGERED).
+// TRIGGERED lasts one frame, then COOLDOWN until cooldown_ms elapsed, then IDLE
+// with a cleared history (the buzzer's tail cannot re-trigger).
+bool detector_update(detector_t *d, bool match, uint32_t now_ms, const detector_cfg_t *cfg);
+const char *detector_state_name(det_state_t s);
diff --git a/main/feature_params.h b/main/feature_params.h
new file mode 100644
index 0000000..5cef775
--- /dev/null
+++ b/main/feature_params.h
@@ -0,0 +1,24 @@
+// feature_params.h -- SINGLE SOURCE OF TRUTH for the audio feature pipeline.
+//
+// This file contains ONLY #define lines (plus comments and an include guard).
+// tools/make_template.py parses it with a regex, so firmware and Python can
+// never silently disagree. Keep every value a plain numeric literal.
+//
+// Band edges (implemented identically in fingerprint.c and make_template.py):
+// f_b = F_MIN_HZ * (F_MAX_HZ / F_MIN_HZ) ^ (b / NUM_BANDS), b = 0..NUM_BANDS
+// bin_b = floor(f_b * FRAME_LEN / FS_HZ + 0.5) (round half up, double math)
+// if bin_b < bin_(b-1) + 1 then bin_b = bin_(b-1) + 1 (>= 1 bin per band)
+// band b covers FFT bins [bin_b, bin_(b+1)). bin_NUM_BANDS must be <= FRAME_LEN/2.
+#ifndef FEATURE_PARAMS_H
+#define FEATURE_PARAMS_H
+
+#define FS_HZ 16000 // sample rate of the whole pipeline
+#define FRAME_LEN 1024 // samples per analysis frame (power of two)
+#define HOP_LEN 512 // hop = 32 ms at 16 kHz
+#define NUM_BANDS 32 // log-spaced bands in the fingerprint
+#define F_MIN_HZ 100 // lower edge of band 0
+#define F_MAX_HZ 7500 // nominal upper edge of the last band
+#define LOG_EPS 1e-10f // added to band power before log (power is of samples scaled to +-1.0)
+#define FP_DETREND 1 // 1: remove linear tilt across band index, 0: off
+
+#endif
diff --git a/main/fingerprint.c b/main/fingerprint.c
new file mode 100644
index 0000000..0e5a102
--- /dev/null
+++ b/main/fingerprint.c
@@ -0,0 +1,141 @@
+// Spectral fingerprint. MUST stay numerically identical to tools/make_template.py.
+#include
+#include
+#include "esp_log.h"
+#include "dsps_fft2r.h"
+#include "fingerprint.h"
+
+static const char *TAG = "features";
+
+static float s_window[FRAME_LEN];
+static int s_edges[NUM_BANDS + 1];
+static float s_inv_width[NUM_BANDS];
+static float s_sxx; // sum of squared centred band indices (detrend)
+static bool s_ready;
+
+esp_err_t features_init(void)
+{
+ if (s_ready) {
+ return ESP_OK;
+ }
+ // Periodic Hann: w[n] = 0.5 - 0.5 cos(2 pi n / N)
+ for (int n = 0; n < FRAME_LEN; n++) {
+ s_window[n] = 0.5f - 0.5f * cosf(2.0f * (float)M_PI * (float)n / (float)FRAME_LEN);
+ }
+
+ // Log-spaced band edges, see feature_params.h. Double math + floor(x+0.5) so
+ // C and numpy can never disagree on rounding of exact .5 values.
+ const double ratio = (double)F_MAX_HZ / (double)F_MIN_HZ;
+ for (int b = 0; b <= NUM_BANDS; b++) {
+ double f = (double)F_MIN_HZ * pow(ratio, (double)b / (double)NUM_BANDS);
+ int bin = (int)floor(f * (double)FRAME_LEN / (double)FS_HZ + 0.5);
+ if (b > 0 && bin < s_edges[b - 1] + 1) {
+ bin = s_edges[b - 1] + 1; // guarantee >= 1 bin per band
+ }
+ s_edges[b] = bin;
+ }
+ if (s_edges[NUM_BANDS] > FRAME_LEN / 2) {
+ ESP_LOGE(TAG, "band edges exceed Nyquist (%d > %d): lower NUM_BANDS or raise F_MIN_HZ",
+ s_edges[NUM_BANDS], FRAME_LEN / 2);
+ return ESP_ERR_INVALID_ARG;
+ }
+ for (int b = 0; b < NUM_BANDS; b++) {
+ s_inv_width[b] = 1.0f / (float)(s_edges[b + 1] - s_edges[b]);
+ }
+ s_sxx = 0.0f;
+ for (int b = 0; b < NUM_BANDS; b++) {
+ float xc = (float)b - 0.5f * (float)(NUM_BANDS - 1);
+ s_sxx += xc * xc;
+ }
+
+ esp_err_t err = dsps_fft2r_init_fc32(NULL, FRAME_LEN);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "dsps_fft2r_init_fc32: %s", esp_err_to_name(err));
+ return err;
+ }
+ s_ready = true;
+ return ESP_OK;
+}
+
+const int *features_band_edges(void) { return s_edges; }
+
+bool features_l2_normalize(float *v, int n)
+{
+ float s = 0.0f;
+ for (int i = 0; i < n; i++) s += v[i] * v[i];
+ float nrm = sqrtf(s);
+ if (!(nrm > 1e-3f)) { // flat or NaN (silence leaves ~1e-5 float residue)
+ memset(v, 0, (size_t)n * sizeof(float));
+ return false;
+ }
+ float inv = 1.0f / nrm;
+ for (int i = 0; i < n; i++) v[i] *= inv;
+ return true;
+}
+
+float features_dot(const float *a, const float *b)
+{
+ float s = 0.0f;
+ for (int i = 0; i < NUM_BANDS; i++) s += a[i] * b[i];
+ if (s > 1.0f) s = 1.0f;
+ if (s < -1.0f) s = -1.0f;
+ return s;
+}
+
+bool features_compute(features_ctx_t *ctx, const int16_t *pcm, float fp[NUM_BANDS], float *rms_out)
+{
+ // 1) DC removal per frame (exact integer sum, then float).
+ int32_t isum = 0;
+ for (int i = 0; i < FRAME_LEN; i++) isum += pcm[i];
+ const float mean = (float)isum / (float)FRAME_LEN;
+
+ // 2) RMS of the DC-free frame (int16 units) + windowed, +-1.0-scaled FFT input.
+ float sq = 0.0f;
+ for (int i = 0; i < FRAME_LEN; i++) {
+ float d = (float)pcm[i] - mean;
+ sq += d * d;
+ ctx->fft[2 * i] = d * (1.0f / 32768.0f) * s_window[i];
+ ctx->fft[2 * i + 1] = 0.0f;
+ }
+ const float rms = sqrtf(sq / (float)FRAME_LEN);
+ if (rms_out) *rms_out = rms;
+
+ // 3) FFT (decimation-in-frequency + bit reversal) -> bins 0..FRAME_LEN/2-1.
+ if (dsps_fft2r_fc32(ctx->fft, FRAME_LEN) != ESP_OK ||
+ dsps_bit_rev_fc32(ctx->fft, FRAME_LEN) != ESP_OK) {
+ memset(fp, 0, NUM_BANDS * sizeof(float));
+ return false;
+ }
+
+ // 4) Mean power per band -> natural log (eps guards log(0)).
+ float mean_log = 0.0f;
+ for (int b = 0; b < NUM_BANDS; b++) {
+ float acc = 0.0f;
+ for (int k = s_edges[b]; k < s_edges[b + 1]; k++) {
+ float re = ctx->fft[2 * k], im = ctx->fft[2 * k + 1];
+ acc += re * re + im * im;
+ }
+ fp[b] = logf(acc * s_inv_width[b] + LOG_EPS);
+ mean_log += fp[b];
+ }
+ // 5) Subtract the mean across bands: removes overall level (gain, distance).
+ mean_log /= (float)NUM_BANDS;
+ for (int b = 0; b < NUM_BANDS; b++) fp[b] -= mean_log;
+
+#if FP_DETREND
+ // 6) Remove the least-squares line over band index. A mic's frequency-response
+ // tilt looks like a linear slope in log-spectrum, so this cancels it. Vector is
+ // already zero-mean, so the fit is slope = / .
+ float sxv = 0.0f;
+ for (int b = 0; b < NUM_BANDS; b++) {
+ sxv += fp[b] * ((float)b - 0.5f * (float)(NUM_BANDS - 1));
+ }
+ const float slope = sxv / s_sxx;
+ for (int b = 0; b < NUM_BANDS; b++) {
+ fp[b] -= slope * ((float)b - 0.5f * (float)(NUM_BANDS - 1));
+ }
+#endif
+
+ // 7) L2 normalise so cosine similarity is a plain dot product.
+ return features_l2_normalize(fp, NUM_BANDS);
+}
diff --git a/main/fingerprint.h b/main/fingerprint.h
new file mode 100644
index 0000000..b227140
--- /dev/null
+++ b/main/fingerprint.h
@@ -0,0 +1,22 @@
+#pragma once
+#include
+#include
+#include "esp_err.h"
+#include "feature_params.h"
+
+// Scratch for one FFT. Each caller thread owns its own context (processing task,
+// selftest) so features_compute() needs no locking. 16-byte aligned for the S3 FFT.
+typedef struct {
+ float fft[2 * FRAME_LEN] __attribute__((aligned(16)));
+} features_ctx_t;
+
+esp_err_t features_init(void); // window, band edges, FFT tables (idempotent)
+
+// pcm: FRAME_LEN int16 samples. Outputs a unit-norm fingerprint and the DC-free RMS
+// (int16 units). Returns false if the fingerprint is degenerate (silence / flat):
+// fp is then all zeros and scores 0.
+bool features_compute(features_ctx_t *ctx, const int16_t *pcm, float fp[NUM_BANDS], float *rms_out);
+
+const int *features_band_edges(void); // NUM_BANDS + 1 FFT-bin edges
+bool features_l2_normalize(float *v, int n); // false (and zeros) if norm ~ 0
+float features_dot(const float *a, const float *b); // cosine for unit vectors
diff --git a/main/idf_component.yml b/main/idf_component.yml
index d57b77d..d2dd69f 100644
--- a/main/idf_component.yml
+++ b/main/idf_component.yml
@@ -1,2 +1,4 @@
dependencies:
- espressif/led_strip: "^3.0.0"
+ idf: ">=5.2"
+ espressif/esp-dsp: ">=1.4.0"
+ espressif/led_strip: ">=3.0.0"
diff --git a/main/led.c b/main/led.c
new file mode 100644
index 0000000..94650f1
--- /dev/null
+++ b/main/led.c
@@ -0,0 +1,41 @@
+#include "esp_log.h"
+#include "led.h"
+#include "sdkconfig.h"
+
+#if CONFIG_BUZZ_RGB_LED_ENABLE
+#include "driver/rmt_types.h"
+#include "led_strip.h"
+#include "board_config.h"
+
+static led_strip_handle_t s_strip;
+
+esp_err_t led_init(void)
+{
+ led_strip_config_t strip_cfg = {
+ .strip_gpio_num = RGB_LED_GPIO,
+ .max_leds = 1,
+ };
+ led_strip_rmt_config_t rmt_cfg = {
+ .clk_src = RMT_CLK_SRC_DEFAULT,
+ .resolution_hz = 10 * 1000 * 1000,
+ };
+ esp_err_t err = led_strip_new_rmt_device(&strip_cfg, &rmt_cfg, &s_strip);
+ if (err != ESP_OK) {
+ ESP_LOGW("led", "led_strip init failed: %s (LED disabled)", esp_err_to_name(err));
+ s_strip = NULL;
+ return err;
+ }
+ led_strip_clear(s_strip);
+ return ESP_OK;
+}
+
+void led_set(uint8_t r, uint8_t g, uint8_t b)
+{
+ if (!s_strip) return;
+ led_strip_set_pixel(s_strip, 0, r, g, b);
+ led_strip_refresh(s_strip);
+}
+#else
+esp_err_t led_init(void) { return ESP_OK; }
+void led_set(uint8_t r, uint8_t g, uint8_t b) { (void)r; (void)g; (void)b; }
+#endif
diff --git a/main/led.h b/main/led.h
new file mode 100644
index 0000000..49311a5
--- /dev/null
+++ b/main/led.h
@@ -0,0 +1,5 @@
+#pragma once
+#include
+#include "esp_err.h"
+esp_err_t led_init(void);
+void led_set(uint8_t r, uint8_t g, uint8_t b); // no-op if the LED is disabled in Kconfig
diff --git a/main/main.c b/main/main.c
index a151240..1aa0c67 100644
--- a/main/main.c
+++ b/main/main.c
@@ -1,139 +1,78 @@
-#include
-#include
-
+// Door buzzer detector: spectral-template matching on an I2S MEMS mic, ntfy push alerts.
+#include
#include "driver/gpio.h"
-#include "driver/i2s_std.h"
-#include "esp_err.h"
#include "esp_log.h"
+#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
-#include "led_strip.h"
+#include "freertos/task.h"
+#include "audio_in.h"
+#include "board_config.h"
+#include "calibration.h"
+#include "config_store.h"
+#include "console.h"
+#include "led.h"
+#include "matcher.h"
+#include "notifier.h"
+#include "processing.h"
-#define I2S_BCLK_GPIO GPIO_NUM_3
-#define I2S_WS_GPIO GPIO_NUM_4
-#define I2S_DATA_GPIO GPIO_NUM_5
-#define RGB_LED_GPIO GPIO_NUM_48
+static const char *TAG = "main";
-#define SAMPLE_RATE_HZ 16000
-#define SAMPLES_PER_READ 256
-#define GREEN_NOISE_RMS 100000.0f
-#define RED_NOISE_RMS 1000000.0f
-#define LED_BRIGHTNESS 64
-
-static const char *TAG = "buzz_detector";
-
-static led_strip_handle_t init_rgb_led(void)
+// BOOT button (GPIO0, active low). A short press (30 ms .. 1 s) starts `learn`.
+static void button_task(void *arg)
{
- const led_strip_config_t strip_config = {
- .strip_gpio_num = RGB_LED_GPIO,
- .max_leds = 1,
- .led_model = LED_MODEL_WS2812,
- .color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
- .flags = {
- .invert_out = false,
- },
+ gpio_config_t io = {
+ .pin_bit_mask = 1ULL << BOOT_BTN_GPIO,
+ .mode = GPIO_MODE_INPUT,
+ .pull_up_en = GPIO_PULLUP_ENABLE,
};
- const led_strip_rmt_config_t rmt_config = {
- .clk_src = RMT_CLK_SRC_DEFAULT,
- .resolution_hz = 10 * 1000 * 1000,
- .mem_block_symbols = 64,
- .flags = {
- .with_dma = false,
- },
- };
-
- led_strip_handle_t strip = NULL;
- ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &strip));
- ESP_ERROR_CHECK(led_strip_clear(strip));
- return strip;
-}
-
-static void update_rgb_led(led_strip_handle_t strip, float rms)
-{
- float red = 0.0f;
- float green = 0.0f;
-
- if (rms < GREEN_NOISE_RMS) {
- green = LED_BRIGHTNESS * rms / GREEN_NOISE_RMS;
- } else {
- float red_fraction = (rms - GREEN_NOISE_RMS) /
- (RED_NOISE_RMS - GREEN_NOISE_RMS);
- if (red_fraction > 1.0f) {
- red_fraction = 1.0f;
+ if (gpio_config(&io) != ESP_OK) {
+ ESP_LOGE(TAG, "button gpio_config failed");
+ vTaskDelete(NULL);
+ }
+ int64_t down_at = 0;
+ for (;;) {
+ vTaskDelay(pdMS_TO_TICKS(20));
+ bool down = gpio_get_level(BOOT_BTN_GPIO) == 0;
+ if (down && down_at == 0) {
+ down_at = esp_timer_get_time();
+ } else if (!down && down_at != 0) {
+ int64_t held_ms = (esp_timer_get_time() - down_at) / 1000;
+ down_at = 0;
+ if (held_ms >= 30 && held_ms <= 1000) {
+ printf("\n[BOOT button] starting learn\n");
+ calibration_run_learn(false, 0.0f); // blocks this task only
+ }
}
- red = LED_BRIGHTNESS * red_fraction;
- green = LED_BRIGHTNESS * (1.0f - red_fraction);
}
-
- ESP_ERROR_CHECK(led_strip_set_pixel(strip, 0, (uint8_t)red,
- (uint8_t)green, 0));
- ESP_ERROR_CHECK(led_strip_refresh(strip));
}
-static i2s_chan_handle_t init_microphone(void)
+static void check(esp_err_t err, const char *what)
{
- i2s_chan_handle_t rx_channel = NULL;
- const i2s_chan_config_t channel_config =
- I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
- ESP_ERROR_CHECK(i2s_new_channel(&channel_config, NULL, &rx_channel));
-
- i2s_std_config_t standard_config = {
- .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE_HZ),
- .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
- I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO),
- .gpio_cfg = {
- .mclk = I2S_GPIO_UNUSED,
- .bclk = I2S_BCLK_GPIO,
- .ws = I2S_WS_GPIO,
- .dout = I2S_GPIO_UNUSED,
- .din = I2S_DATA_GPIO,
- .invert_flags = {
- .mclk_inv = false,
- .bclk_inv = false,
- .ws_inv = false,
- },
- },
- };
- standard_config.slot_cfg.slot_mask = I2S_STD_SLOT_LEFT;
-
- ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_channel, &standard_config));
- ESP_ERROR_CHECK(i2s_channel_enable(rx_channel));
- return rx_channel;
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "%s failed: %s", what, esp_err_to_name(err));
+ }
}
void app_main(void)
{
- led_strip_handle_t rgb_led = init_rgb_led();
- i2s_chan_handle_t microphone = init_microphone();
- int32_t samples[SAMPLES_PER_READ];
- float smoothed_rms = 0.0f;
- uint32_t log_counter = 0;
+ ESP_ERROR_CHECK(config_init()); // also initialises NVS
+ check(led_init(), "led_init");
+ ESP_ERROR_CHECK(calibration_init());
+ ESP_ERROR_CHECK(matcher_init());
+ ESP_ERROR_CHECK(audio_in_init());
+ check(notifier_init(), "notifier_init");
+ ESP_ERROR_CHECK(processing_start());
+ ESP_ERROR_CHECK(audio_in_start());
- ESP_LOGI(TAG, "INMP441 ready on BCLK=%d, WS=%d, SD=%d; RGB LED on GPIO %d",
- I2S_BCLK_GPIO, I2S_WS_GPIO, I2S_DATA_GPIO, RGB_LED_GPIO);
-
- while (true) {
- size_t bytes_read = 0;
- ESP_ERROR_CHECK(i2s_channel_read(microphone, samples, sizeof(samples),
- &bytes_read, portMAX_DELAY));
- const size_t sample_count = bytes_read / sizeof(samples[0]);
- if (sample_count == 0) {
- continue;
- }
-
- int64_t sum_squares = 0;
- for (size_t i = 0; i < sample_count; ++i) {
- // INMP441 provides 24-bit signed samples in the upper bits of each 32-bit slot.
- const int32_t sample = samples[i] >> 8;
- sum_squares += (int64_t)sample * sample;
- }
-
- const float rms = sqrtf((float)sum_squares / sample_count);
- smoothed_rms += 0.2f * (rms - smoothed_rms);
- update_rgb_led(rgb_led, smoothed_rms);
-
- if (++log_counter == 16) {
- ESP_LOGI(TAG, "sound RMS: %.0f", smoothed_rms);
- log_counter = 0;
- }
+ if (xTaskCreate(button_task, "button", 4096, NULL, 3, NULL) != pdPASS) {
+ ESP_LOGE(TAG, "button task not created");
}
+
+ printf("\nDoor buzzer detector ready. Type `help`.\n");
+ if (!matcher_has_template()) {
+ printf("NEEDS TEMPLATE. Either:\n"
+ " 1) python tools/make_template.py --buzz your_recording.wav then rebuild/flash, or\n"
+ " 2) type `learn` (or short-press BOOT) and press the buzzer during the 60 s session.\n");
+ }
+ check(console_start(), "console_start");
}
diff --git a/main/matcher.c b/main/matcher.c
new file mode 100644
index 0000000..679d004
--- /dev/null
+++ b/main/matcher.c
@@ -0,0 +1,161 @@
+#include
+#include
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "nvs.h"
+#include "fingerprint.h"
+#include "matcher.h"
+#include "template.h"
+
+_Static_assert(TEMPLATE_NUM_BANDS == NUM_BANDS,
+ "template.h was generated with a different NUM_BANDS: rerun tools/make_template.py");
+
+static const char *TAG = "matcher";
+#define NVS_NS "buzz"
+#define TPL_KEY "tpl"
+#define TPL_MAGIC 0x42555A54u // 'BUZT'
+
+typedef struct {
+ uint32_t magic;
+ uint32_t num_bands;
+ uint32_t frame_count;
+ float fp[NUM_BANDS];
+} tpl_blob_t;
+
+static SemaphoreHandle_t s_mx;
+static float s_tpl[NUM_BANDS];
+static tpl_source_t s_src = TPL_NONE;
+static uint32_t s_count;
+
+static bool valid_unit_vector(const float *v)
+{
+ float s = 0;
+ for (int i = 0; i < NUM_BANDS; i++) {
+ if (!isfinite(v[i])) return false;
+ s += v[i] * v[i];
+ }
+ return fabsf(sqrtf(s) - 1.0f) < 0.01f;
+}
+
+static bool load_nvs(void)
+{
+ nvs_handle_t h;
+ if (nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) return false;
+ tpl_blob_t b;
+ size_t len = sizeof(b);
+ esp_err_t e = nvs_get_blob(h, TPL_KEY, &b, &len);
+ nvs_close(h);
+ if (e != ESP_OK || len != sizeof(b) || b.magic != TPL_MAGIC ||
+ b.num_bands != NUM_BANDS || !valid_unit_vector(b.fp)) {
+ return false;
+ }
+ memcpy(s_tpl, b.fp, sizeof(s_tpl));
+ s_count = b.frame_count;
+ s_src = TPL_NVS;
+ return true;
+}
+
+static void load_builtin_or_none(void)
+{
+#if TEMPLATE_VALID
+ memcpy(s_tpl, TEMPLATE_FP, sizeof(s_tpl));
+ if (valid_unit_vector(s_tpl)) {
+ s_count = TEMPLATE_FRAME_COUNT;
+ s_src = TPL_BUILTIN;
+ return;
+ }
+ ESP_LOGE(TAG, "compiled-in template is not a unit vector, ignoring it");
+#endif
+ memset(s_tpl, 0, sizeof(s_tpl));
+ s_count = 0;
+ s_src = TPL_NONE;
+}
+
+esp_err_t matcher_init(void)
+{
+ s_mx = xSemaphoreCreateMutex();
+ if (!s_mx) return ESP_ERR_NO_MEM;
+ if (!load_nvs()) load_builtin_or_none();
+ ESP_LOGI(TAG, "template source: %s (%lu frames)", matcher_source_name(), (unsigned long)s_count);
+ return ESP_OK;
+}
+
+bool matcher_has_template(void) { return s_src != TPL_NONE; }
+tpl_source_t matcher_source(void) { return s_src; }
+uint32_t matcher_frame_count(void) { return s_count; }
+
+const char *matcher_source_name(void)
+{
+ switch (s_src) {
+ case TPL_NVS: return "learned on-device (NVS)";
+ case TPL_BUILTIN: return "compiled-in template.h";
+ default: return "NONE (needs template)";
+ }
+}
+
+bool matcher_suggested_threshold(float *thr)
+{
+#if TEMPLATE_HAS_SUGGESTED_THRESHOLD
+ *thr = TEMPLATE_SUGGESTED_THRESHOLD;
+ return true;
+#else
+ (void)thr;
+ return false;
+#endif
+}
+
+float matcher_score(const float fp[NUM_BANDS])
+{
+ float s = 0.0f;
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ if (s_src != TPL_NONE) s = features_dot(fp, s_tpl);
+ xSemaphoreGive(s_mx);
+ return s;
+}
+
+void matcher_get_template(float out[NUM_BANDS])
+{
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ memcpy(out, s_tpl, sizeof(s_tpl));
+ xSemaphoreGive(s_mx);
+}
+
+esp_err_t matcher_set_template(const float fp[NUM_BANDS], uint32_t frame_count)
+{
+ if (!valid_unit_vector(fp)) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = nvs_open(NVS_NS, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ tpl_blob_t b = { .magic = TPL_MAGIC, .num_bands = NUM_BANDS, .frame_count = frame_count };
+ memcpy(b.fp, fp, sizeof(b.fp));
+ err = nvs_set_blob(h, TPL_KEY, &b, sizeof(b));
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "saving template: %s", esp_err_to_name(err));
+ return err;
+ }
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ memcpy(s_tpl, fp, sizeof(s_tpl));
+ s_count = frame_count;
+ s_src = TPL_NVS;
+ xSemaphoreGive(s_mx);
+ return ESP_OK;
+}
+
+esp_err_t matcher_reset(void)
+{
+ nvs_handle_t h;
+ esp_err_t err = nvs_open(NVS_NS, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_erase_key(h, TPL_KEY);
+ if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK;
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ if (err != ESP_OK) return err;
+ xSemaphoreTake(s_mx, portMAX_DELAY);
+ load_builtin_or_none();
+ xSemaphoreGive(s_mx);
+ return ESP_OK;
+}
diff --git a/main/matcher.h b/main/matcher.h
new file mode 100644
index 0000000..9e24200
--- /dev/null
+++ b/main/matcher.h
@@ -0,0 +1,18 @@
+#pragma once
+#include
+#include
+#include "esp_err.h"
+#include "feature_params.h"
+
+typedef enum { TPL_NONE = 0, TPL_NVS, TPL_BUILTIN } tpl_source_t;
+
+esp_err_t matcher_init(void); // priority: NVS-learned > compiled-in template.h > none
+bool matcher_has_template(void);
+tpl_source_t matcher_source(void);
+const char *matcher_source_name(void);
+uint32_t matcher_frame_count(void);
+bool matcher_suggested_threshold(float *thr); // from template.h, if the tool computed one
+float matcher_score(const float fp[NUM_BANDS]); // cosine vs active template (0 if none)
+void matcher_get_template(float out[NUM_BANDS]);
+esp_err_t matcher_set_template(const float fp[NUM_BANDS], uint32_t frame_count); // -> NVS, source = NVS
+esp_err_t matcher_reset(void); // erase NVS template, fall back to built-in
diff --git a/main/notifier.c b/main/notifier.c
new file mode 100644
index 0000000..8a080f6
--- /dev/null
+++ b/main/notifier.c
@@ -0,0 +1,200 @@
+// Wi-Fi station with exponential-backoff reconnect + ntfy.sh HTTPS notifications.
+// Sending happens in its own task fed by a queue so networking can never block audio.
+#include
+#include
+#include "esp_crt_bundle.h"
+#include "esp_event.h"
+#include "esp_http_client.h"
+#include "esp_log.h"
+#include "esp_netif.h"
+#include "esp_timer.h"
+#include "esp_wifi.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/event_groups.h"
+#include "freertos/queue.h"
+#include "freertos/task.h"
+#include "config_store.h"
+#include "notifier.h"
+
+static const char *TAG = "notify";
+
+#define WIFI_CONNECTED_BIT BIT0
+#define SEND_ATTEMPTS 4
+#define NET_TASK_STACK 8192
+#define NET_TASK_PRIO 5
+#define BACKOFF_MIN_MS 1000
+#define BACKOFF_MAX_MS 60000
+
+typedef struct {
+ notif_kind_t kind;
+ char msg[96];
+} notif_msg_t;
+
+static QueueHandle_t s_q;
+static EventGroupHandle_t s_eg;
+static esp_timer_handle_t s_reconnect_timer;
+static uint32_t s_backoff_ms = BACKOFF_MIN_MS;
+static notifier_stats_t s_stats;
+static bool s_wifi_configured;
+
+static void reconnect_cb(void *arg)
+{
+ esp_err_t err = esp_wifi_connect();
+ if (err != ESP_OK && err != ESP_ERR_WIFI_CONN) {
+ ESP_LOGW(TAG, "esp_wifi_connect: %s", esp_err_to_name(err));
+ }
+}
+
+static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
+{
+ if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
+ esp_wifi_connect();
+ } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
+ xEventGroupClearBits(s_eg, WIFI_CONNECTED_BIT);
+ s_stats.wifi_connected = false;
+ ESP_LOGW(TAG, "Wi-Fi disconnected, retry in %lu ms", (unsigned long)s_backoff_ms);
+ esp_timer_stop(s_reconnect_timer); // ok if not running
+ esp_timer_start_once(s_reconnect_timer, (uint64_t)s_backoff_ms * 1000ULL);
+ s_backoff_ms = s_backoff_ms * 2 > BACKOFF_MAX_MS ? BACKOFF_MAX_MS : s_backoff_ms * 2;
+ } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
+ ip_event_got_ip_t *ev = (ip_event_got_ip_t *)data;
+ snprintf(s_stats.ip, sizeof(s_stats.ip), IPSTR, IP2STR(&ev->ip_info.ip));
+ s_stats.wifi_connected = true;
+ s_backoff_ms = BACKOFF_MIN_MS;
+ xEventGroupSetBits(s_eg, WIFI_CONNECTED_BIT);
+ ESP_LOGI(TAG, "Wi-Fi up, IP %s", s_stats.ip);
+ }
+}
+
+static esp_err_t post_ntfy(const char *topic, const char *title, const char *prio, const char *tags, const char *body)
+{
+ char url[192];
+ size_t sl = strlen(CONFIG_BUZZ_NTFY_SERVER);
+ while (sl > 0 && CONFIG_BUZZ_NTFY_SERVER[sl - 1] == '/') sl--;
+ snprintf(url, sizeof(url), "%.*s/%s", (int)sl, CONFIG_BUZZ_NTFY_SERVER, topic);
+
+ esp_http_client_config_t cfg = {
+ .url = url,
+ .method = HTTP_METHOD_POST,
+ .timeout_ms = 10000,
+ .crt_bundle_attach = esp_crt_bundle_attach,
+ };
+ esp_http_client_handle_t c = esp_http_client_init(&cfg);
+ if (!c) return ESP_ERR_NO_MEM;
+ esp_http_client_set_header(c, "Title", title);
+ esp_http_client_set_header(c, "Priority", prio);
+ esp_http_client_set_header(c, "Tags", tags);
+ esp_http_client_set_post_field(c, body, (int)strlen(body));
+ esp_err_t err = esp_http_client_perform(c);
+ if (err == ESP_OK) {
+ int st = esp_http_client_get_status_code(c);
+ if (st != 200) {
+ ESP_LOGW(TAG, "ntfy HTTP status %d", st);
+ err = ESP_FAIL;
+ }
+ } else {
+ ESP_LOGW(TAG, "HTTP perform: %s", esp_err_to_name(err));
+ }
+ esp_http_client_cleanup(c);
+ return err;
+}
+
+// Retry a few times with backoff while Wi-Fi is down / the request fails, then drop.
+static bool deliver(const char *topic, const char *title, const char *prio, const char *tags, const char *body)
+{
+ uint32_t wait_ms = 2000;
+ for (int attempt = 1; attempt <= SEND_ATTEMPTS; attempt++) {
+ EventBits_t bits = xEventGroupWaitBits(s_eg, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, pdMS_TO_TICKS(15000));
+ if (bits & WIFI_CONNECTED_BIT) {
+ if (post_ntfy(topic, title, prio, tags, body) == ESP_OK) {
+ ESP_LOGI(TAG, "sent '%s' to %s (attempt %d)", body, topic, attempt);
+ return true;
+ }
+ } else {
+ ESP_LOGW(TAG, "Wi-Fi down (attempt %d/%d)", attempt, SEND_ATTEMPTS);
+ }
+ vTaskDelay(pdMS_TO_TICKS(wait_ms));
+ wait_ms *= 2;
+ }
+ ESP_LOGE(TAG, "giving up on '%s', dropped", body);
+ return false;
+}
+
+static void net_task(void *arg)
+{
+ int64_t last_hb_us = esp_timer_get_time();
+ notif_msg_t m;
+ for (;;) {
+ if (xQueueReceive(s_q, &m, pdMS_TO_TICKS(30000)) == pdTRUE) {
+ bool ok = deliver(CONFIG_BUZZ_NTFY_TOPIC, "Door buzzer",
+ m.kind == NOTIF_ALERT ? "high" : "default",
+ m.kind == NOTIF_ALERT ? "bell" : "wrench", m.msg);
+ if (ok) s_stats.sent_ok++; else s_stats.sent_failed++;
+ last_hb_us = esp_timer_get_time(); // an alert proves liveness; restart the heartbeat timer
+ }
+ app_config_t cfg;
+ config_get(&cfg);
+ if (cfg.heartbeat_h > 0 &&
+ (esp_timer_get_time() - last_hb_us) >= (int64_t)cfg.heartbeat_h * 3600LL * 1000000LL) {
+ last_hb_us = esp_timer_get_time();
+ if (deliver(CONFIG_BUZZ_NTFY_HB_TOPIC, "Door buzzer heartbeat", "min", "green_heart", "alive")) {
+ s_stats.sent_ok++;
+ } else {
+ s_stats.sent_failed++;
+ }
+ }
+ }
+}
+
+bool notifier_send(notif_kind_t kind, const char *msg)
+{
+ notif_msg_t m = { .kind = kind };
+ snprintf(m.msg, sizeof(m.msg), "%s", msg);
+ if (xQueueSend(s_q, &m, 0) != pdTRUE) {
+ s_stats.dropped++;
+ ESP_LOGW(TAG, "notification queue full, dropped");
+ return false;
+ }
+ return true;
+}
+
+void notifier_get_stats(notifier_stats_t *out)
+{
+ *out = s_stats;
+ out->wifi_configured = s_wifi_configured;
+}
+
+esp_err_t notifier_init(void)
+{
+ s_q = xQueueCreate(4, sizeof(notif_msg_t));
+ s_eg = xEventGroupCreate();
+ if (!s_q || !s_eg) return ESP_ERR_NO_MEM;
+
+ s_wifi_configured = strlen(CONFIG_BUZZ_WIFI_SSID) > 0;
+ if (s_wifi_configured) {
+ ESP_ERROR_CHECK(esp_netif_init());
+ ESP_ERROR_CHECK(esp_event_loop_create_default());
+ esp_netif_create_default_wifi_sta();
+
+ const esp_timer_create_args_t targs = { .callback = reconnect_cb, .name = "wifi_retry" };
+ ESP_ERROR_CHECK(esp_timer_create(&targs, &s_reconnect_timer));
+
+ wifi_init_config_t icfg = WIFI_INIT_CONFIG_DEFAULT();
+ ESP_ERROR_CHECK(esp_wifi_init(&icfg));
+ ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL));
+ ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL));
+
+ wifi_config_t wc = { 0 };
+ strlcpy((char *)wc.sta.ssid, CONFIG_BUZZ_WIFI_SSID, sizeof(wc.sta.ssid));
+ strlcpy((char *)wc.sta.password, CONFIG_BUZZ_WIFI_PASSWORD, sizeof(wc.sta.password));
+ wc.sta.threshold.authmode = strlen(CONFIG_BUZZ_WIFI_PASSWORD) ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
+ wc.sta.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
+ ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
+ ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc));
+ ESP_ERROR_CHECK(esp_wifi_start());
+ } else {
+ ESP_LOGW(TAG, "no Wi-Fi SSID configured (menuconfig): notifications will be dropped");
+ }
+ BaseType_t ok = xTaskCreatePinnedToCore(net_task, "net", NET_TASK_STACK, NULL, NET_TASK_PRIO, NULL, 0);
+ return ok == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
+}
diff --git a/main/notifier.h b/main/notifier.h
new file mode 100644
index 0000000..e542e16
--- /dev/null
+++ b/main/notifier.h
@@ -0,0 +1,17 @@
+#pragma once
+#include
+#include
+#include "esp_err.h"
+
+typedef enum { NOTIF_ALERT = 0, NOTIF_TEST } notif_kind_t;
+
+typedef struct {
+ bool wifi_connected;
+ char ip[16];
+ uint32_t sent_ok, sent_failed, dropped;
+ bool wifi_configured;
+} notifier_stats_t;
+
+esp_err_t notifier_init(void); // Wi-Fi + network task
+bool notifier_send(notif_kind_t kind, const char *msg); // non-blocking enqueue, false if queue full
+void notifier_get_stats(notifier_stats_t *out);
diff --git a/main/processing.c b/main/processing.c
new file mode 100644
index 0000000..26c3930
--- /dev/null
+++ b/main/processing.c
@@ -0,0 +1,143 @@
+// features -> score -> detector, or calibration while a session is active.
+#include
+#include
+#include "esp_log.h"
+#include "esp_task_wdt.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "audio_in.h"
+#include "calibration.h"
+#include "config_store.h"
+#include "detector.h"
+#include "fingerprint.h"
+#include "led.h"
+#include "matcher.h"
+#include "notifier.h"
+#include "processing.h"
+
+static const char *TAG = "proc";
+#define PROC_TASK_CORE 1
+#define PROC_TASK_PRIO 8
+#define PROC_TASK_STACK 6144
+
+static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
+static proc_stats_t s_stats;
+static float s_last_fp[NUM_BANDS];
+static volatile bool s_monitor;
+static features_ctx_t s_ctx;
+
+void processing_get_stats(proc_stats_t *out)
+{
+ portENTER_CRITICAL(&s_lock);
+ *out = s_stats;
+ portEXIT_CRITICAL(&s_lock);
+}
+
+void processing_get_last_fp(float fp[NUM_BANDS])
+{
+ portENTER_CRITICAL(&s_lock);
+ memcpy(fp, s_last_fp, sizeof(s_last_fp));
+ portEXIT_CRITICAL(&s_lock);
+}
+
+void processing_set_monitor(bool on) { s_monitor = on; }
+bool processing_get_monitor(void) { return s_monitor; }
+
+static void update_led(app_mode_t mode, det_state_t st, bool has_tpl)
+{
+ static int last = -1;
+ int code;
+ if (!has_tpl) code = 1;
+ else if (mode != MODE_NORMAL) code = 2;
+ else if (st != DET_IDLE) code = 3;
+ else code = 0;
+ if (code == last) return;
+ last = code;
+ switch (code) {
+ case 1: led_set(20, 0, 0); break; // red: needs template
+ case 2: led_set(20, 12, 0); break; // amber: calibration session
+ case 3: led_set(20, 0, 20); break; // magenta: alert / cooldown
+ default: led_set(0, 0, 6); break; // dim blue: listening
+ }
+}
+
+static void processing_task(void *arg)
+{
+ ESP_ERROR_CHECK(esp_task_wdt_add(NULL));
+ detector_t det;
+ detector_init(&det);
+ float fp[NUM_BANDS];
+ uint32_t last_mon_ms = 0;
+
+ for (;;) {
+ esp_task_wdt_reset();
+ audio_frame_t fr;
+ if (!audio_in_get_frame(&fr, pdMS_TO_TICKS(1000))) {
+ ESP_LOGW(TAG, "no audio frame for 1 s");
+ continue;
+ }
+
+ int64_t t0 = esp_timer_get_time();
+ float rms = 0.0f;
+ bool valid = features_compute(&s_ctx, fr.pcm, fp, &rms);
+ const bool has_tpl = matcher_has_template();
+ const float score = (valid && has_tpl) ? matcher_score(fp) : 0.0f;
+ uint32_t dt_us = (uint32_t)(esp_timer_get_time() - t0);
+ audio_in_release_frame(fr.idx); // pool slot back as early as possible
+
+ const uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
+ app_config_t cfg;
+ config_get(&cfg);
+ const app_mode_t mode = calibration_get_mode();
+ const bool match = has_tpl && detector_is_match(rms, score, cfg.min_rms, cfg.threshold);
+
+ bool trig = false;
+ if (mode == MODE_NORMAL) {
+ if (has_tpl) {
+ detector_cfg_t dc = { cfg.win_n, cfg.win_m, cfg.cooldown_ms };
+ trig = detector_update(&det, match, now_ms, &dc);
+ } else {
+ detector_reset(&det);
+ }
+ } else {
+ calibration_on_frame(fp, valid, rms, score); // detector paused: no alerts
+ detector_reset(&det);
+ }
+ if (trig) {
+ ESP_LOGW(TAG, "BUZZER DETECTED (rms %.0f, score %.3f)", rms, score);
+ notifier_send(NOTIF_ALERT, "Buzzer detected");
+ }
+
+ portENTER_CRITICAL(&s_lock);
+ s_stats.frames++;
+ s_stats.proc_avg_us = s_stats.frames == 1 ? dt_us : (s_stats.proc_avg_us * 15u + dt_us) / 16u;
+ if (dt_us > s_stats.proc_max_us) s_stats.proc_max_us = dt_us;
+ s_stats.last_rms = rms;
+ s_stats.last_score = score;
+ s_stats.last_match = match;
+ s_stats.state = det.state;
+ s_stats.triggers = det.triggers;
+ if (trig) s_stats.last_trigger_s = now_ms / 1000 ? now_ms / 1000 : 1;
+ memcpy(s_last_fp, fp, sizeof(s_last_fp));
+ portEXIT_CRITICAL(&s_lock);
+
+ update_led(mode, det.state, has_tpl);
+
+ if (s_monitor && (uint32_t)(now_ms - last_mon_ms) >= cfg.monitor_ms) {
+ last_mon_ms = now_ms;
+ printf("[%8lu ms] rms=%7.1f score=%+.3f %s %s\n", (unsigned long)now_ms, rms, score,
+ match ? "MATCH" : " - ",
+ mode == MODE_NORMAL ? detector_state_name(det.state) : calibration_mode_name(mode));
+ }
+ }
+}
+
+esp_err_t processing_start(void)
+{
+ esp_err_t err = features_init();
+ if (err != ESP_OK) return err;
+ BaseType_t ok = xTaskCreatePinnedToCore(processing_task, "proc", PROC_TASK_STACK, NULL,
+ PROC_TASK_PRIO, NULL, PROC_TASK_CORE);
+ return ok == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
+}
diff --git a/main/processing.h b/main/processing.h
new file mode 100644
index 0000000..d545be2
--- /dev/null
+++ b/main/processing.h
@@ -0,0 +1,23 @@
+#pragma once
+#include
+#include
+#include "esp_err.h"
+#include "detector.h"
+#include "feature_params.h"
+
+typedef struct {
+ uint32_t frames;
+ uint32_t proc_avg_us; // exponential moving average of features+match time
+ uint32_t proc_max_us;
+ float last_rms, last_score;
+ bool last_match;
+ det_state_t state;
+ uint32_t triggers;
+ uint32_t last_trigger_s; // uptime seconds of the last trigger, 0 = never
+} proc_stats_t;
+
+esp_err_t processing_start(void);
+void processing_get_stats(proc_stats_t *out);
+void processing_get_last_fp(float fp[NUM_BANDS]);
+void processing_set_monitor(bool on);
+bool processing_get_monitor(void);
diff --git a/main/template.h b/main/template.h
new file mode 100644
index 0000000..a8bee27
--- /dev/null
+++ b/main/template.h
@@ -0,0 +1,21 @@
+// GENERATED by tools/make_template.py -- do not edit.
+// Source recording: many_buzz.wav
+// Parameters: FS_HZ=16000 FRAME_LEN=1024 HOP_LEN=512 NUM_BANDS=32 F_MIN_HZ=100 F_MAX_HZ=7500 LOG_EPS=1e-10 FP_DETREND=1
+#ifndef TEMPLATE_H
+#define TEMPLATE_H
+#include "feature_params.h"
+#define TEMPLATE_VALID 1
+#define TEMPLATE_NUM_BANDS 32
+#define TEMPLATE_FRAME_COUNT 1605
+#define TEMPLATE_HAS_SUGGESTED_THRESHOLD 0
+static const float TEMPLATE_FP[NUM_BANDS] = {
+ -1.234297492e-01f, -1.172692978e-01f, -1.647167516e-01f, -2.107734516e-01f,
+ -1.697949230e-01f, -1.590963513e-01f, -1.411206649e-01f, -4.264512523e-02f,
+ 1.186895466e-01f, 5.534144070e-01f, 4.097405769e-01f, 2.584858953e-01f,
+ 2.150118383e-01f, 7.485003583e-02f, -1.082601745e-01f, -1.384175877e-01f,
+ -9.168937132e-02f, 9.338026730e-02f, 2.869415823e-02f, 2.120377562e-02f,
+ -5.454016039e-03f, 1.042970822e-01f, 6.318512667e-02f, -1.156468943e-01f,
+ 1.076363226e-01f, 8.577662649e-02f, -2.341709754e-02f, 5.675516909e-03f,
+ -1.403284712e-01f, -2.986392837e-01f, -1.027432535e-01f, 1.340128842e-02f,
+};
+#endif
diff --git a/main/test_vector.h b/main/test_vector.h
new file mode 100644
index 0000000..472dd8d
--- /dev/null
+++ b/main/test_vector.h
@@ -0,0 +1,86 @@
+// GENERATED by tools/make_template.py -- do not edit.
+// One int16 frame from the middle of the buzzer (frame #1355) and the Python-computed
+// fingerprint/RMS the firmware `selftest` must reproduce within 1e-3.
+#ifndef TEST_VECTOR_H
+#define TEST_VECTOR_H
+#include
+#include "feature_params.h"
+#define TEST_VECTOR_VALID 1
+#define TEST_VECTOR_RMS 2076.644521f
+static const int16_t TEST_VECTOR_PCM[FRAME_LEN] = {
+ -946, -1702, -2426, -2012, -1726, -1055, -1290, -1011, -900, -1042, -1005, -863, -339, 267, 493, 656,
+ 1057, 835, 1041, 1258, 1529, 2141, 1751, 2357, 2697, 3020, 3065, 2443, 2244, 1888, 2155, 2081,
+ 1913, 698, -227, -777, -1053, -939, -1549, -2176, -2786, -2572, -2326, -2590, -3312, -3801, -3829, -3531,
+ -2763, -3285, -3392, -3490, -2180, -1060, -313, 285, 777, 1535, 1521, 2483, 2476, 3045, 3019, 3019,
+ 3037, 3148, 3833, 4197, 3638, 3379, 3587, 3239, 2708, 971, 975, 248, 390, 3, -428, -982,
+ -2180, -1992, -2688, -2702, -2828, -2740, -2665, -3422, -3985, -3687, -3615, -2860, -3001, -2621, -2034, -1543,
+ -709, -1014, -781, -441, 769, 1358, 2244, 2226, 2164, 2159, 2610, 3668, 3800, 3033, 3213, 3712,
+ 4244, 3943, 3241, 3210, 2070, 1926, 1422, 1302, 261, -685, -1026, -1419, -1793, -3110, -3016, -3436,
+ -3220, -3412, -3374, -3168, -3624, -4175, -4202, -3021, -2252, -2002, -2192, -1027, -347, 310, 104, 362,
+ 1024, 1578, 2666, 2916, 3447, 2990, 3264, 3409, 3135, 2726, 2513, 2828, 2955, 2445, 2141, 1814,
+ 1481, 424, -78, -394, -450, -656, -1299, -1327, -1953, -2112, -2976, -3092, -2889, -2478, -2426, -2043,
+ -1736, -1587, -1935, -2470, -1813, -1668, -1009, -889, -150, -333, 120, 594, 1053, 1740, 1188, 2057,
+ 1778, 2014, 1774, 1555, 1634, 1585, 1865, 2073, 1806, 1670, 1799, 1710, 1775, 1059, 894, -78,
+ -486, -592, -269, -190, -962, -1294, -1863, -1644, -1530, -1691, -1792, -2158, -1735, -1357, -1459, -2165,
+ -2874, -2062, -1085, -268, -403, -902, -1154, -721, 155, 732, 1174, 1026, 1404, 1421, 1739, 1599,
+ 985, 978, 1104, 2264, 2049, 1954, 1533, 1634, 1367, 1315, 1278, 584, 5, -719, 98, 99,
+ 88, -729, -941, -1221, -1311, -1253, -1368, -1419, -1579, -1228, -1051, -991, -2154, -2078, -1786, -1166,
+ -929, -1083, -705, -866, -951, -902, -594, 31, 475, 537, 893, 983, 751, 1162, 1168, 1795,
+ 1937, 1809, 2563, 2669, 3197, 2692, 2360, 2003, 2005, 2242, 2144, 1529, 214, -311, -876, -785,
+ -1090, -1692, -2341, -2585, -2170, -2231, -2709, -3558, -3742, -3758, -3035, -2985, -3465, -3599, -3154, -1615,
+ -884, -75, 169, 972, 1194, 1728, 2343, 2365, 2896, 2677, 2926, 2736, 3262, 3838, 3881, 3174,
+ 3531, 3313, 3093, 1714, 821, 799, 124, 423, -305, -312, -1574, -2018, -2184, -2714, -2621, -2761,
+ -2579, -2835, -3778, -3789, -3563, -3181, -2637, -2897, -2059, -1932, -950, -828, -879, -722, 47, 1035,
+ 1707, 2344, 2010, 2167, 2183, 3115, 3855, 3397, 2871, 3421, 3873, 4285, 3420, 3285, 2649, 1887,
+ 1755, 1272, 1057, -423, -732, -1326, -1398, -2516, -3234, -3075, -3377, -3079, -3506, -3085, -3313, -3725,
+ -4221, -3512, -2495, -1947, -2085, -1623, -503, -24, 390, 29, 807, 1227, 2210, 2795, 3080, 3164,
+ 2840, 3370, 3238, 2845, 2400, 2474, 2903, 2721, 2169, 1865, 1593, 1042, 93, -68, -460, -340,
+ -973, -1068, -1358, -1834, -2315, -2970, -2674, -2412, -2107, -2127, -1632, -1580, -1424, -2228, -2102, -1691,
+ -1366, -945, -691, -207, -465, 410, 482, 1438, 1187, 1330, 1893, 1600, 1897, 1347, 1449, 1351,
+ 1582, 1861, 1947, 1546, 1762, 1662, 1799, 1435, 979, 672, -264, -290, -359, 140, -355, -878,
+ -1397, -1710, -1353, -1407, -1449, -1744, -1897, -1323, -1236, -1534, -2518, -2549, -1535, -671, -195, -691,
+ -1057, -1162, -445, 261, 875, 1022, 1010, 1390, 1275, 1703, 1067, 913, 804, 1544, 2231, 1968,
+ 1881, 1512, 1688, 1244, 1546, 958, 521, -323, -244, 374, 201, -97, -830, -878, -1347, -1173,
+ -1386, -1305, -1571, -1459, -1204, -940, -1530, -2386, -2038, -1801, -1114, -1249, -1053, -905, -1173, -1145,
+ -985, -473, 135, 328, 544, 893, 755, 839, 1105, 1265, 1967, 1676, 2179, 2666, 2902, 3083,
+ 2434, 2278, 1916, 2220, 2208, 2101, 967, 12, -504, -836, -692, -1290, -1885, -2495, -2363, -2114,
+ -2331, -3052, -3580, -3674, -3438, -2672, -3197, -3424, -3567, -2430, -1235, -536, 140, 534, 1367, 1330,
+ 2236, 2317, 2768, 2914, 2751, 2799, 2825, 3545, 3999, 3539, 3158, 3450, 3109, 2652, 962, 859,
+ 227, 217, 15, -431, -849, -2168, -2117, -2705, -2770, -2785, -2794, -2676, -3366, -4086, -3684, -3590,
+ -2883, -2934, -2663, -1970, -1588, -720, -1044, -827, -588, 654, 1269, 2183, 2208, 2034, 2086, 2470,
+ 3553, 3808, 3102, 3169, 3690, 4147, 4035, 3280, 3291, 2085, 1895, 1469, 1390, 471, -612, -843,
+ -1398, -1618, -3045, -2973, -3312, -3203, -3305, -3359, -2982, -3414, -3965, -4169, -2995, -2201, -1835, -2065,
+ -984, -264, 317, 222, 303, 1015, 1481, 2617, 2838, 3347, 2925, 3085, 3312, 2979, 2561, 2320,
+ 2617, 2843, 2359, 2025, 1733, 1479, 555, -22, -298, -439, -453, -1090, -1029, -1648, -1807, -2643,
+ -2840, -2604, -2204, -2104, -1823, -1495, -1426, -1695, -2393, -1793, -1665, -1053, -971, -325, -386, -67,
+ 491, 761, 1563, 956, 1771, 1624, 1788, 1693, 1376, 1505, 1447, 1785, 1986, 1785, 1587, 1852,
+ 1716, 1874, 1217, 1104, 295, -290, -317, -129, 91, -733, -1045, -1637, -1541, -1333, -1457, -1520,
+ -1922, -1667, -1255, -1399, -2013, -2788, -2152, -1167, -397, -387, -914, -1192, -913, -91, 466, 1013,
+ 946, 1308, 1326, 1454, 1470, 868, 916, 933, 2086, 2044, 1987, 1646, 1669, 1541, 1338, 1434,
+ 770, 355, -439, 201, 261, 274, -436, -727, -1005, -1204, -1103, -1302, -1219, -1509, -1183, -1103,
+ -949, -2082, -2250, -1984, -1432, -1007, -1229, -865, -1020, -1131, -1081, -815, -224, 270, 405, 682,
+ 827, 604, 986, 1053, 1628, 1950, 1754, 2503, 2613, 3083, 2760, 2347, 2083, 2055, 2321, 2244,
+ 1744, 432, -153, -711, -725, -859, -1520, -2110, -2502, -2145, -2113, -2537, -3378, -3631, -3740, -3130,
+ -2868, -3375, -3432, -3257, -1764, -1048, -113, 192, 940, 1237, 1589, 2344, 2301, 2926, 2664, 2808,
+ 2686, 3133, 3806, 3998, 3205, 3420, 3281, 3081, 1897, 763, 817, 42, 442, -243, -227, -1495,
+ -2116, -2207, -2775, -2608, -2795, -2542, -2734, -3651, -3816, -3572, -3324, -2738, -3075, -2205, -1888, -1061,
+ -827, -1004, -760, -105, 1007, 1581, 2382, 2044, 2165, 2147, 2984, 3765, 3412, 2895, 3423, 3868,
+ 4263, 3583, 3349, 2883, 1943, 1816, 1382, 1233, -142, -664, -1148, -1286, -2163, -3058, -2932, -3291,
+ -3006, -3450, -3043, -3136, -3548, -4173, -3611, -2507, -1905, -2007, -1755, -585, -118, 368, 33, 702,
+ 1077, 1964, 2706, 3013, 3225, 2795, 3307, 3152, 2801, 2380, 2374, 2699, 2571, 2116, 1890, 1595,
+ 1073, 58, -169, -592, -449, -955, -1166, -1421, -1876, -2247, -3003, -2803, -2569, -2175, -2135, -1623,
+ -1546, -1380, -2178, -2211, -1791, -1494, -1014, -817, -218, -500, 303, 385, 1273, 1202, 1148, 1754,
+ 1413, 1771, 1263, 1398, 1298, 1469, 1749, 1864, 1502, 1681, 1694, 1793, 1546, 966, 741, -192,
+ -288, -376, 84, -235, -811, -1224, -1642, -1318, -1356, -1395, -1567, -1805, -1252, -1150, -1411, -2347,
+};
+static const float TEST_VECTOR_FP[NUM_BANDS] = {
+ -3.645497329e-01f, -1.889563834e-01f, -2.027908220e-01f, -9.830562242e-02f,
+ -1.154377913e-01f, -6.263310393e-02f, -5.827089101e-02f, -3.816777800e-02f,
+ 1.257844590e-01f, 5.240575307e-01f, 3.891325996e-01f, 2.516123367e-01f,
+ 2.173896044e-01f, 1.050724342e-01f, -8.207634210e-02f, -9.264745520e-02f,
+ -5.232892854e-02f, 1.067979427e-01f, 4.857097334e-02f, 4.391817445e-02f,
+ -2.416819206e-03f, 1.032181760e-01f, 6.795309402e-02f, -1.334582865e-01f,
+ 8.268648484e-02f, 6.559289181e-02f, -5.534441780e-02f, -1.353428886e-02f,
+ -1.429585141e-01f, -2.783958093e-01f, -1.231069169e-01f, -2.640679836e-02f,
+};
+#endif
diff --git a/many_buzz.wav b/many_buzz.wav
new file mode 100644
index 0000000..3ed3b25
Binary files /dev/null and b/many_buzz.wav differ
diff --git a/sdkconfig.defaults b/sdkconfig.defaults
new file mode 100644
index 0000000..4c04052
--- /dev/null
+++ b/sdkconfig.defaults
@@ -0,0 +1,20 @@
+# Target
+CONFIG_IDF_TARGET="esp32s3"
+CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
+CONFIG_COMPILER_OPTIMIZATION_PERF=y
+CONFIG_FREERTOS_HZ=1000
+
+# Task watchdog: audio + processing tasks subscribe themselves
+CONFIG_ESP_TASK_WDT_EN=y
+CONFIG_ESP_TASK_WDT_INIT=y
+CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
+CONFIG_ESP_TASK_WDT_PANIC=y
+CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
+CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
+
+# HTTPS to ntfy.sh through the Mozilla certificate bundle
+CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
+CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
+
+# Console: leave ESP_CONSOLE_* at the board default (UART port).
+# For the native USB port use: CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
diff --git a/tools/host_test/README.md b/tools/host_test/README.md
new file mode 100644
index 0000000..5adf8cc
--- /dev/null
+++ b/tools/host_test/README.md
@@ -0,0 +1,9 @@
+# Host test (optional, no ESP-IDF needed)
+
+Compiles the real `main/fingerprint.c` and `main/detector.c` on your PC with a stub FFT and checks
+(1) C-vs-Python fingerprint parity on `test_vector.h`, (2) silence/NaN safety, (3) the N-of-M + cooldown state machine.
+
+ python tools/make_template.py --buzz your.wav # writes main/test_vector.h
+ cd tools/host_test
+ gcc -Wall -I stubs -iquote ../../main host_test.c ../../main/fingerprint.c ../../main/detector.c -lm -o host_test
+ ./host_test
diff --git a/tools/host_test/host_test.c b/tools/host_test/host_test.c
new file mode 100644
index 0000000..ba03837
--- /dev/null
+++ b/tools/host_test/host_test.c
@@ -0,0 +1,45 @@
+// PC test of the REAL fingerprint.c and detector.c.
+// gcc -I stubs -iquote ../../main host_test.c ../../main/fingerprint.c ../../main/detector.c -lm -o host_test
+#include
+#include
+#include "fingerprint.h"
+#include "detector.h"
+#include "test_vector.h"
+
+static int fails;
+#define CHECK(c, ...) do { if (!(c)) { printf("FAIL: " __VA_ARGS__); printf("\n"); fails++; } } while (0)
+
+int main(void)
+{
+ CHECK(features_init() == ESP_OK, "features_init");
+#if TEST_VECTOR_VALID
+ static features_ctx_t ctx; float fp[NUM_BANDS], rms;
+ bool ok = features_compute(&ctx, TEST_VECTOR_PCM, fp, &rms);
+ float me = 0; for (int b = 0; b < NUM_BANDS; b++) { float e = fabsf(fp[b] - TEST_VECTOR_FP[b]); if (e > me) me = e; }
+ printf("parity: ok=%d max|fp err|=%.2e rms C=%.4f py=%.4f\n", ok, me, rms, TEST_VECTOR_RMS);
+ CHECK(ok && me < 1e-3f, "fingerprint parity");
+#endif
+ // degenerate inputs must not produce NaN
+ static int16_t z[FRAME_LEN]; static features_ctx_t c2; float f2[NUM_BANDS], r2;
+ CHECK(!features_compute(&c2, z, f2, &r2) && r2 == 0.0f, "silence -> invalid, rms 0");
+ for (int b = 0; b < NUM_BANDS; b++) CHECK(f2[b] == 0.0f, "silence fp zero");
+
+ // detector: 10-of-15 triggers once, cooldown 20 s, then re-arms
+ detector_t d; detector_init(&d);
+ detector_cfg_t cfg = { 15, 10, 20000 };
+ uint32_t t = 0; int trig = 0, first = -1;
+ for (int i = 0; i < 40; i++, t += 32) if (detector_update(&d, i >= 5 && i < 30, t, &cfg)) { trig++; if (first < 0) first = i; }
+ CHECK(trig == 1 && first == 14, "single trigger at 10th match (got %d trig, first %d)", trig, first);
+ CHECK(d.state == DET_COOLDOWN, "cooldown after trigger");
+ for (int i = 0; i < 100; i++, t += 32) CHECK(!detector_update(&d, true, t, &cfg), "no trigger in cooldown");
+ t += 20000; detector_update(&d, false, t, &cfg);
+ CHECK(d.state == DET_IDLE, "back to idle");
+ int n = 0; for (int i = 0; i < 12; i++, t += 32) n += detector_update(&d, true, t, &cfg);
+ CHECK(n == 1, "re-arms and triggers again");
+ // sparse matches never trigger
+ detector_reset(&d); n = 0;
+ for (int i = 0; i < 300; i++, t += 32) n += detector_update(&d, i % 3 == 0, t, &cfg);
+ CHECK(n == 0, "1-in-3 matches must not trigger 10/15");
+ printf(fails ? "HOST TEST FAILED\n" : "HOST TEST PASSED\n");
+ return fails != 0;
+}
diff --git a/tools/host_test/stubs/dsps_fft2r.h b/tools/host_test/stubs/dsps_fft2r.h
new file mode 100644
index 0000000..fb27710
--- /dev/null
+++ b/tools/host_test/stubs/dsps_fft2r.h
@@ -0,0 +1,26 @@
+// Host stand-in for esp-dsp: float32 radix-2 FFT with the same call sequence
+// (fft2r then bit_rev). Output order is natural, so bit_rev is a no-op here.
+#pragma once
+#include
+#include "esp_err.h"
+static inline esp_err_t dsps_fft2r_init_fc32(float *t, int n) { (void)t; (void)n; return ESP_OK; }
+static inline esp_err_t dsps_fft2r_fc32(float *d, int n)
+{
+ for (int i = 1, j = 0; i < n; i++) { // bit-reverse permutation
+ int bit = n >> 1;
+ for (; j & bit; bit >>= 1) j ^= bit;
+ j ^= bit;
+ if (i < j) { float a = d[2*i], b = d[2*i+1]; d[2*i] = d[2*j]; d[2*i+1] = d[2*j+1]; d[2*j] = a; d[2*j+1] = b; }
+ }
+ for (int len = 2; len <= n; len <<= 1) {
+ float ang = -2.0f * (float)M_PI / (float)len;
+ for (int i = 0; i < n; i += len) for (int k = 0; k < len / 2; k++) {
+ float wr = cosf(ang * k), wi = sinf(ang * k);
+ int a = i + k, b = i + k + len / 2;
+ float xr = d[2*b] * wr - d[2*b+1] * wi, xi = d[2*b] * wi + d[2*b+1] * wr;
+ d[2*b] = d[2*a] - xr; d[2*b+1] = d[2*a+1] - xi; d[2*a] += xr; d[2*a+1] += xi;
+ }
+ }
+ return ESP_OK;
+}
+static inline esp_err_t dsps_bit_rev_fc32(float *d, int n) { (void)d; (void)n; return ESP_OK; }
diff --git a/tools/host_test/stubs/esp_err.h b/tools/host_test/stubs/esp_err.h
new file mode 100644
index 0000000..e8e7ebf
--- /dev/null
+++ b/tools/host_test/stubs/esp_err.h
@@ -0,0 +1,6 @@
+#pragma once
+typedef int esp_err_t;
+#define ESP_OK 0
+#define ESP_FAIL -1
+#define ESP_ERR_INVALID_ARG 0x102
+static inline const char *esp_err_to_name(esp_err_t e) { return e == ESP_OK ? "ESP_OK" : "ERR"; }
diff --git a/tools/host_test/stubs/esp_log.h b/tools/host_test/stubs/esp_log.h
new file mode 100644
index 0000000..4a89e24
--- /dev/null
+++ b/tools/host_test/stubs/esp_log.h
@@ -0,0 +1,5 @@
+#pragma once
+#include
+#define ESP_LOGE(t, f, ...) fprintf(stderr, "E %s: " f "\n", t, ##__VA_ARGS__)
+#define ESP_LOGW(t, f, ...) fprintf(stderr, "W %s: " f "\n", t, ##__VA_ARGS__)
+#define ESP_LOGI(t, f, ...) fprintf(stderr, "I %s: " f "\n", t, ##__VA_ARGS__)
diff --git a/tools/make_template.py b/tools/make_template.py
new file mode 100644
index 0000000..320123c
--- /dev/null
+++ b/tools/make_template.py
@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+"""
+make_template.py - build the door-buzzer spectral template from an existing recording.
+
+Usage:
+ python tools/make_template.py --buzz buzzer.wav
+ [--segments 1.2-2.0,5.5-6.4] time ranges (s) that contain the buzzer
+ [--background bg1.wav bg2.wav ...] recordings WITHOUT the buzzer
+ [--out main/template.h] (main/test_vector.h is written next to it)
+ [--gate-mult 4.0] auto-detect gate: noise floor * this
+ [--min-rms 200] [--window-n 15] [--window-m 10] [--cooldown 20]
+ [--threshold T] override threshold used in the false-trigger estimate
+
+What it does:
+ * loads any WAV (any rate / bit depth / channels), mixes to mono, resamples to FS_HZ
+ * parses main/feature_params.h so the feature pipeline is exactly the firmware's
+ * selects buzzer frames (given segments, or auto: RMS > 10th-percentile floor * gate-mult)
+ * template = mean fingerprint of those frames, L2-normalised
+ * with --background: scores every background frame, recommends a threshold and
+ estimates false triggers / hour using the firmware's N-of-M window logic
+ * writes main/template.h and main/test_vector.h (used by the firmware `selftest`)
+
+Dependencies: numpy, scipy.
+"""
+import argparse
+import math
+import os
+import re
+import sys
+
+import numpy as np
+from scipy.io import wavfile
+from scipy.signal import resample_poly
+
+MIN_FRAMES = 20
+
+
+def die(msg):
+ print("ERROR: " + msg, file=sys.stderr)
+ sys.exit(1)
+
+
+# ---------------------------------------------------------------- parameters
+def parse_params(path):
+ """Regex over '#define NAME value' lines of feature_params.h."""
+ try:
+ text = open(path, "r", encoding="utf-8").read()
+ except OSError as e:
+ die("cannot read %s: %s" % (path, e))
+ raw = {}
+ for m in re.finditer(r"^[ \t]*#define[ \t]+(\w+)[ \t]+([^\s/]+)", text, re.M):
+ raw[m.group(1)] = m.group(2)
+ need = ["FS_HZ", "FRAME_LEN", "HOP_LEN", "NUM_BANDS", "F_MIN_HZ", "F_MAX_HZ", "LOG_EPS", "FP_DETREND"]
+ missing = [k for k in need if k not in raw]
+ if missing:
+ die("%s is missing #define(s): %s" % (path, ", ".join(missing)))
+ P = {}
+ for k in need:
+ s = re.sub(r"[fFuUlL]+$", "", raw[k].strip("()")) # 1e-10f -> 1e-10
+ try:
+ v = float(s)
+ except ValueError:
+ die("cannot parse value '%s' of %s in %s" % (raw[k], k, path))
+ P[k] = v if k == "LOG_EPS" else int(round(v))
+ if P["FRAME_LEN"] & (P["FRAME_LEN"] - 1):
+ die("FRAME_LEN must be a power of two")
+ return P
+
+
+def band_edges(P):
+ """Identical to fingerprint.c: log-spaced, round-half-up, >=1 bin per band."""
+ n = P["NUM_BANDS"]
+ ratio = P["F_MAX_HZ"] / P["F_MIN_HZ"]
+ edges = []
+ for b in range(n + 1):
+ f = P["F_MIN_HZ"] * ratio ** (b / n)
+ k = int(math.floor(f * P["FRAME_LEN"] / P["FS_HZ"] + 0.5))
+ if b > 0 and k < edges[-1] + 1:
+ k = edges[-1] + 1
+ edges.append(k)
+ if edges[-1] > P["FRAME_LEN"] // 2:
+ die("band edges exceed Nyquist (%d > %d); lower NUM_BANDS or raise F_MIN_HZ"
+ % (edges[-1], P["FRAME_LEN"] // 2))
+ return np.array(edges, dtype=np.int64)
+
+
+def hann(n):
+ """Periodic Hann, as in fingerprint.c."""
+ return 0.5 - 0.5 * np.cos(2.0 * np.pi * np.arange(n) / n)
+
+
+# ------------------------------------------------------------------ features
+def fingerprints(frames_i16, P, edges, window):
+ """frames_i16: (F, FRAME_LEN) int16 -> fp (F, NUM_BANDS), rms (F,), valid (F,).
+ Steps (same order as firmware): remove DC -> RMS -> /32768 * Hann -> FFT ->
+ mean power per band -> ln(. + eps) -> subtract mean -> (detrend) -> L2 normalise."""
+ L, N = P["FRAME_LEN"], P["NUM_BANDS"]
+ x = frames_i16.astype(np.float64)
+ x -= x.mean(axis=1, keepdims=True)
+ rms = np.sqrt((x * x).mean(axis=1))
+ spec = np.fft.rfft(x / 32768.0 * window, axis=1)[:, : L // 2]
+ power = spec.real ** 2 + spec.imag ** 2
+ sums = np.add.reduceat(power[:, : edges[-1]], edges[:-1], axis=1)
+ v = np.log(sums / np.diff(edges) + P["LOG_EPS"])
+ v -= v.mean(axis=1, keepdims=True)
+ if P["FP_DETREND"]:
+ xc = np.arange(N) - 0.5 * (N - 1)
+ slope = (v @ xc) / (xc @ xc)
+ v -= slope[:, None] * xc
+ norm = np.linalg.norm(v, axis=1)
+ valid = norm > 1e-3
+ fp = np.zeros_like(v)
+ fp[valid] = v[valid] / norm[valid, None]
+ return fp, rms, valid
+
+
+def all_frames(pcm_i16, P, edges, window, chunk=2000):
+ L, H = P["FRAME_LEN"], P["HOP_LEN"]
+ if len(pcm_i16) < L:
+ return np.zeros((0, P["NUM_BANDS"])), np.zeros(0), np.zeros(0, bool)
+ view = np.lib.stride_tricks.sliding_window_view(pcm_i16, L)[::H]
+ fps, rmss, vals = [], [], []
+ for i in range(0, len(view), chunk):
+ fp, rms, ok = fingerprints(view[i:i + chunk], P, edges, window)
+ fps.append(fp); rmss.append(rms); vals.append(ok)
+ return np.vstack(fps), np.concatenate(rmss), np.concatenate(vals)
+
+
+# ------------------------------------------------------------------- audio io
+def load_wav(path, fs_target):
+ """Any WAV -> mono int16-valued array at fs_target (values rounded like the mic path)."""
+ try:
+ fs, data = wavfile.read(path)
+ except Exception as e: # scipy raises various errors for odd formats
+ die("cannot read %s as WAV (%s). If it is 24-bit/compressed, convert to 16-bit PCM or float "
+ "WAV first (e.g. ffmpeg -i in.ext -ac 1 out.wav)." % (path, e))
+ k, size = data.dtype.kind, data.dtype.itemsize
+ if k == "u":
+ x = (data.astype(np.float64) - 2 ** (8 * size - 1)) / 2 ** (8 * size - 1)
+ elif k == "i":
+ x = data.astype(np.float64) / 2 ** (8 * size - 1)
+ elif k == "f":
+ x = data.astype(np.float64)
+ else:
+ die("%s: unsupported sample type %s" % (path, data.dtype))
+ if x.ndim == 2:
+ x = x.mean(axis=1)
+ if x.size == 0:
+ die("%s contains no samples" % path)
+ if fs != fs_target:
+ g = math.gcd(int(fs), int(fs_target))
+ x = resample_poly(x, fs_target // g, int(fs) // g)
+ return np.clip(np.round(x * 32768.0), -32768, 32767).astype(np.int16)
+
+
+# ----------------------------------------------------------------- selection
+def parse_segments(s):
+ out = []
+ for tok in s.split(","):
+ try:
+ a, b = tok.strip().split("-")
+ a, b = float(a), float(b)
+ except ValueError:
+ die("bad segment '%s' (expected START-END in seconds, e.g. 1.2-2.0)" % tok)
+ if b <= a:
+ die("segment '%s': end must be after start" % tok)
+ out.append((a, b))
+ return out
+
+
+def ranges_text(idx, P):
+ """Contiguous runs of frame indices -> 'start-end s' strings."""
+ if len(idx) == 0:
+ return []
+ runs, start, prev = [], idx[0], idx[0]
+ for i in idx[1:]:
+ if i != prev + 1:
+ runs.append((start, prev)); start = i
+ prev = i
+ runs.append((start, prev))
+ H, L, FS = P["HOP_LEN"], P["FRAME_LEN"], P["FS_HZ"]
+ return ["%.2f-%.2f s" % (a * H / FS, (b * H + L) / FS) for a, b in runs]
+
+
+def select_frames(rms, valid, segments, gate_mult, P):
+ H, L, FS = P["HOP_LEN"], P["FRAME_LEN"], P["FS_HZ"]
+ n = len(rms)
+ if segments:
+ sel = np.zeros(n, bool)
+ for a, b in segments:
+ for i in range(n):
+ if i * H >= a * FS and i * H + L <= b * FS:
+ sel[i] = True
+ how = "segments"
+ else:
+ floor = max(np.percentile(rms, 10), 1.0)
+ sel = rms > floor * gate_mult
+ how = "auto (noise floor %.1f x %.1f = RMS gate %.1f)" % (floor, gate_mult, floor * gate_mult)
+ sel &= valid
+ return np.nonzero(sel)[0], how
+
+
+# -------------------------------------------------------------------- report
+def stats_line(label, a):
+ return "%s: n=%d min=%.3f p5=%.3f p50=%.3f mean=%.3f" % (
+ label, len(a), a.min(), np.percentile(a, 5), np.percentile(a, 50), a.mean())
+
+
+def bar_chart(tpl, P, edges):
+ scale = max(abs(tpl).max(), 1e-9)
+ half = 24
+ print("Template (band, centre Hz, value):")
+ for b, v in enumerate(tpl):
+ fc = P["FS_HZ"] * 0.5 * (edges[b] + edges[b + 1]) / P["FRAME_LEN"]
+ n = int(round(abs(v) / scale * half))
+ left = (" " * (half - n) + "#" * n) if v < 0 else " " * half
+ right = ("#" * n) if v >= 0 else ""
+ print(" %2d %6.0f Hz %+.3f %s|%s" % (b, fc, v, left, right))
+
+
+def simulate_triggers(match, nframes_cooldown, N, M):
+ """Same N-of-M + cooldown logic as detector.c, on a boolean match array."""
+ hist, state, cd, trig = 0, "IDLE", 0, 0
+ mask = (1 << N) - 1
+ for m in match:
+ if state == "IDLE":
+ hist = ((hist << 1) | int(m)) & 0xFFFFFFFF
+ if bin(hist & mask).count("1") >= M:
+ trig += 1; state = "TRIGGERED"
+ elif state == "TRIGGERED":
+ state, cd, hist = "COOLDOWN", nframes_cooldown, 0
+ else:
+ cd -= 1
+ if cd <= 0:
+ state, hist = "IDLE", 0
+ return trig
+
+
+# ------------------------------------------------------------------- writers
+def fmt_floats(a, per_line=4):
+ items = ["%.9ef" % v for v in a]
+ lines = []
+ for i in range(0, len(items), per_line):
+ lines.append(" " + ", ".join(items[i:i + per_line]) + ",")
+ return "\n".join(lines)
+
+
+def write_template_h(path, tpl, count, thr, src, P):
+ with open(path, "w") as f:
+ f.write("// GENERATED by tools/make_template.py -- do not edit.\n")
+ f.write("// Source recording: %s\n" % src)
+ f.write("// Parameters: FS_HZ=%d FRAME_LEN=%d HOP_LEN=%d NUM_BANDS=%d F_MIN_HZ=%d F_MAX_HZ=%d "
+ "LOG_EPS=%g FP_DETREND=%d\n" % (P["FS_HZ"], P["FRAME_LEN"], P["HOP_LEN"], P["NUM_BANDS"],
+ P["F_MIN_HZ"], P["F_MAX_HZ"], P["LOG_EPS"], P["FP_DETREND"]))
+ f.write("#ifndef TEMPLATE_H\n#define TEMPLATE_H\n#include \"feature_params.h\"\n")
+ f.write("#define TEMPLATE_VALID 1\n#define TEMPLATE_NUM_BANDS %d\n" % P["NUM_BANDS"])
+ f.write("#define TEMPLATE_FRAME_COUNT %d\n" % count)
+ if thr is not None:
+ f.write("#define TEMPLATE_HAS_SUGGESTED_THRESHOLD 1\n#define TEMPLATE_SUGGESTED_THRESHOLD %.4ff\n" % thr)
+ else:
+ f.write("#define TEMPLATE_HAS_SUGGESTED_THRESHOLD 0\n")
+ f.write("static const float TEMPLATE_FP[NUM_BANDS] = {\n%s\n};\n#endif\n" % fmt_floats(tpl))
+
+
+def write_test_vector_h(path, pcm, fp, rms, frame_idx):
+ with open(path, "w") as f:
+ f.write("// GENERATED by tools/make_template.py -- do not edit.\n")
+ f.write("// One int16 frame from the middle of the buzzer (frame #%d) and the Python-computed\n" % frame_idx)
+ f.write("// fingerprint/RMS the firmware `selftest` must reproduce within 1e-3.\n")
+ f.write("#ifndef TEST_VECTOR_H\n#define TEST_VECTOR_H\n#include \n#include \"feature_params.h\"\n")
+ f.write("#define TEST_VECTOR_VALID 1\n#define TEST_VECTOR_RMS %.6ff\n" % rms)
+ f.write("static const int16_t TEST_VECTOR_PCM[FRAME_LEN] = {\n")
+ for i in range(0, len(pcm), 16):
+ f.write(" " + ", ".join(str(int(v)) for v in pcm[i:i + 16]) + ",\n")
+ f.write("};\nstatic const float TEST_VECTOR_FP[NUM_BANDS] = {\n%s\n};\n#endif\n" % fmt_floats(fp))
+
+
+# ---------------------------------------------------------------------- main
+def main():
+ here = os.path.dirname(os.path.abspath(__file__))
+ root = os.path.dirname(here)
+ ap = argparse.ArgumentParser(description="Build the buzzer spectral template (see file docstring).")
+ ap.add_argument("--buzz", required=True, help="WAV containing the buzzer")
+ ap.add_argument("--segments", help="comma list of START-END seconds, e.g. 1.2-2.0,5.5-6.4")
+ ap.add_argument("--background", nargs="*", default=[], help="WAVs without the buzzer")
+ ap.add_argument("--out", default=os.path.join(root, "main", "template.h"))
+ ap.add_argument("--params", default=os.path.join(root, "main", "feature_params.h"))
+ ap.add_argument("--gate-mult", type=float, default=4.0)
+ ap.add_argument("--min-rms", type=float, default=200.0, help="firmware loudness gate (default Kconfig 200)")
+ ap.add_argument("--window-n", type=int, default=15)
+ ap.add_argument("--window-m", type=int, default=10)
+ ap.add_argument("--cooldown", type=float, default=20.0)
+ ap.add_argument("--threshold", type=float, help="threshold for the false-trigger estimate")
+ a = ap.parse_args()
+
+ P = parse_params(a.params)
+ edges = band_edges(P)
+ win = hann(P["FRAME_LEN"])
+ print("Parameters from %s: %s" % (a.params, {k: v for k, v in P.items()}))
+ print("Band edges (FFT bins):", edges.tolist())
+
+ pcm = load_wav(a.buzz, P["FS_HZ"])
+ print("Loaded %s: %.2f s at %d Hz" % (a.buzz, len(pcm) / P["FS_HZ"], P["FS_HZ"]))
+ fps, rms, valid = all_frames(pcm, P, edges, win)
+ if len(fps) == 0:
+ die("recording shorter than one frame (%d samples)" % P["FRAME_LEN"])
+
+ segs = parse_segments(a.segments) if a.segments else None
+ idx, how = select_frames(rms, valid, segs, a.gate_mult, P)
+ print("Selected %d of %d frames via %s" % (len(idx), len(fps), how))
+ print("Time ranges:", ", ".join(ranges_text(idx, P)) or "(none)")
+ if len(idx) < MIN_FRAMES:
+ die("only %d buzzer frames selected, need >= %d. Give --segments, lower --gate-mult, "
+ "or use a longer/louder recording." % (len(idx), MIN_FRAMES))
+
+ tpl = fps[idx].mean(axis=0)
+ nrm = np.linalg.norm(tpl)
+ if nrm < 1e-3:
+ die("mean fingerprint is degenerate (flat spectrum)")
+ tpl /= nrm
+
+ buzz_scores = fps[idx] @ tpl
+ print("\n" + stats_line("Buzzer frames vs template", buzz_scores))
+ p5 = float(np.percentile(buzz_scores, 5))
+ print()
+ bar_chart(tpl, P, edges)
+
+ thr_suggest = None
+ if a.background:
+ bg_all, n_hours = [], 0.0
+ sims = []
+ thr_for_sim = a.threshold
+ per_file = []
+ for path in a.background:
+ bpcm = load_wav(path, P["FS_HZ"])
+ bfp, brms, bval = all_frames(bpcm, P, edges, win)
+ if len(bfp) == 0:
+ print("WARNING: %s shorter than a frame, skipped" % path)
+ continue
+ sc = bfp @ tpl
+ sc[~bval] = 0.0
+ per_file.append((path, sc, brms, len(bpcm) / P["FS_HZ"]))
+ bg_all.append(sc)
+ if not bg_all:
+ die("no usable background audio")
+ bg = np.concatenate(bg_all)
+ p99 = float(np.percentile(bg, 99))
+ print("\nBackground (%d frames from %d file(s)): min=%.3f mean=%.3f p95=%.3f p99=%.3f max=%.3f" % (
+ len(bg), len(per_file), bg.min(), bg.mean(), np.percentile(bg, 95), p99, bg.max()))
+ thr_suggest = float(np.clip(0.5 * (p99 + p5), 0.50, 0.98))
+ print("Recommended threshold: %.3f (midpoint of background p99 %.3f and buzzer p5 %.3f)" % (thr_suggest, p99, p5))
+ if p5 <= p99:
+ print("!" * 70)
+ print("!! WARNING: background p99 (%.3f) >= buzzer p5 (%.3f): DISTRIBUTIONS OVERLAP." % (p99, p5))
+ print("!! Expect false alarms or missed buzzes. Use a cleaner/louder buzzer recording,")
+ print("!! more specific --segments, or try FP_DETREND 0 (then rerun this tool).")
+ print("!" * 70)
+ thr_use = a.threshold if a.threshold is not None else thr_suggest
+ cd_frames = int(round(a.cooldown * P["FS_HZ"] / P["HOP_LEN"]))
+ trig, secs = 0, 0.0
+ for path, sc, brms, dur in per_file:
+ match = (brms >= a.min_rms) & (sc >= thr_use)
+ trig += simulate_triggers(match, cd_frames, a.window_n, a.window_m)
+ secs += dur
+ print("Frames passing the loudness gate (RMS>=%.0f): %.1f%%" % (
+ a.min_rms, 100.0 * np.mean(np.concatenate([f[2] for f in per_file]) >= a.min_rms)))
+ print("False-trigger estimate at threshold %.3f with %d-of-%d window, %.0f s cooldown: "
+ "%d trigger(s) in %.1f min = %.2f per hour" % (
+ thr_use, a.window_m, a.window_n, a.cooldown, trig, secs / 60.0, trig / (secs / 3600.0)))
+
+ out_dir = os.path.dirname(os.path.abspath(a.out)) or "."
+ os.makedirs(out_dir, exist_ok=True)
+ write_template_h(a.out, tpl, len(idx), thr_suggest, os.path.basename(a.buzz), P)
+ tv_path = os.path.join(out_dir, "test_vector.h")
+ mid = int(idx[len(idx) // 2])
+ frame = pcm[mid * P["HOP_LEN"]: mid * P["HOP_LEN"] + P["FRAME_LEN"]]
+ tfp, trms, tok = fingerprints(frame[None, :], P, edges, win)
+ if not tok[0]:
+ die("test-vector frame has a degenerate fingerprint")
+ write_test_vector_h(tv_path, frame, tfp[0], float(trms[0]), mid)
+ print("\nWrote %s\nWrote %s\nNext: idf.py build flash monitor, then `selftest`, then `selfcheck`." % (a.out, tv_path))
+
+
+if __name__ == "__main__":
+ main()