With clean audio flowing from Part 1, the obvious next question isn't "which model should I train" — it's "do I need one at all." A loudness threshold is the cheapest possible detector, and cheap-first is the right default when nothing has told you otherwise yet. This post is what happened when I actually tried it: how far a threshold gets you, where it breaks, and a decision that looked closed in early August and reopened itself a week later, on its own terms.
Stage 1: a threshold, a duration gate, and a background that moves
The detector is deliberately simple. A rolling estimate of the ambient noise floor (a slow percentile-follower over ~60 seconds of frame energy) plays the role of "background." A frame counts as event-worthy when it clears that background by a fixed margin, sustained for long enough to rule out a door slam:
frame_rms > background + MARGIN_DB sustained for >= MIN_DURATION_S
Two things make this adaptive rather than a single hard-coded number: daytime ambient at a street-facing window sits far above night ambient, and window-open sound differs from window-closed. A fixed threshold either floods with false positives in the loud regime or goes deaf in the quiet one. Tracking background relative to itself sidesteps both — the same 7 dB margin works at 2am and at rush hour, because it's 7 dB above that hour's floor, not an absolute level.
Every parameter here started as a guess and got corrected against an hour of real recorded levels from the actual window, replayed offline through the exact same detector code that runs on the device — not a re-implementation that could quietly drift from it.
Two bugs that only showed up in real audio
The first hour of logged data immediately broke the detector's own logic, in a way no amount of staring at the code would have caught.
The loudest, cleanest candidate in the whole hour — 11.5 seconds sustained, 27 dB above background — got logged as a 1.8-second blip. What happened: a faint precursor crossed the threshold 2.9 seconds before the real excursion arrived, fired the detector, and started a 90-second "don't double-count this vehicle" refractory window. The real siren showed up inside that window and was locked out entirely.
This isn't a rare edge case — it's exactly the shape a real approaching siren has: faint and masked, then loud and sustained. The bug wasn't corrupting random events, it was selectively corrupting the events most likely to be genuine sirens, and it was corrupting them in the two fields — duration and peak level — that any later filtering would be done on.
The fix was two changes: bridge short gaps between above-threshold runs into one excursion before applying the duration gate, and measure the refractory window from the end of an event rather than its start. Same hour, same audio: 5 events became 10, and the strongest one was finally described correctly.
Then a second, subtler bug turned up inside the fix itself. A host-side Python replica of the firmware disagreed with the actual C code on exactly one event — a single frame sitting 0.01 dB from the threshold, rounded to opposite sides by float32 versus float64. Chasing that knife-edge case exposed something structural in both implementations: bridging could merge two isolated one-frame spikes 1.5 seconds apart into a single "excursion" whose total span cleared the duration gate, even though only 0.2 seconds of it contained real energy. Two door slams, dressed up as one siren.
The transferable bit
The duration gate had been measuring elapsed wall-clock span. The fix was to measure energy instead — count frames actually above threshold, not the time between the first and last one. A gate that measures the wrong quantity can look correct for weeks and only fail on the specific input shape that exposes it.
Loudness alone can't tell you what a siren is
Three days of unattended collection gave the first real corpus: 199 clips, 41 of them sirens. Recall was essentially perfect — the detector rarely missed a real siren. Precision was 21%. Four in five uploaded clips were not sirens.
The instinct here is to add an absolute loudness floor on top of the relative one — clip counts spiked overnight, which looked like an obvious artefact to filter out. It would have cut the corpus from 199 clips to 80 and produced a much more plausible-looking daily curve.
It would also have thrown away real sirens. Rendering the nine loudest night clips as spectrograms — before touching any code — showed three unmistakable wails among them: a siren at 2am on an empty road is quiet in absolute terms and still a siren. Worse, the nine loudest clips in the entire corpus contained no sirens at all — vehicle passes and impulsive bangs. Loudness and "sirenness" turned out to be close to uncorrelated. No threshold on level, relative or absolute, was ever going to separate them, because it was the wrong feature entirely.
What actually separates a siren from everything else, consistently, across the labelled corpus:
- a narrow fundamental that sweeps, roughly 700 → 1700 Hz
- harmonics at 2f₀ and 3f₀ moving in lockstep with it, often brighter than the fundamental itself
- the sweep repeats on a 1.5–5 second cycle
- it persists for many seconds, not one
Engine noise is the closest false-positive: also a harmonic stack, but the fundamental sits lower (150–350 Hz) and drifts rather than sweeping. Rain and wind are broadband, with no harmonic structure at all — trivially separable once you're looking for the right shape.
That gave a second, cheap stage: a harmonic-sum score over candidate fundamentals, checked for span, sweep rate, and periodicity — deliberately not a neural network, just a few thousand multiply-accumulates per frame, well within an ESP32-S3's budget, and fully inspectable while the labelled set was still tiny. Checked against a set of clips by eye: 12/12 top scorers were textbook wails, 0/12 bottom scorers were sirens, and the genuinely ambiguous cases clustered exactly where you'd expect — in the middle.
This isn't a claim to take on faith — it's visible directly on a spectrogram, from two real labelled clips out of the actual corpus:
siren, 2026-08-04 07:19 — the repeating 700–1700 Hz sweep is the whole detection signal. Right: labelled other (traffic), 2026-08-04 05:16 — no sweep, no harmonic stack, just quiet broadband noise.The decision that closed the question, and the check it demanded of itself
By early August this had matured into a versioned scoring algorithm — DSP v3 — combining the loudness gate with the harmonic-sweep score into a single detector. Scored against 121 labels collected up to that point, it hit 97.1% precision at 91.7% recall, clearing a pre-registered bar of ≥90% precision at ≥90% recall on non-distant clips. The rule had been fixed in advance: clear that bar, and DSP ships — no ML needed. On the numbers in hand, it cleared it comfortably. Scope closed.
That same entry named the one thing it still wanted before treating the result as final: "another ~50 labels, ideally from a day not yet touched, scored by v3 without further changes." A genuinely held-out check. It never ran — a tool that joins scores against labels was silently reading only the first three days of a growing scores file, so every label collected after that window was dropped from every subsequent evaluation without anyone noticing.
Backfilling the missing scores and re-running the same tool eight days later reversed the decision:
| Set | Days | Clips | Precision | Recall |
|---|---|---|---|---|
| In-sample (v3 tuned here) | Aug 1–3 | 122 | 94.6% | 89.7% |
| Held-out (never touched) | Aug 4–6 | 55 | 81.2% | 81.2% |
The obvious explanation — that the early days were louder, cleaner bring-up audio and the later days were harder — doesn't hold up under measurement. Held-out sirens were marginally louder on average (median margin 21.2 dB vs 20.2 dB in-sample), and the non-siren distributions matched to within 0.1 dB. The drop wasn't a harder test set. It was generalisation failure: v3 had overfit to the specific spectrogram shapes it was tuned against, exactly the risk its own write-up had flagged as unresolved and couldn't rule out at the time.
Why this gets a whole post instead of a footnote
The rule that closed ML scope was pre-registered, measured, and genuinely met on the data available at the time. It was still wrong, because the specific held-out check it promised itself was never actually run — not because anyone skipped it deliberately, but because a silent join bug made "held-out" and "in-sample" look identical to the tool computing the verdict. A pre-registered rule only means something if you check whether you actually followed it.
What this settles, and what it reopens
This isn't the end of the DSP path — v3 stays in the comparison as the baseline every learned approach has to beat, and it's a genuinely strong one: cheap, deployable from day one, and it needed zero labelled data to get started. What the reversal actually settles is narrower and more useful: the question "does this problem need machine learning at all" doesn't get to be answered from an in-sample number, however good that number looks. It reopens exactly the door the original 90/90 decision had closed — and that's where Part 4 and Part 5 pick up, testing learned models against this same DSP baseline, fairly, on data none of them have seen.
Before any model comparison could mean anything, though, the labels themselves needed a design that didn't quietly bias the answer. That's Part 3.