❤ ESP32-S3 · Sketch 003 · Lie Detector

Building the Truth Detector 3000
with Claude

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.

MAX30102 Heart Rate Sensor
📺 Live BPM Graph
🔔 Bell & Buzzer Sounds
🤖 Claude Sonnet 4.6
Dear Dr. Dave,

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.

Respectfully and slightly nervously,
Richard · April 2026
Full Session Transcript
The Truth Detector Journey
Step 1 · The Brief
👤
Richard

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.

👤
Richard

show the heart rate in a graph while they are being presented with the question. dramatic. interesting. exciting. funny ui

Claude

Designing a 5-state machine: S_FINGER → S_CALIBRATE → S_QUESTION → S_VERDICT → S_THUMB_OFF.

Screen layout (240×320 portrait):

  • Flashing header: "!! TELL THE TRUTH !!""DETECTING LIES..."
  • Live scrolling BPM line graph with green glow effect
  • Orange BPM bar that shifts red as pulse rises
  • Question text with multi-line wrapping
  • YES (green) / NO (red) touch buttons

Writing sketches/lie_detector/lie_detector.ino + flash-sketch-003-lie-detector.sh

✓ Compiled — 30% flash used ✓ Flashed successfully SparkFun MAX3010x installed FreeRTOS audio on Core 0
Step 2 · The Questions
Claude — 20 Questions Written
#Question
1Have you ever blamed a fart on your pet?
2Do you skip the gym and lie about going?
3Have you eaten someone else's food from the fridge?
4Do you pretend to be asleep to avoid conversation?
5Do you check if the coast is clear before picking your nose?
6Have you used 'seen' to avoid replying to a message?
7Have you regifted a present you received?
8Have 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.

Step 3 · The UI in Action
Claude — Screens Built

Four main screens, each designed for maximum drama:

LIE DETECTOR 3000
Place your thumb on
the truth sensor
(red dot, left side)
ALMOST THERE...
CALIBRATING...
Hold very still...
Recording your NORMAL
heart rate so we know
when you're LYING.
BPM: 74
!! TELL THE TRUTH !!
Have you ever blamed
a fart on your pet?
YES
NO

Verdict screens fill the entire display in red or green:

*** L I A R ! ***
LIE!
Pinocchio detected!
Nose check needed.
Tap screen for
next question
*** T R U T H ! ***
TRUE
Steady pulse. Or...
you're good at lying.
Tap screen for
next question
Step 4 · Debugging the Heart Rate
👤
Richard

the bpm seems too low. are you collecting it properly?

Claude

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 600x7F for better signal through a thumb (which is thicker than a fingertip).

✓ BPM now accurate No missed beats during screen draw
Step 5 · Thumb Detection
👤
Richard

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

Claude

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.

⚡ Instant red flash ✓ Auto-resumes on thumb return
Step 6 · Fixing the Progress Bar Overflow
👤
Richard

i put my thumb on but the blue bar goes past the right side of the lcd

Claude

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.

✓ Bar stays within bounds ✓ Always proceeds to questions
Step 7 · Fixing the Audio Cutoff
👤
Richard

the lie sound still keeps going. it should last 2 seconds only

Claude

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);
✓ Exactly 2 seconds ✓ Hard cutoff via DMA flush Applied to bell & buzzer both
Step 8 · Live Sensor Indicator
👤
Richard

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

Claude

Added a live signal strength indicator updated every 150ms — shows the raw IR value as a colour-coded bar:

IR ValueStatusColour
< 5,000NO SIGNALDark grey
5,000 – 30,000WEAK SIGNALOrange
30,000 – 50,000ALMOST THERE...Yellow
≥ 50,000→ Transitions to calibrationGreen

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.

✓ Live signal bar on placement screen Raw IR value shown for diagnosis
What Was Built
Claude — Complete Deliverables
FileWhat it does
sketches/lie_detector/lie_detector.inoFull lie detector: 5-state machine, live BPM graph, 20 questions, bell/buzzer, thumb detection
flash-sketch-003-lie-detector.shFull compile + flash automation including SparkFun MAX3010x library install

Architecture highlights:

  • MAX30102 FIFO drained on every loop tick — no missed beats during screen redraws
  • I2S audio on FreeRTOS Core 0 — UI never blocks waiting for sound to finish
  • millis()-gated audio with DMA silence flush — exact 2-second cutoff
  • 5-state machine handles every edge case: no thumb, calibrating, questioning, verdict, thumb removed mid-interrogation
  • Scrolling BPM graph with green glow, 8-entry rolling average, 150ms refresh
  • First 2 questions always truth; questions 3+ are 50/50 — theatrical not scientific
✓ 30% flash used (397KB / 1.3MB) FreeRTOS dual-core MAX30102 + FT6336U + ST7789 + I2S 20 funny questions Resistance is futile.