Lie detector, science fair style, on an Arduino Uno
Skin response and heart rate against a calm baseline on a ten-segment stress bar. A science demo of how the body reacts, not a real lie detector. The whole build, an Arduino Uno and 14 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Intermediate About 40 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $42. On Mokxi they're free.
What you'll learn
- Measuring skin conductance with a GSR sensor
- Detecting a heartbeat from an infrared signal
- Comparing live readings with a baseline
- Driving a ten-segment LED bar from ten pins
- Why a stress reading is not proof of lying
- Difficulty: Intermediate
- Time: About 40 minutes
- Board: Arduino Uno R3
- Parts: 16
How it works
This is a science demo of the body's stress response, not a real lie detector. A galvanic skin response sensor on A0 measures how well your skin conducts. Sweat raises it. A MAX30102 sensor on I2C detects each heartbeat with infrared light.
At startup, and whenever you press the button on A3, the sketch spends 10 seconds recording a calm baseline of skin conductance and heart rate. The bar graph fills as a progress meter during that time.
After that, the ten-segment bar on pins 2 to 11 shows the change from baseline. Each 5% more conductance adds a segment, and each 3 beats a minute above baseline adds another. It prints readings every half second.
Parts list
16 parts, plus the jumper wires. They retail for about $42; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 1 × LED bar graph, 10 segmentsIn Spaceship Panel Kit.
- 10 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × Galvanic skin response sensor
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × MAX30102 pulse oximeter
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| MAX30102 pulse oximeter pin SCL | Arduino Uno R3 pin SCL |
| MAX30102 pulse oximeter pin SDA | Arduino Uno R3 pin SDA |
| MAX30102 pulse oximeter pin GND | Arduino Uno R3 pin GND |
| Resistor, 220 Ω (10) pin 1 | Arduino Uno R3 pin 11 |
| Resistor, 220 Ω (9) pin 1 | Arduino Uno R3 pin 10 |
| Resistor, 220 Ω (8) pin 1 | Arduino Uno R3 pin 9 |
| Resistor, 220 Ω (7) pin 1 | Arduino Uno R3 pin 8 |
| Resistor, 220 Ω (6) pin 1 | Arduino Uno R3 pin 7 |
| Resistor, 220 Ω (5) pin 1 | Arduino Uno R3 pin 6 |
| Resistor, 220 Ω (4) pin 1 | Arduino Uno R3 pin 5 |
| Resistor, 220 Ω (3) pin 1 | Arduino Uno R3 pin 4 |
| Resistor, 220 Ω (2) pin 1 | Arduino Uno R3 pin 3 |
| Resistor, 220 Ω (1) pin 1 | Arduino Uno R3 pin 2 |
| Galvanic skin response sensor pin VCC | Arduino Uno R3 pin IOREF |
| MAX30102 pulse oximeter pin VIN | Arduino Uno R3 pin 3V3 |
| LED bar graph, 10 segments pin C10 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C9 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C8 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C7 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C6 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C5 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C4 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C3 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C2 | Arduino Uno R3 pin GND |
| LED bar graph, 10 segments pin C1 | Arduino Uno R3 pin GND |
| Pushbutton pin 2a | Arduino Uno R3 pin GND |
| Pushbutton pin 2b | Arduino Uno R3 pin GND |
| Galvanic skin response sensor pin GND | Arduino Uno R3 pin GND |
| Galvanic skin response sensor pin SIG | Arduino Uno R3 pin A0 |
| Pushbutton pin 1a | Arduino Uno R3 pin A3 |
| Pushbutton pin 1b | Arduino Uno R3 pin A3 |
| Resistor, 220 Ω (1) pin 2 | LED bar graph, 10 segments pin A1 |
| Resistor, 220 Ω (2) pin 2 | LED bar graph, 10 segments pin A2 |
| Resistor, 220 Ω (3) pin 2 | LED bar graph, 10 segments pin A3 |
| Resistor, 220 Ω (4) pin 2 | LED bar graph, 10 segments pin A4 |
| Resistor, 220 Ω (5) pin 2 | LED bar graph, 10 segments pin A5 |
| Resistor, 220 Ω (6) pin 2 | LED bar graph, 10 segments pin A6 |
| Resistor, 220 Ω (7) pin 2 | LED bar graph, 10 segments pin A7 |
| Resistor, 220 Ω (8) pin 2 | LED bar graph, 10 segments pin A8 |
| Resistor, 220 Ω (9) pin 2 | LED bar graph, 10 segments pin A9 |
| Resistor, 220 Ω (10) pin 2 | LED bar graph, 10 segments pin A10 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Lie detector, science fair style: skin response and heart rate against a
// calm baseline, on a ten-segment stress bar.
//
// A science demo, not a lie detector. Nothing can tell a lie from the
// truth by measuring the body: sweat and heart rate rise with surprise,
// effort, laughter or a cold room just as readily as with a fib. What this
// shows, honestly, is the body's stress response, and how it changes from a
// calm baseline.
//
// The skin sensor is a Grove GSR module with two finger straps. Its reading
// x converts to resistance with the module's own formula,
// R = (1024 + 2x) * 10 k / (512 - x)
// and conductance, in microsiemens, is one over that. Sweatier skin conducts
// more.
//
// Wiring (Arduino Uno):
// GSR SIG -> A0 (the module runs from 5 V)
// LED bar segments 1 to 10 -> D2 to D11, each through 220 ohms
// calibrate button -> A3, to ground
// MAX30102 on I2C: SDA A4, SCL A5, VIN 5 V
#include <Wire.h>
#include "MAX30105.h"
#include "heartRate.h"
const uint8_t GSR_PIN = A0;
const uint8_t FIRST_BAR = 2; // segments on D2 to D11
const uint8_t CALIBRATE = A3; // the button, to ground
const unsigned long CALIBRATE_MS = 10000;
MAX30105 heart;
const byte RATE_SIZE = 4; // beats averaged
byte rates[RATE_SIZE];
byte rateSpot = 0;
byte ratesSeen = 0;
unsigned long lastBeat = 0;
int bpm = 0;
float baseSkin = 0; // microsiemens, the calm baseline
int baseBpm = 0;
bool calibrating = false;
bool calibrated = false;
unsigned long calibrateAt = 0;
float skinSum = 0;
long bpmSum = 0;
int samples = 0;
bool buttonDown = false;
unsigned long printAt = 0;
// Skin conductance in microsiemens, or 0 with no fingers on the straps.
float readSkin() {
int x = analogRead(GSR_PIN);
if (x >= 505) return 0; // open straps read 512
float ohms = (1024.0 + 2.0 * x) * 10000.0 / (512.0 - x);
return 1000000.0 / ohms;
}
void showBar(int segments) {
for (uint8_t i = 0; i < 10; i++) {
digitalWrite(FIRST_BAR + i, i < segments ? HIGH : LOW);
}
}
// The SparkFun example's way: a beat is a rise in the infrared reflection,
// and the gap between two beats is the heart rate, averaged over four.
void readHeart() {
long ir = heart.getIR();
if (ir < 50000) { // no finger on the sensor
bpm = 0;
ratesSeen = 0;
return;
}
if (checkForBeat(ir)) {
unsigned long now = millis();
unsigned long gap = now - lastBeat;
lastBeat = now;
float rate = 60000.0 / gap;
if (rate > 30 && rate < 220) {
rates[rateSpot++] = (byte)rate;
rateSpot %= RATE_SIZE;
if (ratesSeen < RATE_SIZE) ratesSeen++;
int sum = 0;
for (byte i = 0; i < ratesSeen; i++) sum += rates[i];
bpm = sum / ratesSeen;
}
}
}
void startCalibration() {
calibrating = true;
calibrateAt = millis();
skinSum = 0;
bpmSum = 0;
samples = 0;
Serial.println("Calibrating: sit still and breathe slowly for 10 seconds.");
}
void readButton() {
bool down = digitalRead(CALIBRATE) == LOW;
if (down && !buttonDown) startCalibration();
buttonDown = down;
}
void setup() {
Serial.begin(115200);
for (uint8_t i = 0; i < 10; i++) pinMode(FIRST_BAR + i, OUTPUT);
pinMode(CALIBRATE, INPUT_PULLUP);
if (!heart.begin(Wire, I2C_SPEED_FAST)) {
Serial.println("MAX30102 not found: check its wiring.");
}
heart.setup();
heart.setPulseAmplitudeRed(0x0A); // a dim red lamp to say it is on
heart.setPulseAmplitudeGreen(0);
Serial.println("Science fair stress meter ready.");
startCalibration();
}
void loop() {
readHeart();
readButton();
float skin = readSkin();
if (calibrating) {
// Ten seconds of quiet readings make the baseline. The bar fills as a
// progress meter while it does.
unsigned long spent = millis() - calibrateAt;
if (skin > 0 && millis() - printAt >= 200) {
printAt = millis();
skinSum += skin;
bpmSum += bpm;
samples++;
}
showBar(spent * 10 / CALIBRATE_MS);
if (spent >= CALIBRATE_MS) {
calibrating = false;
if (samples == 0) {
Serial.println("No fingers on the straps. Press the button to try again.");
return;
}
calibrated = true;
baseSkin = skinSum / samples;
baseBpm = bpmSum / samples;
Serial.print("Baseline: skin ");
Serial.print(baseSkin, 2);
Serial.print(" uS");
Serial.print(", heart ");
Serial.print(baseBpm);
Serial.print(" bpm");
Serial.println(". Now ask a question.");
}
return;
}
if (millis() - printAt < 500) return;
printAt = millis();
if (!calibrated) return;
if (skin == 0) {
showBar(0);
Serial.println("skin no fingers on the straps");
return;
}
// Stress is a change from the baseline, not a level: one person's calm is
// another's excited. The scale is ours, not a medical one: every 5% more
// conductance is one segment
float skinChange = (skin - baseSkin) * 100.0 / baseSkin;
float score = skinChange / 5.0;
// and every 3 beats a minute above the baseline is another.
int heartChange = bpm > 0 && baseBpm > 0 ? bpm - baseBpm : 0;
score += heartChange / 3.0;
int segments = constrain((int)(score + 0.5), 0, 10);
showBar(segments);
Serial.print("skin ");
Serial.print(skin, 2);
Serial.print(" uS (");
if (skinChange >= 0) Serial.print('+');
Serial.print(skinChange, 0);
Serial.print("%)");
Serial.print(" heart ");
if (bpm > 0) {
Serial.print(bpm);
Serial.print(" bpm (");
if (heartChange >= 0) Serial.print('+');
Serial.print(heartChange);
Serial.print(")");
} else {
Serial.print("--");
}
Serial.print(" stress ");
Serial.print(segments);
Serial.println("/10");
}
Code walkthrough
The key functions of the sketch, and what each one does.
readSkin sketch.ino
Reads A0 and turns it into conductance in microsiemens. A reading of 505 or more means the straps are open, so it returns 0. Otherwise it computes the skin resistance from the module's formula and returns one million divided by that. Sweatier skin gives a bigger number.
// Skin conductance in microsiemens, or 0 with no fingers on the straps.
float readSkin() {
int x = analogRead(GSR_PIN);
if (x >= 505) return 0; // open straps read 512
float ohms = (1024.0 + 2.0 * x) * 10000.0 / (512.0 - x);
return 1000000.0 / ohms;
}readHeart sketch.ino
Reads the infrared level. Below 50000 there is no finger, so the heart rate is cleared. When checkForBeat sees a beat, the time since the last beat gives a rate. Rates between 30 and 220 are kept, and the last four are averaged into bpm.
// The SparkFun example's way: a beat is a rise in the infrared reflection,
// and the gap between two beats is the heart rate, averaged over four.
void readHeart() {
long ir = heart.getIR();
if (ir < 50000) { // no finger on the sensor
bpm = 0;
ratesSeen = 0;
return;
}
if (checkForBeat(ir)) {
unsigned long now = millis();
unsigned long gap = now - lastBeat;
lastBeat = now;
float rate = 60000.0 / gap;
if (rate > 30 && rate < 220) {
rates[rateSpot++] = (byte)rate;
rateSpot %= RATE_SIZE;
if (ratesSeen < RATE_SIZE) ratesSeen++;
int sum = 0;
for (byte i = 0; i < ratesSeen; i++) sum += rates[i];
bpm = sum / ratesSeen;
}
}
}loop sketch.ino
First it reads the heart sensor, the button and the skin. During calibration it sums samples for 10 seconds and then stores baseSkin and baseBpm. After that it scores the change from baseline, sets the bar with showBar, and prints skin, heart and stress out of 10.
void loop() {
readHeart();
readButton();
float skin = readSkin();
if (calibrating) {
// Ten seconds of quiet readings make the baseline. The bar fills as a
// progress meter while it does.
unsigned long spent = millis() - calibrateAt;
if (skin > 0 && millis() - printAt >= 200) {
printAt = millis();
skinSum += skin;
bpmSum += bpm;
samples++;
}
showBar(spent * 10 / CALIBRATE_MS);
if (spent >= CALIBRATE_MS) {
calibrating = false;
if (samples == 0) {
Serial.println("No fingers on the straps. Press the button to try again.");
return;
}
calibrated = true;
baseSkin = skinSum / samples;
baseBpm = bpmSum / samples;
Serial.print("Baseline: skin ");
Serial.print(baseSkin, 2);
Serial.print(" uS");
// ... 47 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The monitor says No fingers on the straps. This project
The skin sensor must read a closed circuit during calibration. Check the sensor signal on A0, then press the button to try again. - The bar sits at 0 or 10 all the time. This project
The baseline was taken while you were not calm. Press the button and sit still for the 10 seconds. - The monitor says MAX30102 not found. This project
Check its SDA and SCL wires and its power and ground. - Segments are dim, uneven, or the chip is damaged. LED bar graph
Every segment is its own LED and needs its own current-limiting resistor, about 220 to 330 ohms at 5 V. One shared resistor makes brightness change with how many are lit. - The bar lights in reverse order or a segment never lights. LED bar graph
Check pin 1 and the orientation. Anode and cathode sides are on opposite rows. Some bars are numbered the other way around, so test with one segment at a time. - The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure. - A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground. - The reading is noisy or unrelated to skin. Galvanic skin response sensor
Strap the electrodes on two fingers snugly and keep still. Poor contact changes the signal. Let it settle for 30 seconds or more after putting it on.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.