Files

9.5 KiB

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.