vibe code the initial impl

This commit is contained in:
2026-10-13 19:47:46 -05:00
parent 7da1c2119d
commit 7862a09858
39 changed files with 2867 additions and 157 deletions
+2 -7
View File
@@ -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)
+142 -25
View File
@@ -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 <param> <value>` `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.
+13 -1
View File
@@ -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
+19 -3
View File
@@ -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
)
+110
View File
@@ -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
+175
View File
@@ -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 <string.h>
#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; }
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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
+9
View File
@@ -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)
+354
View File
@@ -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 <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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);
+190
View File
@@ -0,0 +1,190 @@
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
#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);
}
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#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"
+227
View File
@@ -0,0 +1,227 @@
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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 <param> <value> (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)", "<param> <value>", 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;
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
#include "esp_err.h"
esp_err_t console_start(void); // esp_console REPL on the configured console device
+51
View File
@@ -0,0 +1,51 @@
#include <string.h>
#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 "?";
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
// Pure state machine: no ESP-IDF dependencies, can be compiled and tested on a PC.
#include <stdbool.h>
#include <stdint.h>
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);
+24
View File
@@ -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
+141
View File
@@ -0,0 +1,141 @@
// Spectral fingerprint. MUST stay numerically identical to tools/make_template.py.
#include <math.h>
#include <string.h>
#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 = <v, xc> / <xc, xc>.
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);
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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
+3 -1
View File
@@ -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"
+41
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <stdint.h>
#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
+59 -120
View File
@@ -1,139 +1,78 @@
#include <math.h>
#include <stdint.h>
// Door buzzer detector: spectral-template matching on an I2S MEMS mic, ntfy push alerts.
#include <stdio.h>
#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");
}
+161
View File
@@ -0,0 +1,161 @@
#include <math.h>
#include <string.h>
#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;
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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
+200
View File
@@ -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 <stdio.h>
#include <string.h>
#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;
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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);
+143
View File
@@ -0,0 +1,143 @@
// features -> score -> detector, or calibration while a session is active.
#include <stdio.h>
#include <string.h>
#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;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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);
+21
View File
@@ -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
+86
View File
@@ -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 <stdint.h>
#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
BIN
View File
Binary file not shown.
+20
View File
@@ -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
+9
View File
@@ -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
+45
View File
@@ -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 <math.h>
#include <stdio.h>
#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;
}
+26
View File
@@ -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 <math.h>
#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; }
+6
View File
@@ -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"; }
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <stdio.h>
#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__)
+385
View File
@@ -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 <stdint.h>\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()