Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
753a62beca | ||
|
|
63d9995f59 | ||
|
|
7862a09858 |
+2
-7
@@ -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)
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
(6) watchdog / reconnect / LED in the same files. The final tree is what is delivered; each layer only depends on the ones before it.
|
||||||
|
|
||||||
|
## Wiring (INMP441)
|
||||||
|
|
||||||
|
| INMP441 | ESP32-S3 | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| INMP441 | SCK / BCLK | GPIO 3 |
|
| VDD | 3V3 | |
|
||||||
| INMP441 | WS / LRCLK | GPIO 4 |
|
| GND | GND | |
|
||||||
| INMP441 | SD | GPIO 5 |
|
| SCK | GPIO3 | BCLK (`BUZZ_I2S_BCLK_GPIO`) |
|
||||||
| INMP441 | L/R | GND |
|
| WS | GPIO4 | (`BUZZ_I2S_WS_GPIO`) |
|
||||||
| INMP441 | VDD | 3.3V |
|
| SD | GPIO5 | data out of mic (`BUZZ_I2S_DATA_GPIO`) |
|
||||||
| INMP441 | GND | GND |
|
| L/R | GND | left slot (to VDD -> change slot to Right in menuconfig) |
|
||||||
| Onboard RGB LED | Data | GPIO 48 (on supported board variants) |
|
|
||||||
|
|
||||||
## Build and run
|
On-board: RGB LED on GPIO48 (WS2812), BOOT button GPIO0. LED: red = needs template, dim blue = listening,
|
||||||
|
amber = calibration session, magenta = alert/cooldown.
|
||||||
|
|
||||||
Open this folder as an ESP-IDF project, select the `esp32s3` target, then build,
|
## menuconfig
|
||||||
flash, and open the serial monitor. The firmware uses ESP-IDF's I2S driver and
|
|
||||||
the `espressif/led_strip` component.
|
```
|
||||||
|
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
@@ -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
|
||||||
|
|||||||
+18
-2
@@ -1,3 +1,19 @@
|
|||||||
idf_component_register(SRCS "main.c"
|
idf_component_register(
|
||||||
|
SRCS
|
||||||
|
main.c
|
||||||
|
audio_in.c
|
||||||
|
fingerprint.c
|
||||||
|
matcher.c
|
||||||
|
detector.c
|
||||||
|
calibration.c
|
||||||
|
config_store.c
|
||||||
|
processing.c
|
||||||
|
console.c
|
||||||
|
notifier.c
|
||||||
|
led.c
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
REQUIRES esp_driver_gpio esp_driver_i2s led_strip)
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "esp_err.h"
|
||||||
|
esp_err_t console_start(void); // esp_console REPL on the configured console device
|
||||||
@@ -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 "?";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
+58
-119
@@ -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");
|
||||||
|
calibration_run_learn(false, 0.0f); // blocks this task only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void update_rgb_led(led_strip_handle_t strip, float rms)
|
static void check(esp_err_t err, const char *what)
|
||||||
{
|
{
|
||||||
float red = 0.0f;
|
if (err != ESP_OK) {
|
||||||
float green = 0.0f;
|
ESP_LOGE(TAG, "%s failed: %s", what, esp_err_to_name(err));
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
i2s_chan_handle_t rx_channel = NULL;
|
|
||||||
const i2s_chan_config_t channel_config =
|
|
||||||
I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
|
|
||||||
ESP_ERROR_CHECK(i2s_new_channel(&channel_config, NULL, &rx_channel));
|
|
||||||
|
|
||||||
i2s_std_config_t standard_config = {
|
|
||||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE_HZ),
|
|
||||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
|
|
||||||
I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO),
|
|
||||||
.gpio_cfg = {
|
|
||||||
.mclk = I2S_GPIO_UNUSED,
|
|
||||||
.bclk = I2S_BCLK_GPIO,
|
|
||||||
.ws = I2S_WS_GPIO,
|
|
||||||
.dout = I2S_GPIO_UNUSED,
|
|
||||||
.din = I2S_DATA_GPIO,
|
|
||||||
.invert_flags = {
|
|
||||||
.mclk_inv = false,
|
|
||||||
.bclk_inv = false,
|
|
||||||
.ws_inv = false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
standard_config.slot_cfg.slot_mask = I2S_STD_SLOT_LEFT;
|
|
||||||
|
|
||||||
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_channel, &standard_config));
|
|
||||||
ESP_ERROR_CHECK(i2s_channel_enable(rx_channel));
|
|
||||||
return rx_channel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
printf("\nDoor buzzer detector ready. Type `help`.\n");
|
||||||
for (size_t i = 0; i < sample_count; ++i) {
|
if (!matcher_has_template()) {
|
||||||
// INMP441 provides 24-bit signed samples in the upper bits of each 32-bit slot.
|
printf("NEEDS TEMPLATE. Either:\n"
|
||||||
const int32_t sample = samples[i] >> 8;
|
" 1) python tools/make_template.py --buzz your_recording.wav then rebuild/flash, or\n"
|
||||||
sum_squares += (int64_t)sample * sample;
|
" 2) type `learn` (or short-press BOOT) and press the buzzer during the 60 s session.\n");
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
check(console_start(), "console_start");
|
||||||
}
|
}
|
||||||
|
|||||||
+161
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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"; }
|
||||||
@@ -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__)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user