152 lines
9.5 KiB
Markdown
152 lines
9.5 KiB
Markdown
# Door buzzer detector (ESP32-S3, ESP-IDF, spectral template matching)
|
|
|
|
Listens through an I2S MEMS mic, compares the *shape* of the spectrum of every 32 ms frame to a stored
|
|
buzzer template (cosine similarity of a 32-band log-spectrum fingerprint), and posts each completed buzz
|
|
event to a configurable HTTP endpoint. No machine learning.
|
|
|
|
Written in C (C11), as your file list (`main.c`, ...) implied. ESP-IDF API usage follows v5.x/v6.0 conventions
|
|
(`driver/i2s_std.h`, `esp_driver_*` components, esp_console REPL).
|
|
|
|
## Project tree
|
|
|
|
```
|
|
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
|
|
```
|
|
|
|
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 |
|
|
|---|---|---|
|
|
| VDD | 3V3 | |
|
|
| GND | GND | |
|
|
| SCK | GPIO3 | BCLK (`BUZZ_I2S_BCLK_GPIO`) |
|
|
| WS | GPIO4 | (`BUZZ_I2S_WS_GPIO`) |
|
|
| SD | GPIO5 | data out of mic (`BUZZ_I2S_DATA_GPIO`) |
|
|
| L/R | GND | left slot (to VDD -> change slot to Right in menuconfig) |
|
|
|
|
On-board: RGB LED on GPIO48 (WS2812), BOOT button GPIO0. LED: red = needs template, dim blue = listening,
|
|
amber = calibration session, magenta = alert/cooldown.
|
|
|
|
## menuconfig
|
|
|
|
```
|
|
idf.py set-target esp32s3
|
|
idf.py menuconfig # "Door Buzzer Detector"
|
|
```
|
|
* **Wi-Fi**: SSID + password.
|
|
* **Buzz event HTTP endpoint**: configure a full endpoint URL and secret. Each completed buzz sends one
|
|
`application/json` POST with `{"delay_ms":<detected duration>,"secret":"<configured secret>"}`. There are no
|
|
HTTP retries or heartbeat messages. The endpoint must be reachable from the ESP32; `127.0.0.1` refers to
|
|
the ESP32 itself, so use the server's LAN address when it runs on another machine.
|
|
* **Hardware**: pins, I2S slot, `Right shift` (default 14 = 4x digital gain for the quiet INMP441).
|
|
* **Detector defaults**: threshold, loudness gate, N/M, and a 500 ms quiet tail for grouping intermittent matching frames into one buzz. Each new match within that tail extends the event; its reported duration runs from the first qualifying match through the last, excluding the final quiet tail. Duration is frame-granular (32 ms hops), and is included in the event POST body and `status`. Settings can also be changed live with `set` and persist in NVS.
|
|
* Console: defaults to the **UART** USB port. For the native USB port set *Component config > ESP System Settings > Channel for console output*
|
|
to *USB Serial/JTAG*.
|
|
|
|
## Build / flash
|
|
|
|
```
|
|
idf.py set-target esp32s3
|
|
idf.py build flash monitor
|
|
```
|
|
|
|
## Workflow
|
|
|
|
**a. Make the template from your recording**
|
|
```
|
|
pip install numpy scipy
|
|
python tools/make_template.py --buzz buzzer.wav --background quiet_room.wav tv_talking.wav
|
|
# optional: --segments 1.2-2.0,5.5-6.4 (otherwise auto-detected: frames > 4x noise floor)
|
|
```
|
|
It prints the selected time ranges, buzzer score distribution, a text bar chart, background score stats, a recommended threshold,
|
|
and an estimated false-trigger rate per hour, then writes `main/template.h` and `main/test_vector.h`. It refuses with < 20 buzzer frames and warns loudly if background and buzzer scores overlap.
|
|
|
|
**b. Build, flash, verify**
|
|
```
|
|
buzz> selftest # must print PASS (C == Python, tolerance 1e-3)
|
|
buzz> selfcheck # press the real buzzer for 10 s; verdict good / weak / poor
|
|
```
|
|
**c. If the match is weak/poor** (your recording used a different mic):
|
|
```
|
|
buzz> adapt # 60 s: blends live buzzer 50/50 into the PC template (adapt 0.7 = weight of NEW sound)
|
|
or buzz> learn # 60 s: replace the template completely (also: short-press BOOT)
|
|
buzz> background # 60 s of normal ambient sound, do NOT press the buzzer
|
|
buzz> test # 10 s, press the buzzer
|
|
buzz> autothresh # threshold = midpoint of background p99 and buzzer p5, saved
|
|
```
|
|
**d. Live test**
|
|
```
|
|
buzz> monitor on # timestamp, RMS, score, MATCH flag, state
|
|
buzz> alert # test event POST (delay_ms=42)
|
|
buzz> status # template source, timing, Wi-Fi, event POST counters
|
|
```
|
|
`learn reset` clears the NVS template and falls back to `template.h`. During any calibration session the detector is
|
|
paused (pressing the buzzer will not send alerts).
|
|
|
|
### Console reference
|
|
`selftest` `selfcheck` `learn [reset]` `adapt [w]` `background` `test` `autothresh` `monitor on|off`
|
|
`set <param> <value>` `get [param]` `status` `fingerprint` `alert` `reboot`.
|
|
Parameters: `threshold min_rms win_n win_m cooldown_ms noise_mult adapt_weight monitor_ms`.
|
|
|
|
## Design notes
|
|
|
|
* **Template priority at boot:** NVS-learned > compiled-in `template.h` > "needs template" (instructions printed, LED red). `status` shows which.
|
|
* **Fingerprint:** DC removal per frame -> RMS -> samples/32768 x periodic Hann -> 1024-pt FFT (esp-dsp) -> *mean* power per band over
|
|
32 log-spaced bands -> ln(P+1e-10) -> subtract mean -> (FP_DETREND) subtract least-squares line over band index -> L2 normalise.
|
|
Score = dot product (cosine).
|
|
* **Parity:** `feature_params.h` is parsed by Python; band edges use double math and `floor(x+0.5)` on both sides. I verified on a PC
|
|
(real `fingerprint.c` + stub FFT vs numpy) a max fingerprint error of ~1e-6, also with other NUM_BANDS / FP_DETREND values.
|
|
* **Concurrency:** audio task (core 1, prio 10) -> pool of 8 frame slots + two index queues (no allocation; drops and logs if processing lags)
|
|
-> processing task (core 1, prio 8). Network and console tasks are separate; the notifier has its own queue. Mode and session data
|
|
are guarded by a mutex; template and config by mutexes. Audio and processing tasks are on the task watchdog (5 s, panic -> reboot).
|
|
* **Event sender:** exponential Wi-Fi reconnect (1 s .. 60 s); at most one HTTP POST per queued buzz event, no request retries.
|
|
* Simplifications chosen on purpose: one `TEST_CAL` mode serves `test` and `selfcheck`; calibration results (background/buzzer stats) live in RAM only
|
|
(re-run after reboot before `autothresh`); `TEMPLATE_SUGGESTED_THRESHOLD` is shown in `status`, not applied automatically.
|
|
|
|
## Assumptions
|
|
|
|
* INMP441-style mic: 24-bit in a 32-bit Philips slot, mono. 16 kHz, hop 32 ms.
|
|
* `min_rms` is in int16 units *after* the shift/gain, so it depends on `BUZZ_AUDIO_SHIFT`. Default 200 is a guess; check `monitor on` while the buzzer sounds vs. silence and adjust.
|
|
* `learn`/`adapt` gate = max(median RMS of the first second x `noise_mult`, `min_rms`) so stay quiet in the first second. `test`/`selfcheck` count frames with RMS >= `min_rms`.
|
|
* Verdict: **good** if the 5th-percentile score >= threshold, **weak** if only the median passes, **poor** otherwise.
|
|
* GPIO3 is an ESP32-S3 strapping pin (JTAG source); it works as an I2S clock after boot but avoid a heavy pull-up/down on the mic line.
|
|
|
|
## Tuning tips
|
|
|
|
* **False alarms:** raise `threshold` (run `background` + `autothresh`), raise `min_rms`, or raise `win_m` (e.g. 12/15). Door slams and speech are loud
|
|
but rarely hold a buzzer-shaped spectrum for 0.3+ s.
|
|
* **Missed buzzes:** `selfcheck` first. Weak/poor -> `adapt`/`learn`. Then lower `threshold` slightly, lower `win_m` (8/15), lower `min_rms`
|
|
(the buzzer may be quieter at the mic than you think; use `monitor on`).
|
|
* **Mic mismatch:** the PC recording mic and the INMP441 have different frequency responses. Detrending cancels a tilt but not bumps; `adapt` fixes
|
|
residual differences without throwing away a good template.
|
|
* **FP_DETREND:** keep on by default. Turn it **off** (`#define FP_DETREND 0`) if the buzzer's own spectral tilt is its most distinctive feature
|
|
(e.g. a very low, dull buzz vs. bright background noise) and `background`/`test` overlap. Then re-run `make_template.py` and rebuild: it changes the fingerprint, so an old template/NVS template is invalid until relearned (`learn reset` + `learn`).
|
|
Changing any `feature_params.h` value invalidates stored templates the same way; NVS templates with a different band count are ignored automatically.
|
|
* Typical processing time is far below the 32 ms budget; `status` prints the measured average and maximum.
|