Police, fire and ambulance sirens pass my window often enough to be a recurring disturbance — sometimes waking me mid-night. I wanted to know how often, and when, so I built a device to count them: an ESP32-S3 microcontroller and a MEMS microphone, listening at the window, 24/7.
This is the first of six posts on that build. Before any detection logic, before any model, there's a more basic question: does the microphone actually capture a siren cleanly, or does it just look like it does? Getting that wrong is the kind of bug that doesn't announce itself — it just trains everything downstream on bad data.
The hardware, and why I didn't reach for a camera
Two ways to sense a passing emergency vehicle: listen for the siren, or watch for the vehicle. I chose audio first, for reasons that are mostly about honesty rather than convenience:
- Cost. An ESP32-S3 dev board plus an INMP441 I2S microphone is about £15–20. A camera capable of classifying vehicle liveries starts around £45 and climbs fast.
- The real limitation, stated up front. UK police, fire and ambulance sirens use essentially the same tone patterns. Audio can tell you an emergency vehicle with its siren on passed — not which service. It also misses silent, lights-only runs entirely.
- Privacy. A microphone pointed at a public street raises none of the questions a camera does.
I decided the honest limitation was acceptable: the thing that disturbs me is the siren, so an audio-only count answers the question I actually have. A camera stays a someday-extension, not a blocker.
Stage 0: proving the board is what I paid for
The board was ordered as an N16R8 variant — 16 MB flash, 8 MB octal PSRAM — because the listing also offered a cheaper N8R2 (2 MB PSRAM), and AliExpress substitutions are a known risk. Before writing a line of firmware, the only question that mattered was: which one actually arrived?
esptool --chip esp32s3 flash-id reported 16 MB flash and an embedded 8 MB PSRAM feature line. Flash size turned out to be the trustworthy reading — PSRAM reporting is version-dependent and can come back blank on a perfectly good chip, so an absent PSRAM line isn't yet grounds to dispute a purchase. Flash size lying, on the other hand, is.
Both readings came back clean. Board confirmed genuine before the return window closed — a five-minute check that would have been a much worse conversation with a seller two weeks later.
Wiring the mic, and the pins that are already spoken for
The ESP32-S3 reserves a surprising number of GPIOs before you've connected anything: 26–32 for SPI flash, 33–37 for the octal PSRAM, a handful more for strapping and USB-JTAG. The INMP441 needed three data lines (clock, word-select, data-in) placed well clear of all of them — GPIO 4, 5 and 6, wired on short jumper leads with the microphone's L/R pin tied to ground (left channel, and it must not be left floating).
The datasheet also sets a clock floor that isn't obvious until you go looking for it: the mic's bit clock has to run at least 0.5 MHz or the part won't produce clean data. At 16 kHz sample rate with 32-bit slots, the clock lands at 1.024 MHz — comfortably clear. The tempting memory-saving move — dropping to 16-bit slots to save buffer space later — halves that to 0.512 MHz, right on the edge of spec. I kept 32-bit slots. The memory saving wasn't worth operating at the limit of what the datasheet promises.
First capture, and the bug that would have poisoned everything after it
With the board verified and the mic wired, the actual test is simple to describe and easy to get subtly wrong: record something, listen to it, and check whether it's the sound that was actually in the room.
Amplitude looked healthy immediately — quiet baseline, a clear 37–40× jump on a finger snap, back to baseline. That's the check most bring-up guides stop at, and it would have told me nothing was wrong.
Listening to the recording told a different story: the audio was harsh and buzzy. Not silent, not garbled in an obviously broken way — just wrong, in a way that's easy to shrug off as "MEMS mics sound a bit rough anyway."
I ruled out the usual suspects one at a time, with evidence rather than guesses: bit misalignment (the skew was consistent with correct alignment, and deliberately flipping it made things strictly worse), dropped DMA blocks (no anomalous sample-to-sample jumps across 80,000 samples), alternating-sample corruption (no energy spike at Nyquist), mains hum (only 5–6% of energy at 50/60 Hz). None of it. The signal path measured clean; it just sounded wrong.
The actual cause was a gain bug: converting the INMP441's 24-bit samples down to 16 bits used raw >> 14 — a 4× amplification, not the unity conversion I intended. Speech survived it without visibly clipping. A 1 kHz test tone did not: 40,407 of 80,000 samples pinned at full scale, 41.5% total harmonic distortion.
That's the part worth sitting with. Sirens are loud by design. A gain bug that passes every check on room noise and speech will clip the exact signal this whole project exists to capture — and it would have done so silently, producing training data that looked plausible and was structurally wrong. Fixed to raw >> 16 (true unity gain): headroom beats loudness, because a host can always amplify a quiet recording later, but nothing recovers a clipped sample.
Verified clean with the same 1 kHz reference tone afterward: 0% clipped, distortion harmonics decaying steeply instead of sitting flat. Noise floor measured at −67.4 dBFS broadband and, more relevantly, −74.9 dBFS in the 500–1500 Hz band where sirens actually live.
The frontend that both paths will share
The next piece isn't a detector yet — it's the signal-conditioning chain that every detector after it, hand-tuned or learned, reads from: a 150 Hz high-pass to remove desk rumble and handling noise, followed by a 500–1500 Hz bandpass tuned to where a siren's energy actually concentrates, both at 24 dB/octave. Validated against a host-side Python reference to 0.0001 dB agreement, with both band corners landing at exactly −3.0 dB and the passband flat within 0.4 dB.
This is worth naming explicitly, because it's the seam the rest of the series runs through: the same filtered, correctly-scaled audio feeds a hand-written threshold detector in Part 2, and later becomes the input to a trained model in Parts 4 and 5. Get this stage wrong and every comparison downstream is comparing garbage to garbage. Get it right once, here, and it never needs revisiting.
Proof this all actually works came a few days later, unattended: the device's first genuinely confirmed siren pass, captured at the window with no one watching.
What's next
With clean, correctly-gained audio flowing and a validated filter frontend, the obvious next move is not to reach for a model. It's to ask whether a simple threshold on that filtered signal is good enough on its own — and to find out, honestly, whether it actually is. That's Part 2.