PPG on Hardware
Heart rate extraction on ESP32-S3 with MAX30102
The PPG pipeline — DC removal, bandpass filtering, zero-crossing detection, Hampel outlier rejection, exponential smoothing — maps cleanly to a microcontroller. The entire chain runs at 100 Hz with negligible CPU load, leaving the ESP32-S3 free for wireless communication, display updates, and sensor management.
This page consolidates the embedded implementation. For the theory, signal decomposition, and Python prototypes, see the main PPG page.
Hardware
| Component | Role | Interface | Approx. cost |
|---|---|---|---|
| ESP32-S3-DevKitC | Processing, WiFi/BLE | — | EUR 8 |
| MAX30102 breakout | PPG sensor (red + IR LEDs, photodetector, 18-bit ADC) | I2C | EUR 3 |
| LSM303C breakout | 3-axis accelerometer (motion detection, future adaptive filtering) | I2C | EUR 5 |
| PCM5102 breakout | Audio DAC for sonification / heartbeat feedback | I2S | EUR 3 |
| SSD1306 OLED (128x64) | Heart rate display | I2C | EUR 3 |
| Breadboard + wires | EUR 3 | ||
| Total | ~EUR 25 |
The MAX30102 samples at 100 Hz with 18-bit resolution. Both red and infrared channels are read; only the IR channel is used for heart rate. The red channel is reserved for future SpO2 estimation.
I2C bus setup
Both the MAX30102 and LSM303C share the same I2C bus. The ESP32-S3 has two I2C controllers; using one for sensors keeps the other available for the OLED display if needed.
#include "driver/i2c.h"
#define I2C_MASTER_NUM I2C_NUM_0
#define I2C_SDA_PIN GPIO_NUM_21
#define I2C_SCL_PIN GPIO_NUM_22
#define I2C_FREQ_HZ 400000
void i2c_init(void) {
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = I2C_SDA_PIN,
.scl_io_num = I2C_SCL_PIN,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master = { .clk_speed = I2C_FREQ_HZ },
};
i2c_param_config(I2C_MASTER_NUM, &conf);
i2c_driver_install(I2C_MASTER_NUM, conf.mode, 0, 0, 0);
}MAX30102 configuration
The MAX30102 is configured for PPG mode (red + IR), 100 Hz sample rate, 18-bit resolution, and moderate LED current:
#define MAX30102_ADDR 0x57
// Key registers
#define REG_MODE_CONFIG 0x09
#define REG_SPO2_CONFIG 0x0A
#define REG_LED1_PA 0x0C // Red LED current
#define REG_LED2_PA 0x0D // IR LED current
// Helper: write a single register via I2C.
// Implement using i2c_master_write_to_device() from driver/i2c.h.
static esp_err_t i2c_write_reg(uint8_t addr, uint8_t reg, uint8_t val) {
uint8_t buf[2] = {reg, val};
return i2c_master_write_to_device(I2C_MASTER_NUM, addr,
buf, sizeof(buf),
pdMS_TO_TICKS(100));
}
void max30102_init(void) {
// Reset
i2c_write_reg(MAX30102_ADDR, REG_MODE_CONFIG, 0x40);
vTaskDelay(pdMS_TO_TICKS(100));
// SpO2 mode (red + IR), 100 Hz sample rate, 18-bit ADC (411 us pulse width)
// Note: sample averaging is set via FIFO_CONFIG register (0x08), not here
i2c_write_reg(MAX30102_ADDR, REG_SPO2_CONFIG, 0x27);
i2c_write_reg(MAX30102_ADDR, REG_MODE_CONFIG, 0x03);
// LED current: ~6 mA (good starting point for finger-tip)
i2c_write_reg(MAX30102_ADDR, REG_LED1_PA, 0x1F);
i2c_write_reg(MAX30102_ADDR, REG_LED2_PA, 0x1F);
}The LED current may need adjustment depending on skin tone and sensor placement. Higher current gives stronger signals but increases power consumption. A perfusion index below 0.5% suggests insufficient LED drive or poor contact.
DSP pipeline
The pipeline is structured as five sequential stages, each implemented as a small C++ class with a process() method. The main loop calls them in order at the sensor’s 100 Hz sample rate.
Stage 1: DC removal and normalisation
A lowpass FIR estimates the DC component. A delay line compensates for the filter’s group delay. The AC/DC ratio normalises the pulse amplitude across different perfusion levels.
// DC removal and perfusion index calculation
// dc_filter: lowpass FIR estimating the slowly varying DC component
// delay_line: compensates for FIR group delay so subtraction is time-aligned
// dc_filter: lowpass FIR estimating DC (see Stage 1 description above)
// delay_line: 50-sample FIFO that delays raw input to align with FIR group delay
float dc = dc_filter.process(sample);
float ac = delay_line.process(sample) - dc;
float ppg = (dc > 100.0f) ? ac / dc : 0.0f; // guard against division by zeroThe delay element is critical — without it, the subtraction would compare the current sample against a time-shifted DC estimate, distorting the AC waveform. For a 101-tap FIR with 0.1 Hz cutoff at 100 Hz sample rate, the group delay is 50 samples (0.5 s).
Stage 2: Bandpass filtering
A biquad cascade isolates the heart rate band (0.5–5 Hz, i.e., 30–300 BPM). This uses transposed direct form II, which is numerically better behaved for floating-point because zeros are processed first:
// Transposed direct form II biquad --- one second-order section
float Biquad::process(float in) {
float out = b[0] * in + w[0];
w[0] = w[1] + b[1] * in - a[1] * out;
w[1] = b[2] * in - a[2] * out;
return out;
}Two sections are cascaded for a 4th-order Butterworth bandpass. The state variables must be initialised to avoid a startup transient that would trigger a false first beat:
void Biquad::init(float dc_value) {
// Pre-fill states to steady-state values for constant input = dc_value
w[1] = dc_value * (b[2] - a[2] * b[0]);
w[0] = dc_value * (b[1] - a[1] * b[0]) + w[1];
}Design the filter in Python with scipy.signal.butter(..., output='sos'), then copy the SOS coefficients into the C++ code. The biquad structure here uses un-negated a[1] and a[2] (note the subtraction in the update equations), matching SciPy’s convention directly. This differs from the CMSIS-DSP convention which stores negated denominators.
Stage 3: Beat detection
Rising zero crossings in the filtered signal mark heartbeat onsets. The inter-beat interval (IBI) converts to BPM, with a range check rejecting physiologically impossible values:
bool PulseDetector::process(float sample) {
bool beat = false;
if (prev_sample < 0.0f && sample >= 0.0f) {
uint32_t now = millis();
uint32_t ibi = now - last_beat_time;
if (ibi > 500 && ibi < 1500) { // 40-120 BPM
heart_rate = 60000.0f / ibi;
beat = true;
}
last_beat_time = now;
}
prev_sample = sample;
return beat;
}Stage 4: Hampel outlier rejection
A sliding-window median filter rejects heart rate estimates that deviate too far from the local trend. This catches motion artifacts that survive the bandpass filter:
float HampelFilter::process(float hr) {
buffer[idx] = hr;
idx = (idx + 1) % window_size;
// Find median via partial sort
std::copy(buffer, buffer + window_size, sorted);
std::sort(sorted, sorted + window_size);
float median = sorted[window_size / 2];
// Median absolute deviation, scaled to sigma
for (int i = 0; i < window_size; i++)
deviations[i] = std::abs(buffer[i] - median);
std::sort(deviations, deviations + window_size);
float mad = 1.4826f * deviations[window_size / 2];
if (std::abs(hr - median) > n_sigma * mad)
return median;
return hr;
}With window_size = 5 and n_sigma = 2.0, the Hampel filter replaces outliers with the local median while passing clean beats through unchanged. The cost is 2 sorts of 5 elements per beat — negligible at heart rate frequencies.
Stage 5: Exponential smoothing
The final EMA produces a stable, displayable heart rate:
float EMA::process(float hr) {
if (!initialised) {
avg = hr;
initialised = true;
} else {
avg = alpha * avg + (1.0f - alpha) * hr;
}
return avg;
}With alpha = 0.9, the output settles within 3–4 beats and smooths out the remaining beat-to-beat variability.
FreeRTOS task structure
The pipeline runs in a dedicated task pinned to core 1, keeping core 0 free for WiFi/BLE and display updates:
void ppg_task(void *param) {
// Initialise sensor and pipeline
max30102_init();
dc_filter.init();
bandpass.init(0.0f); // or first-sample DC value
hampel.init();
ema.init();
while (true) {
// Read sensor (blocks until new sample, ~10 ms at 100 Hz)
uint32_t ir_sample = max30102_read_ir();
float sample = (float)ir_sample;
// Run pipeline
float dc = dc_filter.process(sample);
float ac = delay_line.process(sample) - dc;
float ppg = (dc > 100.0f) ? ac / dc : 0.0f;
float filtered = bandpass.process(ppg);
if (pulse_detector.process(filtered)) {
float hr = pulse_detector.heart_rate;
hr = hampel.process(hr);
hr = ema.process(hr);
// Publish to other tasks via queue or shared variable
xQueueOverwrite(hr_queue, &hr);
}
}
}
void app_main(void) {
i2c_init();
hr_queue = xQueueCreate(1, sizeof(float));
xTaskCreatePinnedToCore(ppg_task, "ppg", 4096, NULL, 5, NULL, 1);
// Core 0: WiFi, BLE, display tasks...
}Respiratory rate
The same pipeline architecture extracts respiratory rate by reusing the components with different parameters:
| Parameter | Heart rate | Respiratory rate |
|---|---|---|
| Bandpass | 0.5 – 5 Hz | 0.1 – 0.5 Hz |
| BPM range | 40 – 120 | 6 – 30 |
A second biquad cascade and zero-crossing detector run in parallel on the same normalised PPG signal:
float resp_filtered = resp_bandpass.process(ppg);
if (resp_detector.process(resp_filtered)) {
float rr = resp_detector.heart_rate; // reusing same class
rr = resp_hampel.process(rr);
rr = resp_ema.process(rr);
xQueueOverwrite(rr_queue, &rr);
}Performance budget
At 100 Hz sample rate on ESP32-S3 (240 MHz), the processing budget per sample is 2,400,000 cycles (10 ms):
| Stage | Operations | Est. cycles | Est. time |
|---|---|---|---|
| DC removal (101-tap FIR) | 101 MACs | ~500 | 2.1 us |
| Delay line (50 samples) | 1 read + 1 write | ~10 | 0.04 us |
| Bandpass (2 biquad sections) | 10 MACs | ~50 | 0.2 us |
| Zero-crossing check | 1 comparison | ~5 | 0.02 us |
| Hampel (per beat, ~1 Hz) | 2 sorts of 5 | ~200 | 0.8 us |
| EMA | 2 MACs | ~10 | 0.04 us |
| Total per sample | ~775 | ~3.2 us | |
| Utilisation | 0.032% |
The pipeline uses less than 0.1% of the available CPU budget. The MAX30102 I2C read (~200 us) is the dominant cost, not the DSP.
Open questions
See the main PPG page for discussion of motion artifact removal via adaptive filtering, SpO2 estimation, and wearable vs. fingertip placement.