vibe code the initial impl

This commit is contained in:
2026-10-13 19:47:46 -05:00
parent 7da1c2119d
commit 7862a09858
39 changed files with 2867 additions and 157 deletions
+9
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
// 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: 10-of-15 triggers once, cooldown 20 s, then re-arms
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);
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;
}
+26
View File
@@ -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; }
+6
View File
@@ -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"; }
+5
View File
@@ -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__)
+385
View File
@@ -0,0 +1,385 @@
#!/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 20]
[--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_cooldown, N, M):
"""Same N-of-M + cooldown logic as detector.c, on a boolean match array."""
hist, state, cd, trig = 0, "IDLE", 0, 0
mask = (1 << N) - 1
for m in match:
if state == "IDLE":
hist = ((hist << 1) | int(m)) & 0xFFFFFFFF
if bin(hist & mask).count("1") >= M:
trig += 1; state = "TRIGGERED"
elif state == "TRIGGERED":
state, cd, hist = "COOLDOWN", nframes_cooldown, 0
else:
cd -= 1
if cd <= 0:
state, hist = "IDLE", 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=20.0)
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 s cooldown: "
"%d trigger(s) in %.1f min = %.2f per hour" % (
thr_use, a.window_m, a.window_n, a.cooldown, 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()