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()