Enhance buzzer detection logic to group intermittent matches with a 500 ms quiet tail, updating relevant configurations and documentation.

This commit is contained in:
2026-10-19 15:28:21 -05:00
parent 7862a09858
commit 63d9995f59
10 changed files with 131 additions and 50 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ idf.py menuconfig # "Door Buzzer Detector"
* **ntfy**: server (default `https://ntfy.sh`), **topic** (pick something unguessable: anyone who knows it can read it), * **ntfy**: server (default `https://ntfy.sh`), **topic** (pick something unguessable: anyone who knows it can read it),
optional heartbeat topic and interval (hours, 0 = off). Subscribe to the topic in the ntfy phone app. optional heartbeat topic and interval (hours, 0 = off). Subscribe to the topic in the ntfy phone app.
* **Hardware**: pins, I2S slot, `Right shift` (default 14 = 4x digital gain for the quiet INMP441). * **Hardware**: pins, I2S slot, `Right shift` (default 14 = 4x digital gain for the quiet INMP441).
* **Detector defaults**: threshold, loudness gate, N/M, cooldown (default 5 ms). The existing N-of-M sliding window smooths frame matches; because frames arrive every 32 ms, cooldown/re-arm timing is effectively frame-granular. All can also be changed live with `set` and persist in NVS. * **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 alert notification 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* * 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*. to *USB Serial/JTAG*.
+2 -2
View File
@@ -65,8 +65,8 @@ menu "Door Buzzer Detector"
default 10 default 10
range 1 32 range 1 32
config BUZZ_COOLDOWN_MS config BUZZ_COOLDOWN_MS
int "Cooldown after an alert (milliseconds)" int "Quiet tail to group buzz frames into one event (milliseconds)"
default 5 default 500
range 0 86400000 range 0 86400000
config BUZZ_NOISE_MULT_X10 config BUZZ_NOISE_MULT_X10
int "Learn/adapt gate: noise floor multiplier x10" int "Learn/adapt gate: noise floor multiplier x10"
+13 -3
View File
@@ -13,7 +13,8 @@
static const char *TAG = "config"; static const char *TAG = "config";
#define NVS_NS "buzz" #define NVS_NS "buzz"
#define CFG_KEY "cfg" #define CFG_KEY "cfg"
#define CFG_MAGIC 0xB0220002u #define CFG_MAGIC 0xB0220003u
#define CFG_MAGIC_PRE_500MS 0xB0220002u
#define CFG_MAGIC_PRE_COOLDOWN_MS 0xB0220001u #define CFG_MAGIC_PRE_COOLDOWN_MS 0xB0220001u
typedef struct { uint32_t magic; app_config_t cfg; } cfg_blob_t; typedef struct { uint32_t magic; app_config_t cfg; } cfg_blob_t;
@@ -33,7 +34,7 @@ static const param_t s_params[] = {
P(min_rms, PT_FLOAT, 0.0, 32767.0, "loudness gate (int16 RMS)"), 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_n, PT_UINT, 1, 32, "sliding window length N (frames)"),
P(win_m, PT_UINT, 1, 32, "matches M needed in window (<= N)"), P(win_m, PT_UINT, 1, 32, "matches M needed in window (<= N)"),
P(cooldown_ms, PT_UINT, 0, 86400000, "cooldown after alert (ms)"), P(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(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(adapt_weight, PT_FLOAT, 0.0, 1.0, "weight of new template in adapt"),
P(monitor_ms, PT_UINT, 10, 60000, "min interval between monitor lines (ms)"), P(monitor_ms, PT_UINT, 10, 60000, "min interval between monitor lines (ms)"),
@@ -102,7 +103,16 @@ esp_err_t config_init(void)
} else if (blob.magic == CFG_MAGIC_PRE_COOLDOWN_MS) { } else if (blob.magic == CFG_MAGIC_PRE_COOLDOWN_MS) {
s_cfg = blob.cfg; s_cfg = blob.cfg;
s_cfg.cooldown_ms = CONFIG_BUZZ_COOLDOWN_MS; s_cfg.cooldown_ms = CONFIG_BUZZ_COOLDOWN_MS;
ESP_LOGI(TAG, "migrating saved config to millisecond cooldown"); ESP_LOGI(TAG, "migrating saved config to event-grouping cooldown");
err = save_locked();
if (err != ESP_OK) {
nvs_close(h);
return err;
}
} else if (blob.magic == CFG_MAGIC_PRE_500MS) {
s_cfg = blob.cfg;
s_cfg.cooldown_ms = CONFIG_BUZZ_COOLDOWN_MS;
ESP_LOGI(TAG, "updating saved cooldown for buzz-event grouping");
err = save_locked(); err = save_locked();
if (err != ESP_OK) { if (err != ESP_OK) {
nvs_close(h); nvs_close(h);
+1 -1
View File
@@ -10,7 +10,7 @@ typedef struct {
float min_rms; // loudness gate (int16 RMS); quieter frames never 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_n; // sliding window length (frames), 1..32
uint32_t win_m; // matches needed inside the window, 1..win_n uint32_t win_m; // matches needed inside the window, 1..win_n
uint32_t cooldown_ms; // quiet period after an alert uint32_t cooldown_ms; // quiet tail that groups matches into one buzz event
float noise_mult; // learn/adapt gate = noise floor * this float noise_mult; // learn/adapt gate = noise floor * this
float adapt_weight; // weight of the NEW template in `adapt` (0..1) float adapt_weight; // weight of the NEW template in `adapt` (0..1)
uint32_t monitor_ms; // min interval between `monitor` lines uint32_t monitor_ms; // min interval between `monitor` lines
+2 -1
View File
@@ -128,10 +128,11 @@ static int cmd_status(int argc, char **argv)
printf("template: %s, %lu frames%s\n", matcher_source_name(), (unsigned long)matcher_frame_count(), 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`"); matcher_has_template() ? "" : " <-- run tools/make_template.py + rebuild, or `learn`");
if (matcher_suggested_threshold(&sug)) printf(" template.h suggests threshold %.3f\n", sug); if (matcher_suggested_threshold(&sug)) printf(" template.h suggests threshold %.3f\n", sug);
printf("detector: threshold %.3f, min_rms %.0f, window %lu/%lu, cooldown %lu ms\n", cfg.threshold, cfg.min_rms, 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); (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("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("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, 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()); (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, printf("proc time: avg %lu us, max %lu us (budget %d us per frame)\n", (unsigned long)ps.proc_avg_us,
+49 -17
View File
@@ -1,39 +1,71 @@
#include <string.h> #include <string.h>
#include "detector.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_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; } 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) bool detector_is_match(float rms, float score, float min_rms, float threshold)
{ {
return rms >= min_rms && score >= threshold; return rms >= min_rms && score >= threshold;
} }
bool detector_update(detector_t *d, bool match, uint32_t now_ms, const detector_cfg_t *cfg) 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 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); uint32_t m = cfg->win_m < 1 ? 1 : (cfg->win_m > n ? n : cfg->win_m);
switch (d->state) { switch (d->state) {
case DET_IDLE: { case DET_IDLE:
d->hist = (d->hist << 1) | (match ? 1u : 0u); update_idle(d, match, now_ms, n, m);
uint32_t mask = (n == 32) ? 0xFFFFFFFFu : ((1u << n) - 1u); return false;
if ((uint32_t)__builtin_popcount(d->hist & mask) >= m) {
d->state = DET_TRIGGERED;
d->triggers++;
return true;
}
break;
}
case DET_TRIGGERED: case DET_TRIGGERED:
d->state = DET_COOLDOWN;
d->cooldown_start_ms = now_ms;
d->hist = 0;
break;
case DET_COOLDOWN: case DET_COOLDOWN:
if ((uint32_t)(now_ms - d->cooldown_start_ms) >= cfg->cooldown_ms) { 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->state = DET_IDLE;
d->hist = 0; d->hist = 0;
if (match) update_idle(d, true, now_ms, n, m);
return true;
} }
break; break;
} }
+10 -5
View File
@@ -14,7 +14,11 @@ typedef struct {
typedef struct { typedef struct {
det_state_t state; det_state_t state;
uint32_t hist; // bit 0 = newest frame, 1 = matched uint32_t hist; // bit 0 = newest frame, 1 = matched
uint32_t cooldown_start_ms; 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; uint32_t triggers;
} detector_t; } detector_t;
@@ -24,8 +28,9 @@ void detector_reset(detector_t *d); // back to IDLE with empty history
// Loudness gate AND score >= threshold. // Loudness gate AND score >= threshold.
bool detector_is_match(float rms, float score, float min_rms, float threshold); bool detector_is_match(float rms, float score, float min_rms, float threshold);
// Feed one frame. Returns true exactly once per trigger (IDLE -> TRIGGERED). // Feed one frame. Returns true when a buzz event ends and writes its duration.
// TRIGGERED lasts one frame, then COOLDOWN until cooldown_ms elapsed, then IDLE // Matches within cooldown_ms extend the same event; the final quiet tail is
// with a cleared history (the buzzer's tail cannot re-trigger). // excluded from its duration.
bool detector_update(detector_t *d, bool match, uint32_t now_ms, const detector_cfg_t *cfg); 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); const char *detector_state_name(det_state_t s);
+15 -6
View File
@@ -92,11 +92,12 @@ static void processing_task(void *arg)
const app_mode_t mode = calibration_get_mode(); const app_mode_t mode = calibration_get_mode();
const bool match = has_tpl && detector_is_match(rms, score, cfg.min_rms, cfg.threshold); const bool match = has_tpl && detector_is_match(rms, score, cfg.min_rms, cfg.threshold);
bool trig = false; bool buzz_ended = false;
uint32_t buzz_duration_ms = 0;
if (mode == MODE_NORMAL) { if (mode == MODE_NORMAL) {
if (has_tpl) { if (has_tpl) {
detector_cfg_t dc = { cfg.win_n, cfg.win_m, cfg.cooldown_ms }; detector_cfg_t dc = { cfg.win_n, cfg.win_m, cfg.cooldown_ms };
trig = detector_update(&det, match, now_ms, &dc); buzz_ended = detector_update(&det, match, now_ms, &dc, &buzz_duration_ms);
} else { } else {
detector_reset(&det); detector_reset(&det);
} }
@@ -104,9 +105,13 @@ static void processing_task(void *arg)
calibration_on_frame(fp, valid, rms, score); // detector paused: no alerts calibration_on_frame(fp, valid, rms, score); // detector paused: no alerts
detector_reset(&det); detector_reset(&det);
} }
if (trig) { if (buzz_ended) {
ESP_LOGW(TAG, "BUZZER DETECTED (rms %.0f, score %.3f)", rms, score); char message[96];
notifier_send(NOTIF_ALERT, "Buzzer detected"); 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(NOTIF_ALERT, message);
} }
portENTER_CRITICAL(&s_lock); portENTER_CRITICAL(&s_lock);
@@ -118,7 +123,11 @@ static void processing_task(void *arg)
s_stats.last_match = match; s_stats.last_match = match;
s_stats.state = det.state; s_stats.state = det.state;
s_stats.triggers = det.triggers; s_stats.triggers = det.triggers;
if (trig) s_stats.last_trigger_s = now_ms / 1000 ? now_ms / 1000 : 1; 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)); memcpy(s_last_fp, fp, sizeof(s_last_fp));
portEXIT_CRITICAL(&s_lock); portEXIT_CRITICAL(&s_lock);
+1
View File
@@ -14,6 +14,7 @@ typedef struct {
det_state_t state; det_state_t state;
uint32_t triggers; uint32_t triggers;
uint32_t last_trigger_s; // uptime seconds of the last trigger, 0 = never 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; } proc_stats_t;
esp_err_t processing_start(void); esp_err_t processing_start(void);
+37 -14
View File
@@ -24,21 +24,44 @@ int main(void)
CHECK(!features_compute(&c2, z, f2, &r2) && r2 == 0.0f, "silence -> invalid, rms 0"); 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"); for (int b = 0; b < NUM_BANDS; b++) CHECK(f2[b] == 0.0f, "silence fp zero");
// detector: 10-of-15 triggers once, cooldown 20 s, then re-arms // Detector 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_t d; detector_init(&d);
detector_cfg_t cfg = { 15, 10, 20000 }; detector_cfg_t cfg = { 1, 1, 500 };
uint32_t t = 0; int trig = 0, first = -1; uint32_t duration = 0;
for (int i = 0; i < 40; i++, t += 32) if (detector_update(&d, i >= 5 && i < 30, t, &cfg)) { trig++; if (first < 0) first = i; } CHECK(!detector_update(&d, true, 0, &cfg, &duration), "event remains open after first match");
CHECK(trig == 1 && first == 14, "single trigger at 10th match (got %d trig, first %d)", trig, first); CHECK(d.state == DET_TRIGGERED && d.triggers == 1, "event starts on match");
CHECK(d.state == DET_COOLDOWN, "cooldown after trigger"); CHECK(!detector_update(&d, false, 100, &cfg, &duration), "intermittent gap remains in event");
for (int i = 0; i < 100; i++, t += 32) CHECK(!detector_update(&d, true, t, &cfg), "no trigger in cooldown"); CHECK(!detector_update(&d, true, 400, &cfg, &duration), "match within quiet tail extends event");
t += 20000; detector_update(&d, false, t, &cfg); CHECK(!detector_update(&d, false, 600, &cfg, &duration), "event remains open before tail expires");
CHECK(d.state == DET_IDLE, "back to idle"); CHECK(detector_update(&d, false, 900, &cfg, &duration), "event closes at 500 ms after last match");
int n = 0; for (int i = 0; i < 12; i++, t += 32) n += detector_update(&d, true, t, &cfg); CHECK(duration == 432, "duration includes gap, excludes final quiet tail (got %lu ms)",
CHECK(n == 1, "re-arms and triggers again"); (unsigned long)duration);
// sparse matches never trigger CHECK(d.state == DET_IDLE, "returns to idle after event closes");
detector_reset(&d); n = 0; CHECK(!detector_update(&d, true, 932, &cfg, &duration), "next event starts after prior event closes");
for (int i = 0; i < 300; i++, t += 32) n += detector_update(&d, i % 3 == 0, t, &cfg); 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"); CHECK(n == 0, "1-in-3 matches must not trigger 10/15");
printf(fails ? "HOST TEST FAILED\n" : "HOST TEST PASSED\n"); printf(fails ? "HOST TEST FAILED\n" : "HOST TEST PASSED\n");
return fails != 0; return fails != 0;