Compare commits

...
3 Commits
39 changed files with 2982 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 cmake_minimum_required(VERSION 3.16)
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake) include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# "Trim" the build. Include the minimal set of components, main, and anything it depends on. project(door_buzzer)
idf_build_set_property(MINIMAL_BUILD ON)
project(buzz_detector)
+144 -25
View File
@@ -1,32 +1,151 @@
# 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 Listens through an I2S MEMS mic, compares the *shape* of the spectrum of every 32 ms frame to a stored
the smoothed sound level (RMS) using the ESP32-S3 board's onboard RGB LED. The buzzer template (cosine similarity of a 32-band log-spectrum fingerprint), and posts each completed buzz
microphone's left channel is used because its L/R pin is grounded. event to a configurable HTTP endpoint. No machine learning.
The LED fades from off to green as the smoothed RMS rises from 0 to 100,000. Written in C (C11), as your file list (`main.c`, ...) implied. ESP-IDF API usage follows v5.x/v6.0 conventions
From 100,000 to 1,000,000 RMS, it smoothly changes from green to red; at or (`driver/i2s_std.h`, `esp_driver_*` components, esp_console REPL).
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.
The thresholds are defined by `GREEN_NOISE_RMS` and `RED_NOISE_RMS` near the ## Project tree
top of [main/main.c](main/main.c). Adjust them after checking the RMS values in
the serial monitor for your microphone and environment.
## 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 + queued event HTTP POST
└── led.c/h # WS2812 status LED
```
| Module | Pin | ESP32-S3 | 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`,
| INMP441 | SCK / BCLK | GPIO 3 | (6) watchdog / reconnect / LED in the same files. The final tree is what is delivered; each layer only depends on the ones before it.
| 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) |
## Build and run ## Wiring (INMP441)
Open this folder as an ESP-IDF project, select the `esp32s3` target, then build, | INMP441 | ESP32-S3 | Note |
flash, and open the serial monitor. The firmware uses ESP-IDF's I2S driver and |---|---|---|
the `espressif/led_strip` component. | 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.
* **Buzz event HTTP endpoint**: configure a full endpoint URL and secret. Each completed buzz sends one
`application/json` POST with `{"delay_ms":<detected duration>,"secret":"<configured secret>"}`. There are no
HTTP retries or heartbeat messages. The endpoint must be reachable from the ESP32; `127.0.0.1` refers to
the ESP32 itself, so use the server's LAN address when it runs on another machine.
* **Hardware**: pins, I2S slot, `Right shift` (default 14 = 4x digital gain for the quiet INMP441).
* **Detector defaults**: threshold, loudness gate, N/M, and a 500 ms quiet tail for grouping intermittent matching frames into one buzz. Each new match within that tail extends the event; its reported duration runs from the first qualifying match through the last, excluding the final quiet tail. Duration is frame-granular (32 ms hops), and is included in the event POST body and `status`. Settings 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 event POST (delay_ms=42)
buzz> status # template source, timing, Wi-Fi, event POST 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`.
## 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).
* **Event sender:** exponential Wi-Fi reconnect (1 s .. 60 s); at most one HTTP POST per queued buzz event, no request retries.
* 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: 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: espressif/led_strip:
component_hash: 28621486f77229aaf81c71f5e15d6fbf36c2949cf11094e07090593e659e7639 component_hash: 28621486f77229aaf81c71f5e15d6fbf36c2949cf11094e07090593e659e7639
dependencies: dependencies:
@@ -14,7 +24,9 @@ dependencies:
type: idf type: idf
version: 6.0.0 version: 6.0.0
direct_dependencies: direct_dependencies:
- espressif/esp-dsp
- espressif/led_strip - espressif/led_strip
manifest_hash: cdcc77aff1f56b906c61e51c8f438577513c8d92aa9dfd7bf655f126d2b2d320 - idf
manifest_hash: 1ecd46a48423b6609e679fe9f0888e13e7702742f2dcb02c0937daebcd6f4fb2
target: esp32s3 target: esp32s3
version: 3.0.0 version: 3.0.0
+19 -3
View File
@@ -1,3 +1,19 @@
idf_component_register(SRCS "main.c" idf_component_register(
INCLUDE_DIRS "." SRCS
REQUIRES esp_driver_gpio esp_driver_i2s led_strip) 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
)
+108
View File
@@ -0,0 +1,108 @@
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 "Quiet tail to group buzz frames into one event (milliseconds)"
default 500
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 "Buzz event HTTP endpoint"
config BUZZ_EVENT_URL
string "POST endpoint URL"
default ""
help
Full URL for the JSON event POST. Use an address reachable from the ESP32;
localhost refers to the ESP32 itself, not your development computer.
config BUZZ_EVENT_SECRET
string "Event secret key"
default ""
help
Secret sent in the JSON request body. Keep it private and configure it for the endpoint.
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);
+210
View File
@@ -0,0 +1,210 @@
#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 0xB0220004u
#define CFG_MAGIC_PRE_EVENT_POST 0xB0220003u
#define CFG_MAGIC_PRE_500MS 0xB0220002u
#define CFG_MAGIC_PRE_COOLDOWN_MS 0xB0220001u
typedef struct { uint32_t magic; app_config_t cfg; } cfg_blob_t;
typedef struct {
uint32_t magic;
float threshold, min_rms;
uint32_t win_n, win_m, cooldown_ms;
float noise_mult, adapt_weight;
uint32_t monitor_ms, heartbeat_h;
} legacy_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, "quiet tail used to group buzzer frames (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)"),
};
#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;
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) {
union {
cfg_blob_t current;
legacy_cfg_blob_t legacy;
} blob;
size_t len = sizeof(blob);
if (nvs_get_blob(h, CFG_KEY, &blob, &len) == ESP_OK) {
if (len == sizeof(cfg_blob_t) && blob.current.magic == CFG_MAGIC) {
s_cfg = blob.current.cfg;
ESP_LOGI(TAG, "loaded saved config from NVS");
} else if (len == sizeof(legacy_cfg_blob_t) &&
(blob.legacy.magic == CFG_MAGIC_PRE_EVENT_POST ||
blob.legacy.magic == CFG_MAGIC_PRE_500MS ||
blob.legacy.magic == CFG_MAGIC_PRE_COOLDOWN_MS)) {
s_cfg.threshold = blob.legacy.threshold;
s_cfg.min_rms = blob.legacy.min_rms;
s_cfg.win_n = blob.legacy.win_n;
s_cfg.win_m = blob.legacy.win_m;
s_cfg.cooldown_ms = blob.legacy.magic == CFG_MAGIC_PRE_EVENT_POST
? blob.legacy.cooldown_ms : CONFIG_BUZZ_COOLDOWN_MS;
s_cfg.noise_mult = blob.legacy.noise_mult;
s_cfg.adapt_weight = blob.legacy.adapt_weight;
s_cfg.monitor_ms = blob.legacy.monitor_ms;
ESP_LOGI(TAG, "migrating saved detector settings; heartbeat setting removed");
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);
}
}
+25
View File
@@ -0,0 +1,25 @@
#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 tail that groups matches into one buzz event
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
} 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"
+228
View File
@@ -0,0 +1,228 @@
#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, buzz quiet tail %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("last buzz: %lu ms (final quiet tail excluded)\n", (unsigned long)ps.last_buzz_ms);
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("event POST: %s, ok %lu, failed %lu, dropped %lu\n",
ns.endpoint_configured ? "endpoint configured" : "endpoint not configured",
(unsigned long)ns.sent_ok, (unsigned long)ns.sent_failed, (unsigned long)ns.dropped);
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(42) ? "queued test event (delay_ms=42)\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 event POST", 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
+83
View File
@@ -0,0 +1,83 @@
#include <string.h>
#include "detector.h"
#include "feature_params.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;
d->frame_index = 0;
}
bool detector_is_match(float rms, float score, float min_rms, float threshold)
{
return rms >= min_rms && score >= threshold;
}
static void update_idle(detector_t *d, bool match, uint32_t now_ms,
uint32_t n, uint32_t m)
{
uint32_t slot = d->frame_index % 32;
d->match_time_ms[slot] = now_ms;
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) {
uint32_t matches_seen = 0;
for (uint32_t offset = 0; offset < n; offset++) {
if ((d->hist & (1u << offset)) && ++matches_seen == m) {
uint32_t first_slot = (d->frame_index - offset) % 32;
d->buzz_start_ms = d->match_time_ms[first_slot];
break;
}
}
d->last_match_ms = now_ms;
d->state = DET_TRIGGERED;
d->triggers++;
}
d->frame_index++;
}
bool detector_update(detector_t *d, bool match, uint32_t now_ms, const detector_cfg_t *cfg,
uint32_t *buzz_duration_ms)
{
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:
update_idle(d, match, now_ms, n, m);
return false;
case DET_TRIGGERED:
case DET_COOLDOWN:
if (match && (uint32_t)(now_ms - d->last_match_ms) < cfg->cooldown_ms) {
d->last_match_ms = now_ms;
d->state = DET_COOLDOWN;
} else {
d->state = DET_COOLDOWN;
if ((uint32_t)(now_ms - d->last_match_ms) < cfg->cooldown_ms) break;
d->last_completed_start_ms = d->buzz_start_ms;
if (buzz_duration_ms) {
*buzz_duration_ms = (uint32_t)(d->last_match_ms - d->buzz_start_ms) +
(1000u * HOP_LEN / FS_HZ);
}
d->state = DET_IDLE;
d->hist = 0;
if (match) update_idle(d, true, now_ms, n, m);
return true;
}
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 "?";
}
+36
View File
@@ -0,0 +1,36 @@
#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 frame_index;
uint32_t match_time_ms[32];
uint32_t buzz_start_ms;
uint32_t last_match_ms;
uint32_t last_completed_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 when a buzz event ends and writes its duration.
// Matches within cooldown_ms extend the same event; the final quiet tail is
// excluded from its duration.
bool detector_update(detector_t *d, bool match, uint32_t now_ms, const detector_cfg_t *cfg,
uint32_t *buzz_duration_ms);
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: 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> // Door buzzer detector: spectral-template matching on an I2S MEMS mic, event HTTP POST.
#include <stdint.h> #include <stdio.h>
#include "driver/gpio.h" #include "driver/gpio.h"
#include "driver/i2s_std.h"
#include "esp_err.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.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 static const char *TAG = "main";
#define I2S_WS_GPIO GPIO_NUM_4
#define I2S_DATA_GPIO GPIO_NUM_5
#define RGB_LED_GPIO GPIO_NUM_48
#define SAMPLE_RATE_HZ 16000 // BOOT button (GPIO0, active low). A short press (30 ms .. 1 s) starts `learn`.
#define SAMPLES_PER_READ 256 static void button_task(void *arg)
#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)
{ {
const led_strip_config_t strip_config = { gpio_config_t io = {
.strip_gpio_num = RGB_LED_GPIO, .pin_bit_mask = 1ULL << BOOT_BTN_GPIO,
.max_leds = 1, .mode = GPIO_MODE_INPUT,
.led_model = LED_MODEL_WS2812, .pull_up_en = GPIO_PULLUP_ENABLE,
.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
.flags = {
.invert_out = false,
},
}; };
const led_strip_rmt_config_t rmt_config = { if (gpio_config(&io) != ESP_OK) {
.clk_src = RMT_CLK_SRC_DEFAULT, ESP_LOGE(TAG, "button gpio_config failed");
.resolution_hz = 10 * 1000 * 1000, vTaskDelete(NULL);
.mem_block_symbols = 64, }
.flags = { int64_t down_at = 0;
.with_dma = false, for (;;) {
}, vTaskDelay(pdMS_TO_TICKS(20));
}; bool down = gpio_get_level(BOOT_BTN_GPIO) == 0;
if (down && down_at == 0) {
led_strip_handle_t strip = NULL; down_at = esp_timer_get_time();
ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &strip)); } else if (!down && down_at != 0) {
ESP_ERROR_CHECK(led_strip_clear(strip)); int64_t held_ms = (esp_timer_get_time() - down_at) / 1000;
return strip; down_at = 0;
} if (held_ms >= 30 && held_ms <= 1000) {
printf("\n[BOOT button] starting learn\n");
static void update_rgb_led(led_strip_handle_t strip, float rms) calibration_run_learn(false, 0.0f); // blocks this task only
{ }
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;
} }
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; if (err != ESP_OK) {
const i2s_chan_config_t channel_config = ESP_LOGE(TAG, "%s failed: %s", what, esp_err_to_name(err));
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;
} }
void app_main(void) void app_main(void)
{ {
led_strip_handle_t rgb_led = init_rgb_led(); ESP_ERROR_CHECK(config_init()); // also initialises NVS
i2s_chan_handle_t microphone = init_microphone(); check(led_init(), "led_init");
int32_t samples[SAMPLES_PER_READ]; ESP_ERROR_CHECK(calibration_init());
float smoothed_rms = 0.0f; ESP_ERROR_CHECK(matcher_init());
uint32_t log_counter = 0; 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", if (xTaskCreate(button_task, "button", 4096, NULL, 3, NULL) != pdPASS) {
I2S_BCLK_GPIO, I2S_WS_GPIO, I2S_DATA_GPIO, RGB_LED_GPIO); ESP_LOGE(TAG, "button task not created");
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;
}
} }
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
+221
View File
@@ -0,0 +1,221 @@
// Wi-Fi station with exponential-backoff reconnect and queued event POSTs.
// HTTP requests run in their own task so networking cannot 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 "notifier.h"
static const char *TAG = "notify";
#define WIFI_CONNECTED_BIT BIT0
#define NET_TASK_STACK 8192
#define NET_TASK_PRIO 5
#define BACKOFF_MIN_MS 1000
#define BACKOFF_MAX_MS 60000
#define EVENT_BODY_SIZE 512
typedef struct {
uint32_t delay_ms;
} event_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 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);
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 bool make_event_body(uint32_t delay_ms, char *body, size_t body_size)
{
int n = snprintf(body, body_size, "{\"delay_ms\":%lu,\"secret\":\"",
(unsigned long)delay_ms);
if (n < 0 || (size_t)n >= body_size) return false;
size_t used = (size_t)n;
for (const unsigned char *p = (const unsigned char *)CONFIG_BUZZ_EVENT_SECRET; *p; p++) {
char escaped[7];
const char *text = (const char *)p;
size_t len = 1;
if (*p == '"' || *p == '\\') {
escaped[0] = '\\';
escaped[1] = (char)*p;
text = escaped;
len = 2;
} else if (*p < 0x20) {
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
text = escaped;
len = 6;
}
if (len >= body_size - used) return false;
memcpy(body + used, text, len);
used += len;
}
if (body_size - used < 3) return false;
body[used++] = '"';
body[used++] = '}';
body[used] = '\0';
return true;
}
// One queue item produces at most one HTTP request: there is deliberately no retry.
static bool post_event(uint32_t delay_ms)
{
EventBits_t bits = xEventGroupWaitBits(s_eg, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE,
pdMS_TO_TICKS(15000));
if (!(bits & WIFI_CONNECTED_BIT)) {
ESP_LOGE(TAG, "Wi-Fi unavailable; event was not sent");
return false;
}
char body[EVENT_BODY_SIZE];
if (!make_event_body(delay_ms, body, sizeof(body))) {
ESP_LOGE(TAG, "event body exceeds buffer");
return false;
}
const char *url = "http://192.168.0.154:3000/event/buzz";
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = 10000,
.disable_auto_redirect = true,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t c = esp_http_client_init(&cfg);
if (!c) {
ESP_LOGE(TAG, "could not create HTTP client");
return false;
}
esp_err_t err = esp_http_client_set_header(c, "Content-Type", "application/json");
if (err == ESP_OK) err = esp_http_client_set_post_field(c, body, (int)strlen(body));
if (err != ESP_OK) {
ESP_LOGE(TAG, "configure event POST: %s", esp_err_to_name(err));
esp_http_client_cleanup(c);
return false;
}
err = esp_http_client_perform(c);
int status = err == ESP_OK ? esp_http_client_get_status_code(c) : 0;
esp_http_client_cleanup(c);
if (err != ESP_OK) {
ESP_LOGE(TAG, "event POST failed: %s", esp_err_to_name(err));
return false;
}
if (status < 200 || status >= 300) {
ESP_LOGE(TAG, "event endpoint returned HTTP %d", status);
return false;
}
ESP_LOGI(TAG, "sent buzz duration %lu ms (HTTP %d)", (unsigned long)delay_ms, status);
return true;
}
static void net_task(void *arg)
{
event_msg_t msg;
for (;;) {
if (xQueueReceive(s_q, &msg, portMAX_DELAY) == pdTRUE) {
if (post_event(msg.delay_ms)) s_stats.sent_ok++;
else s_stats.sent_failed++;
}
}
}
bool notifier_send(uint32_t delay_ms)
{
event_msg_t msg = { .delay_ms = delay_ms };
if (xQueueSend(s_q, &msg, 0) != pdTRUE) {
s_stats.dropped++;
ESP_LOGW(TAG, "event queue full, dropped");
return false;
}
return true;
}
void notifier_get_stats(notifier_stats_t *out)
{
*out = s_stats;
out->wifi_configured = strlen(CONFIG_BUZZ_WIFI_SSID) > 0;
out->endpoint_configured = strlen(CONFIG_BUZZ_EVENT_URL) > 0;
}
esp_err_t notifier_init(void)
{
s_q = xQueueCreate(4, sizeof(event_msg_t));
s_eg = xEventGroupCreate();
if (!s_q || !s_eg) return ESP_ERR_NO_MEM;
s_stats.endpoint_configured = strlen(CONFIG_BUZZ_EVENT_URL) > 0;
if (!s_stats.endpoint_configured) {
ESP_LOGW(TAG, "no event endpoint configured; buzz events will be dropped");
}
if (!strlen(CONFIG_BUZZ_EVENT_SECRET)) {
ESP_LOGW(TAG, "event secret is empty; configure it before using the endpoint");
}
s_stats.wifi_configured = strlen(CONFIG_BUZZ_WIFI_SSID) > 0;
if (s_stats.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; event requests cannot be sent");
}
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;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct {
bool wifi_connected;
char ip[16];
uint32_t sent_ok, sent_failed, dropped;
bool wifi_configured;
bool endpoint_configured;
} notifier_stats_t;
esp_err_t notifier_init(void); // Wi-Fi + event POST task
bool notifier_send(uint32_t delay_ms); // queue one duration event; non-blocking
void notifier_get_stats(notifier_stats_t *out);
+152
View File
@@ -0,0 +1,152 @@
// 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 buzz_ended = false;
uint32_t buzz_duration_ms = 0;
if (mode == MODE_NORMAL) {
if (has_tpl) {
detector_cfg_t dc = { cfg.win_n, cfg.win_m, cfg.cooldown_ms };
buzz_ended = detector_update(&det, match, now_ms, &dc, &buzz_duration_ms);
} else {
detector_reset(&det);
}
} else {
calibration_on_frame(fp, valid, rms, score); // detector paused: no alerts
detector_reset(&det);
}
if (buzz_ended) {
char message[96];
snprintf(message, sizeof(message), "Buzzer detected: %lu ms",
(unsigned long)buzz_duration_ms);
ESP_LOGW(TAG, "BUZZER EVENT ended, detected duration %lu ms",
(unsigned long)buzz_duration_ms);
notifier_send(buzz_duration_ms);
}
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 (buzz_ended) {
s_stats.last_trigger_s = det.last_completed_start_ms / 1000;
if (s_stats.last_trigger_s == 0) s_stats.last_trigger_s = 1;
s_stats.last_buzz_ms = buzz_duration_ms;
}
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;
}
+24
View File
@@ -0,0 +1,24 @@
#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
uint32_t last_buzz_ms; // detected duration, excluding final quiet tail
} 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
+68
View File
@@ -0,0 +1,68 @@
// 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 starts after N-of-M matches, groups matches within 500 ms,
// includes intermittent gaps, and excludes the final quiet tail.
detector_t d; detector_init(&d);
detector_cfg_t cfg = { 1, 1, 500 };
uint32_t duration = 0;
CHECK(!detector_update(&d, true, 0, &cfg, &duration), "event remains open after first match");
CHECK(d.state == DET_TRIGGERED && d.triggers == 1, "event starts on match");
CHECK(!detector_update(&d, false, 100, &cfg, &duration), "intermittent gap remains in event");
CHECK(!detector_update(&d, true, 400, &cfg, &duration), "match within quiet tail extends event");
CHECK(!detector_update(&d, false, 600, &cfg, &duration), "event remains open before tail expires");
CHECK(detector_update(&d, false, 900, &cfg, &duration), "event closes at 500 ms after last match");
CHECK(duration == 432, "duration includes gap, excludes final quiet tail (got %lu ms)",
(unsigned long)duration);
CHECK(d.state == DET_IDLE, "returns to idle after event closes");
CHECK(!detector_update(&d, true, 932, &cfg, &duration), "next event starts after prior event closes");
CHECK(detector_update(&d, false, 1432, &cfg, &duration), "single-frame event closes");
CHECK(duration == (1000u * HOP_LEN / FS_HZ), "single-frame duration is one hop (got %lu ms)",
(unsigned long)duration);
detector_reset(&d);
cfg = (detector_cfg_t){ 5, 3, 500 };
CHECK(!detector_update(&d, true, 0, &cfg, &duration), "first of three matches");
CHECK(!detector_update(&d, false, 32, &cfg, &duration), "intervening non-match");
CHECK(!detector_update(&d, true, 64, &cfg, &duration), "second match");
CHECK(!detector_update(&d, true, 96, &cfg, &duration), "third match starts event");
CHECK(d.buzz_start_ms == 0, "event start is the earliest qualifying match");
CHECK(detector_update(&d, false, 608, &cfg, &duration), "N-of-M event ends after quiet tail");
CHECK(duration == 128, "N-of-M duration includes one-hop extent (got %lu ms)",
(unsigned long)duration);
// Sparse matches must not satisfy 10-of-15.
cfg = (detector_cfg_t){ 15, 10, 500 };
int n = 0;
detector_reset(&d);
uint32_t t = 0;
for (int i = 0; i < 300; i++, t += 32) {
n += detector_update(&d, i % 3 == 0, t, &cfg, &duration);
}
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__)
+390
View File
@@ -0,0 +1,390 @@
#!/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 0.5]
[--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_quiet_tail, N, M):
"""Simulate N-of-M detection and group matches inside the quiet tail."""
hist, state, quiet_left, trig = 0, "IDLE", 0, 0
mask = (1 << N) - 1
for m in match:
if state != "IDLE":
if quiet_left <= 1:
state, hist = "IDLE", 0
else:
quiet_left = nframes_quiet_tail if m else quiet_left - 1
continue
if state == "IDLE":
hist = ((hist << 1) | int(m)) & 0xFFFFFFFF
if bin(hist & mask).count("1") >= M:
trig += 1
state = "GROUPING"
quiet_left = nframes_quiet_tail
hist = 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=0.5,
help="quiet tail for grouping buzz frames, in seconds (default 0.5)")
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 ms quiet tail: "
"%d buzz event(s) in %.1f min = %.2f per hour" % (
thr_use, a.window_m, a.window_n, a.cooldown * 1000.0,
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()