diff --git a/README.md b/README.md index 6a9f073..1270311 100644 --- a/README.md +++ b/README.md @@ -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), 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). -* **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* to *USB Serial/JTAG*. diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 313f484..06d250a 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -65,8 +65,8 @@ menu "Door Buzzer Detector" default 10 range 1 32 config BUZZ_COOLDOWN_MS - int "Cooldown after an alert (milliseconds)" - default 5 + 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" diff --git a/main/config_store.c b/main/config_store.c index ce85350..962e838 100644 --- a/main/config_store.c +++ b/main/config_store.c @@ -13,7 +13,8 @@ static const char *TAG = "config"; #define NVS_NS "buzz" #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 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(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, "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(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)"), @@ -102,7 +103,16 @@ esp_err_t config_init(void) } else if (blob.magic == CFG_MAGIC_PRE_COOLDOWN_MS) { s_cfg = blob.cfg; 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(); if (err != ESP_OK) { nvs_close(h); diff --git a/main/config_store.h b/main/config_store.h index 38d09b6..5eab81e 100644 --- a/main/config_store.h +++ b/main/config_store.h @@ -10,7 +10,7 @@ typedef struct { 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 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 adapt_weight; // weight of the NEW template in `adapt` (0..1) uint32_t monitor_ms; // min interval between `monitor` lines diff --git a/main/console.c b/main/console.c index 71c182d..3462911 100644 --- a/main/console.c +++ b/main/console.c @@ -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(), 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, 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); 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, diff --git a/main/detector.c b/main/detector.c index 79f7de1..8419424 100644 --- a/main/detector.c +++ b/main/detector.c @@ -1,39 +1,71 @@ #include #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; } +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; } -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 m = cfg->win_m < 1 ? 1 : (cfg->win_m > n ? n : cfg->win_m); switch (d->state) { - case DET_IDLE: { - 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) { - d->state = DET_TRIGGERED; - d->triggers++; - return true; - } - break; - } + case DET_IDLE: + update_idle(d, match, now_ms, n, m); + return false; case DET_TRIGGERED: - d->state = DET_COOLDOWN; - d->cooldown_start_ms = now_ms; - d->hist = 0; - break; 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->hist = 0; + if (match) update_idle(d, true, now_ms, n, m); + return true; } break; } diff --git a/main/detector.h b/main/detector.h index d1c962e..43aa30f 100644 --- a/main/detector.h +++ b/main/detector.h @@ -14,7 +14,11 @@ typedef struct { typedef struct { det_state_t state; 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; } detector_t; @@ -24,8 +28,9 @@ 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 exactly once per trigger (IDLE -> TRIGGERED). -// TRIGGERED lasts one frame, then COOLDOWN until cooldown_ms elapsed, then IDLE -// with a cleared history (the buzzer's tail cannot re-trigger). -bool detector_update(detector_t *d, bool match, uint32_t now_ms, const detector_cfg_t *cfg); +// 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); diff --git a/main/processing.c b/main/processing.c index 26c3930..02faf76 100644 --- a/main/processing.c +++ b/main/processing.c @@ -92,11 +92,12 @@ static void processing_task(void *arg) const app_mode_t mode = calibration_get_mode(); 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 (has_tpl) { 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 { detector_reset(&det); } @@ -104,9 +105,13 @@ static void processing_task(void *arg) calibration_on_frame(fp, valid, rms, score); // detector paused: no alerts detector_reset(&det); } - if (trig) { - ESP_LOGW(TAG, "BUZZER DETECTED (rms %.0f, score %.3f)", rms, score); - notifier_send(NOTIF_ALERT, "Buzzer detected"); + 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(NOTIF_ALERT, message); } portENTER_CRITICAL(&s_lock); @@ -118,7 +123,11 @@ static void processing_task(void *arg) s_stats.last_match = match; s_stats.state = det.state; 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)); portEXIT_CRITICAL(&s_lock); diff --git a/main/processing.h b/main/processing.h index d545be2..cbb0136 100644 --- a/main/processing.h +++ b/main/processing.h @@ -14,6 +14,7 @@ typedef struct { 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); diff --git a/tools/host_test/host_test.c b/tools/host_test/host_test.c index ba03837..ac7c6c7 100644 --- a/tools/host_test/host_test.c +++ b/tools/host_test/host_test.c @@ -24,21 +24,44 @@ int main(void) 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: 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_cfg_t cfg = { 15, 10, 20000 }; - uint32_t t = 0; int trig = 0, first = -1; - 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(trig == 1 && first == 14, "single trigger at 10th match (got %d trig, first %d)", trig, first); - CHECK(d.state == DET_COOLDOWN, "cooldown after trigger"); - for (int i = 0; i < 100; i++, t += 32) CHECK(!detector_update(&d, true, t, &cfg), "no trigger in cooldown"); - t += 20000; detector_update(&d, false, t, &cfg); - CHECK(d.state == DET_IDLE, "back to idle"); - int n = 0; for (int i = 0; i < 12; i++, t += 32) n += detector_update(&d, true, t, &cfg); - CHECK(n == 1, "re-arms and triggers again"); - // sparse matches never trigger - detector_reset(&d); n = 0; - for (int i = 0; i < 300; i++, t += 32) n += detector_update(&d, i % 3 == 0, t, &cfg); + 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;