Acoustic detection¶
The node classifies short windows of microphone audio as drone or not. The
arithmetic lives in fw/common/boomdetect, which builds both for the STM32 and
natively for a host; the board-specific wiring is fw/bom-stm32node/App/detect/.
The chain¶
mic (PDM) → pdm_pcm → 48 kHz PCM → ÷3 → 16 kHz
│
frames of 1024, hop 512 ──┤
│ RMS gate per frame
MFCC (13 coefficients per frame)
│
14 accepted frames → feature vector
(layout 1: [mean, std, dmean, cmax] × 13)
│
classifier → decision
│
decision ≥ threshold → DRONE
A window is 14 frames at a hop of 512, so about 448 ms of audio. Frames overlap by half, which matters more than it looks: the first frame after the input goes quiet still contains half a frame of the loud audio before it, and passes the gate.
dmean is the mean absolute frame-to-frame change, which is what carries
propeller modulation; cmax is max minus mean, a peakiness measure that does not
move with level. Feature 0 is the mean of MFCC coefficient 0, which tracks
absolute loudness — the MLP models skip it deliberately, to stay gain-invariant.
Where the code lives¶
fw/common/boomdetect/src/boomdetect.c |
decimation, FIFO, framing, gate, windowing, the per-frame descriptor |
fw/common/boomdetect/src/boomdetect_mfcc_f32.c |
MFCC, with one deviation from CMSIS-DSP (below) |
fw/common/boomdetect/src/frame_scalars.c |
eight spectral scalars per frame from the magnitude spectrum |
fw/common/boomdetect/src/extractor_*.c |
the feature layouts (below) and their registry |
fw/common/boomdetect/src/nn_infer.c |
float32 interpreter for the small CNNs |
fw/common/boomdetect/src/boomdetect_alarm.c |
K-of-N alarm with hysteresis over window verdicts |
fw/common/boomdetect/models/ |
one translation unit per model, each exporting a classifier_t |
fw/common/boomdetect/training/ |
the Python that trains, compares and exports the models (below) |
fw/bom-stm32node/App/detect/detect_service.c |
microphone, pacing, alarm, console output |
The split is the same one fw/common/boomlink has against App/link/, and for
the same reason: Core/ is CubeMX's and cross-compiles only for the Cortex-M33,
so anything living there can never be run on a host or compared against
anything.
Feeding samples and doing work are separate calls (boomdetect_push,
boomdetect_step) because the firmware must not run two MFCCs in one iteration
of its superloop — doing so overran the 21.33 ms budget and starved the USB
stack. step() does at most one frame, by contract rather than by comment.
Console commands¶
For a hands-on guide to switching models and tuning the threshold, squelch and alarm in the field, see field-manual.md (Czech).
| command | what it does |
|---|---|
detect <sec> [squelch_milli] [thr_milli] [dbg] [rule] |
run for <sec> seconds (1..86400; 0 runs until any byte arrives on the console) under the alarm rule rule (2of4 by default, or mean<n>), stream LVL/DET lines and ALM on alarm transitions, end with DETEND |
model [name] |
list the classifiers in this image, or select one |
micslot [a\|b] |
which microphone of the PDM pair is decoded |
micdiag |
probe the PDM data pins |
detselftest |
run a fixed synthetic signal through the chain, print raw float bits |
Omitting thr_milli uses the selected model's own default. That means the same
detect 20 0 means different things depending on what model last selected —
deliberately, because a linear SVM's decisions live around ±3 while an MLP's are
unbounded logits, so one global default would make one of the families useless.
model prints the value it will use.
Neither model nor micslot is persisted; a reset returns to the defaults.
The alarm¶
A window is 448 ms and one logit; whether a drone is present is a property of
seconds. Above the classifier sits a K-of-N rule with hysteresis
(boomdetect_alarm.h): the alarm turns ON when at least 2 of the last 4
classified windows were called DRONE, and OFF when fewer than 1 were. The
board prints ALM t=<s>.<ms> ON|OFF hits=<k>/<n> only on transitions, and
DETEND counts the OFF→ON transitions in alarms= and stamps the first DRONE
window and the first alarm (first_drone=, first_alarm=, seconds into the run,
- if never) so a field log needs nothing but the trailer. Squelched frames yield no
window and do not move the history. The constants are in detect_service.h and
pinned by the host tool's tests; the training package evaluates clip-level
verdicts with the same rule, so "alarm" means one thing on both sides.
Feature layouts¶
Every accepted frame leaves a 41-float descriptor behind: the 13 MFCC coefficients, the 20 log-mel energies the DCT was computed from, and eight spectral scalars from the magnitude spectrum the MFCC destroys its input into (power above 4 kHz, power 1–4 kHz, centroid, flatness, 85 % roll-off, crest, the strength of the best harmonic comb between 60 and 400 Hz and its fundamental).
| id | extractor | width | what |
|---|---|---|---|
| 1 | stats |
52 | [mean, std, dmean, cmax] × 13 — what mlp_v6 and svm_v3 read |
| 2 | stats_spectral |
69 | layout 1, then mean and std of the eight scalars, then log-mel flux |
| 3 | logmel |
280 | the 14 × 20 log-mel patch minus its mean, frame-major — a CNN's input |
| 4 | stats_spectral_mod |
79 | layout 2, then ten numbers from the modulation spectrum of the 1–4 kHz envelope over the last two seconds (src/extractor_mod.c) |
The scalars are the things an MFCC envelope smooths away and that separate a
rotor from a hum: a closed mouth has almost nothing above 4 kHz, a single stable
harmonic series, and a flat-ish spectrum only where it has energy at all. The
arithmetic is specified by training/boomdetect_train/features.py; the C is
held to it by extractor_test on the detselftest signal, with stated
tolerances and the two discrete values (roll-off bin, comb fundamental) compared
only on windows the fixture marks as numerically stable.
Layout 4 adds the one thing the frame descriptors cannot see: the rhythm of
the sound. A rotor's broadband noise is amplitude-modulated at the blade-pass
rate — about 170–185 Hz for a hovering Phantom 4, with a harmonic at twice it
— and that modulation survives at 60–90 m overhead, where the spectrum itself
has sunk to the background. The pipeline therefore keeps a second signal
beside the frames: the 1–4 kHz band, rectified and low-passed (three biquads,
src/envelope.c, coefficients from src/envelope_coefs.h), sampled at 1 kHz
into a ring of 1984 values (boomdetect_t::env_ring) that follows the audio
continuously, gated frames included. When a window closes, the extractor takes
a Welch spectrum of the ring (12 Hann segments of 512 every 128), measures how
far each bin stands above the mean of the 41 bins around it, and reports the
strongest line in 50–400 Hz, its frequency, the harmonic, per-band maxima,
the share of bins that are lines, the envelope's depth and the share of its
power in 100–250 Hz. Until the ring has two seconds in it — after init, and
after a gap — a layout-4 window closes warming, with no decision; the
training package has NaN there and never fitted a model on such a window.
The fixture for it is a 5 s LCG noise amplitude-modulated at 192 Hz in integer
arithmetic (tests/vectors/extractor_mod_expected.h), so both sides see the
same samples; extractor_test holds the envelope and the ten features to it.
Training package¶
fw/common/boomdetect/training is the other half of the detector: the Python
that trains, judges and exports the models the C ships. It reads the firmware's
own mfcc_tables.h, so it cannot disagree with the board about a filter edge,
and its tests hold it to the detselftest fixture stage by stage. The data
lives outside the repository (~/Documents/boomdetect-data, or
BOOMDETECT_DATA); training/README.md has the commands, the suites and how a
model is judged.
Swapping the classifier¶
Each model is its own translation unit exporting one classifier_t, listed in
src/classifier_registry.c. This works because the weight arrays in the
generated headers are static const, so identical symbol names have internal
linkage and several models coexist in one image.
An entry declares which slice of the feature vector it reads
(feature_offset, n_features), its own operating point, and a layout_id.
That last one guards what a feature count cannot: reorder the aggregate and the
count still matches while every weight lands on the wrong input. It is only as
strong as the discipline of bumping it, so treat it as a tripwire — the real
check is the parity fixture.
Adding a model is a new file under models/, one line in the registry, and one
declaration in models/models.h so the compiler checks the pair. bdtrain
export generates the file and its weight header; the registry lines stay a
hand edit, and model_parity_test fails for any exported model the registry
does not list.
models/models.h lists what the image carries; each generated model_*.c
records its run, layout and threshold, and the field manual
(field-manual.md) has the numbers. The
default is gbt_m1 on layout 4, with mlp_m1 beside it on the same layout and
mlp_f2 on layout 2 as the previous default; mlp_v6 and svm_v3 are the
public-data models the selftest is anchored to. The three field-trained models
ship at the threshold that kept their false-alarm windows under 5 per hour on
the validation negatives, and model <name> switches between them.
A model needing a different feature representation — raw frames for a CNN, say
— adds a feature extractor rather than an edit to the pipeline.
boomdetect_extractor_t (include/extractor.h) declares a name, the
layout_id it produces and how wide it is, and boomdetect_init() checks the
model's layout_id against the configured extractor's rather than against a
constant. So a new representation is one file under src/, a new id, a line in
the extractor registry, and a model that declares the same id; nothing in
boomdetect.c changes.
The image carries the four extractors of the table above, stats being the
default. A consumer must hand boomdetect_init() the extractor that produces the
model's layout — boomdetect_extractor_for_layout(model->layout_id), which is
what the host tests and App/detect/detect_service.c do; a config with the field
left NULL gets stats, and a model of any other layout is then refused at init.
registry_test drives a fake extractor with a foreign layout end to end, and
checks that a mismatched pair in either direction is refused — which is what a
feature count cannot catch, since reordering the statistics keeps the width.
What is measured, and what is not¶
This section matters more than the rest of the page. The detector has been carrying assumptions that looked like facts.
| status | |
|---|---|
| Which microphone of the PDM pair is populated | measured, one board. With the wrong slot the chain decodes the empty half and reports a flat zero — indistinguishable from a perfectly quiet detector. |
| Threshold 15.0 | measured against ambient office noise, three minutes, no drone. The offline +7.25 fired on 23 of 396 windows, peaking at 12.05. |
| Session-to-session variance | measured, and larger than the change it justified: one session peaked at 12.05, another at 2.77, same board and firmware. |
| C refactor did not move any number | measured, detselftest bit-identical across the whole move — captured on the pre-move firmware and re-run on the post-move build on the same board; both runs are recorded in the fixture's header |
-O2 does not move any number |
measured, the Shipped preset builds without sanitizers at -O2 and CI diffs its fixture output against the Debug build's |
| Host and target agree bit for bit | measured, and they do NOT — same C, same input, but MFCC coefficients differ by up to 1.8e-4 relative and decisions by 1.1e-6 (below) |
| Train/deploy skew | expressible but still not measured: the window policy is now configuration rather than compile-time constants, so both sides can be run; nobody has run them |
| C matches the Python it is trained with | measured on the host: the Python front end reproduces the detselftest MFCC and features to 1e-4 relative and the mlp_v6 decisions to 1e-5 (training/tests/test_parity_selftest.py); the C extractors reproduce the Python layouts 2–4 (extractor_test); every registered model's C forward pass reproduces its Python one (model_parity_test) |
| New models generalise to unseen recordings | measured: trained on the public sets alone, every family scores ~0.99 window AUC on held-out clips of those sets and 0.7–0.94 on Halmstad; the node's own field recordings in training are what made the models work outdoors |
| Detection of an actual drone on this hardware | measured outdoors, a DJI Phantom 4 hovering at 20–100 m (the field manual has the numbers) |
Train/deploy skew¶
The training pipeline and the firmware do not window audio the same way:
| training | firmware | |
|---|---|---|
| windows | sliding, hop 7 frames | disjoint runs of 14 |
| continuity | 14 contiguous frames | the gate resets accumulation, so a window can span silence |
| gate | median RMS of the window ≥ 0.002 | each frame ≥ 0.003 by default, 1.5 times stricter |
| 48 → 16 kHz | no counterpart | decimate by 3 |
None of that is wrong on its face; it is simply undescribed by whatever the model was fitted to.
Three of those four rows are now settable rather than compiled in.
boomdetect_config_t carries accum_frames, hop and gate, and
BOOMDETECT_GATE_WINDOW_MEDIAN implements the training pipeline's policy: no
frame is rejected, and the window is kept or dropped as a whole on the median of
its frames' RMS. Leaving all three at 0 gives the firmware's behaviour, which is
what every checked-in fixture still reproduces.
So the gap can now be run from both ends on a host.
Host and target are not bit-identical¶
Running the same fixture through boomdetect_selftest_tool on x86-64 and
through detselftest on the board gives the same input checksum, the same 42
frames and 3 windows, and different bits:
| worst MFCC coefficient | 1.8e-4 relative (2694 ULP), 1.6e-7 absolute |
| worst decision | 1.1e-6 relative; board −11.476006 / −9.829288 / −10.548382 against host −11.476009 / −9.829277 / −10.548393 |
Identical C, different instruction selection: the Cortex-M33 build takes
CMSIS-DSP's architecture-specific paths at -O2, the host the generic ones at
-O1 under sanitizers. Neither compiler owes anyone a particular summation
order, and float32 addition is not associative.
Two consequences worth stating plainly. Bit-exactness is only ever a same-binary, same-target claim, which is why there are two fixtures and each is compared only against its own side. And the parity suite this package exists for cannot be a bit comparison either; it has to carry a tolerance, and 1e-6 on the decision is the scale this measurement suggests.
The CMSIS-DSP deviation¶
boomdetect_mfcc_f32() conditions the FFT input by RMS where upstream uses the
absolute maximum, with a different quiet-frame guard. The scaling is undone
afterwards so it cancels in exact arithmetic, but it changes float32 rounding and
which frames are left untouched.
It arrived as an unrecorded hand-edit to a vendored copy of CMSIS-DSP: 466 of the 467 files that copy was committed with were byte-identical to v1.15.0, and this one was not. (The changeset's count of 470 is the same tree at the point it was deleted, after a LICENSE, a README and a patch file were added to it.) The deployed model was selected against the values it produces, so reverting to stock upstream would silently move every coefficient. It is now our own source file with the licence attribution attached, and upstream is fetched untouched.
Checking a change did not move the numbers¶
The microphone never repeats an input, so two detect runs can never be
compared. The generator in fw/common/boomdetect/src/boomdetect_selftest.c
drives the chain from an integer LCG instead — the same input on every platform,
no flash cost — and prints every stage as raw IEEE-754 bit patterns rather than
decimals, so "unchanged" means unchanged.
Two consumers, two fixtures, each compared only against its own side (see the host/target note above for why one fixture would not do):
# the host, no hardware; part of `task test`
cd fw/common/boomdetect && ctest --preset Debug -R selftest_host
# a connected board, against its own recorded capture
cd fw/common/boomdetect && task setup && task check-board
A mismatch means one of: the RMS deviation above was lost, ARM_MATH_LOOPUNROLL
was switched on (upstream defaults it on, this build does not), -O2 did not
survive a build change, or the change moved the arithmetic. All four are
findings, and the middle two no longer need a board to notice: LOOPUNROLL
fails the configure, and the Shipped preset builds at -O2 without sanitizers
so CI can diff its output against the Debug build's.
Host tests¶
ASan and UBSan are on by default in the preset. The suite covers the MFCC front end, the registry, the pipeline's edge cases, every model's trained weights, the fixture, the extractors against the Python specification, the alarm, the CNN interpreter, and — through a stub registry linked in place of the real one — that the model table really is replaceable. The harness itself is checked able to fail, because a test framework that silently returns 0 is the failure mode this repository has already hit once.
On Windows the host suite runs under MinGW-w64 (no sanitizers there; the
fixture is compared within 1e-5 because that compiler lands a few ULP from
the Linux capture — see the fixture header), and the Python side runs with
pytest in training/.