From a one-line request to a fully working biometric lie detector — live BPM graph, bell & buzzer sounds, and 20 funny questions — in a single conversation.
You remember how last time I told you about getting the ESP32-S3 to beep when you tap a button? Well, I went a little further. I asked Claude to build a lie detector.
Not a fake one with random lights. A real — well, real-ish — biometric lie detector. It uses the onboard MAX30102 pulse sensor to read your heart rate, displays a scrolling live BPM graph on the 2.8" touchscreen while you're being interrogated, plays a bell sound if you're deemed truthful and a buzzer sound if you're lying, and presents you with 20 genuinely funny yes/no questions.
What impressed me wasn't just that Claude wrote it — it's that it diagnosed and fixed every problem along the way without being asked. The heart rate sensor wasn't reading because the firmware was only consuming one FIFO sample per loop tick, missing beats during screen redraws. Claude spotted the root cause, rewrote the collection loop to drain the full FIFO on every call, and the BPM locked in immediately.
The audio system kept running past its 2-second cutoff because i2s_channel_write was blocking for up to 300ms per chunk — so instead of counting samples, Claude switched to a millis()-based exit with a short I2S timeout, then flushes silence into the DMA buffer at the end to kill any residual audio. Precise. Correct. Elegant.
There's a "thumb off sensor" state that flashes the screen red and demands you put your thumb back — with a note that "removing your thumb is very suspicious." The first two questions always register as truth to build false confidence, then every question after that is a pure coin flip. It's theatrical, it's funny, and it runs on a £25 development board.
Dave, I genuinely cannot overstate how good this is. Claude understands embedded hardware at a level I didn't expect — not just the code, but the physics of why a DMA buffer keeps playing audio after a function returns, or why a progress bar overflows when a timing condition isn't met. It reasons about the hardware, not just the syntax.
The Truth Detector 3000 is real. And it is coming for all of us.
new sketch. lie detector simulator using that sensor. it should present questions, the user selects yes or no on the lcd. if the user is 'lying', the speakers should buzz in a negative way. if the user tells the truth, a positive sound like a bell. many funny questions.
show the heart rate in a graph while they are being presented with the question. dramatic. interesting. exciting. funny ui
Designing a 5-state machine: S_FINGER → S_CALIBRATE → S_QUESTION → S_VERDICT → S_THUMB_OFF.
Screen layout (240×320 portrait):
Writing sketches/lie_detector/lie_detector.ino + flash-sketch-003-lie-detector.sh…
| # | Question |
|---|---|
| 1 | Have you ever blamed a fart on your pet? |
| 2 | Do you skip the gym and lie about going? |
| 3 | Have you eaten someone else's food from the fridge? |
| 4 | Do you pretend to be asleep to avoid conversation? |
| 5 | Do you check if the coast is clear before picking your nose? |
| 6 | Have you used 'seen' to avoid replying to a message? |
| 7 | Have you regifted a present you received? |
| 8 | Have you pretended you've seen a movie you haven't? |
| … | + 12 more equally incriminating questions, shuffled each session |
First two questions always register as truth to build false confidence. From question 3 onward: pure 50/50 coin flip.
Four main screens, each designed for maximum drama:
Verdict screens fill the entire display in red or green:
the bpm seems too low. are you collecting it properly?
Root cause found. The original updateHR() was calling hr.getIR() which reads exactly one sample from the FIFO per call. During TFT graph redraws (~300ms), multiple sensor samples pile up in the FIFO and go unread — so the inter-beat delta spans 2 or more actual beats, halving the reported BPM.
Fix: drain the entire FIFO on every call using the FIFO API:
void updateHR() {
hr.check(); // fill library buffer from FIFO
while (hr.available()) {
long ir = hr.getFIFOIR();
fingerOn = (ir >= 50000);
if (fingerOn && checkForBeat(ir)) {
// compute BPM from inter-beat delta
}
hr.nextSample();
}
}
Also bumped LED brightness from 60 → 0x7F for better signal through a thumb (which is thicker than a fingertip).
if the user takes their thumb off the sensor at any time, flash the screen red and demand the user put their thumb back on the sensor
Added S_THUMB_OFF state. Any state (calibrating, question, verdict) that loses the IR signal immediately transitions to a full-screen flashing red alert:
THUMB OFF
SENSOR!
Put your thumb back on
the sensor
(red dot, left side)
Removing your thumb is
very suspicious.
Header alternates between bright red and dark red every 400ms. When IR ≥ 50,000 returns, it resumes the previous state — or restarts calibration cleanly if that's where you left off.
Key fix: resume condition checks fingerOn only (not beatAvg > 20) — BPM resets to 0 when the thumb comes off so a BPM requirement would lock the user out permanently.
i put my thumb on but the blue bar goes past the right side of the lcd
Root cause: Arduino's map() doesn't clamp — it extrapolates linearly. If elapsed overshoots CALIB_MS while waiting for a valid BPM reading, the mapped pixel width grows larger than barW and fillRect runs off the right edge of the screen.
// Before — unclamped, could overflow screen
int pw = map(elapsed, 0, CALIB_MS, 0, barW);
// After — safe
int pw = constrain(map(elapsed, 0, CALIB_MS, 0, barW), 0, barW);
Also removed the && calibCount > 0 gate on the calibration completion check — calibration now always exits after 3 seconds, using a 72 BPM default baseline if no beats were detected.
the lie sound still keeps going. it should last 2 seconds only
Two separate problems — both fixed:
Problem 1 — The loop ran too long. i2s_channel_write had a 300ms timeout per chunk. With ~172 chunks of audio, worst-case duration was 172 × 300ms = 51 seconds. Switching to a millis()-based exit with a 20ms I2S timeout lets the loop check wall time on every iteration and exit after exactly 2000ms.
Problem 2 — DMA buffer kept playing after the loop. Even after playBuzzer() returned, audio queued in the DMA buffer continued. Fix: write 8 chunks of silence after the loop to immediately overwrite and silence the DMA output.
unsigned long endMs = millis() + 2000;
while (millis() < endMs) {
// generate + write chunk (20ms timeout)
}
// Flush DMA — silence kills residual audio
memset(buf, 0, sizeof(buf));
for (int f = 0; f < 8; f++) writeI2S(buf, CHUNK);
on the screen that says place your thumb on the sensor, make an indicator that the sensor is detecting. it used to work now i don't think it detects the heart beat
Added a live signal strength indicator updated every 150ms — shows the raw IR value as a colour-coded bar:
| IR Value | Status | Colour |
|---|---|---|
| < 5,000 | NO SIGNAL | Dark grey |
| 5,000 – 30,000 | WEAK SIGNAL | Orange |
| 30,000 – 50,000 | ALMOST THERE... | Yellow |
| ≥ 50,000 | → Transitions to calibration | Green |
Raw IR value shown in small text at the bottom so you can see exactly what the sensor reads and whether repositioning the thumb helps.
Also exposed gLastIR as a global updated on every FIFO drain, and removed the old beatAvg > 20 transition gate — the app now moves to calibration as soon as the IR signal is strong enough, without waiting for BPM to compute first.
| File | What it does |
|---|---|
sketches/lie_detector/lie_detector.ino | Full lie detector: 5-state machine, live BPM graph, 20 questions, bell/buzzer, thumb detection |
flash-sketch-003-lie-detector.sh | Full compile + flash automation including SparkFun MAX3010x library install |
Architecture highlights:
millis()-gated audio with DMA silence flush — exact 2-second cutoff