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 zero

The 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];
}
Tip

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.