Short answer: yes, but the part is fighting you, and you should know by how much before you design a product around it. This is what I measured building wrist heart rate firmware on an ESP32-C3 with a MAX30102 — the chain that worked, the numbers it produced against a hand-counted pulse, and the case it still fails.
The problem with the wrist
The MAX30102 is a red/IR part designed for fingertip SpO2. At the fingertip you get a strong pulsatile signal through thin tissue with a dense capillary bed. The wrist gives you neither.
Measured on this band, wrist perfusion — the AC/DC ratio, how much of the returned light is actually pulsing — ran between 0.02% and 0.25%. In one capture the baseline wander was 8.8× the DC level while the pulse itself was 7.2×. The signal you want is routinely weaker than the drift it sits on.
This is also why commercial wrist wearables use green LEDs rather than red or IR: haemoglobin absorbs green far more strongly, and green stays in the shallow capillary bed instead of penetrating past it. The MAX30105 is a near drop-in for the 30102 — same 0x57 address, same 0x15 part ID, same register map — and adds a green channel. If you are picking a part today rather than inheriting one, pick that. It fixes optically what the rest of this post fights algorithmically.
The signal chain
Eight stages, in this order. Each one earns its place; several things I expected to help did not survive measurement.
ambient subtract 3rd LED slot with no LED = dark reference
-> median-5 + MA-3 impulse rejection
-> DC-normalise x/dc - 1, puts Red and IR on a common scale
-> Butterworth 6th-order band-pass, 0.8-3.8 Hz
-> NLMS Red as noise reference, alpha adapted out of band
-> 512-pt FFT 20.5 s window, harmonic-aware peak picking
-> SQI concentration + SNR + kurtosis + band-edge penalty
-> ACQUIRE/LOCKED state machine, then KalmanAmbient subtraction
The MAX30102 can be configured with a third LED time slot that fires no LED. That slot reads whatever ambient light reaches the photodiode, and subtracting it removes room lighting and most of the 50/60 Hz flicker before anything else runs. It costs one slot and it is the cheapest win in the chain.
Median-5 then MA-3
A 5-sample median kills impulse noise — contact micro-slips produce single-sample spikes that a mean filter smears across neighbours instead of removing. A 3-sample moving average after it smooths what is left. Measured across three captures this improved pulse SNR by 27%, 39% and 18%.
DC normalisation
Dividing by the DC level and subtracting one (x/dc - 1) puts the red and IR channels on a common scale. This matters for the next stage: NLMS needs the two channels comparable in magnitude, and raw counts are not, because the LED currents and the tissue response differ.
Butterworth band-pass, 0.8–3.8 Hz
48 to 228 BPM. Sixth order. The low corner is doing the real work — it removes the baseline wander that is otherwise larger than the pulse. Going lower to catch bradycardia lets the wander back in, and at that point nothing downstream recovers.
NLMS with red as the noise reference
Adaptive noise cancellation using the red channel as a reference for what the IR channel should not contain. The important detail is that the adaptation rate is updated using out-of-band energy only. If you adapt on in-band energy the filter learns the pulse and cancels it, which is a fun way to spend an afternoon.
512-point FFT with harmonic-aware peak picking
A 20.5-second window. Frequency-domain estimation beats time-domain peak detection here because the pulse is often not the largest feature in the waveform, but it is usually the most concentrated peak in the spectrum. Harmonic awareness matters: the second harmonic is frequently stronger than the fundamental, and naive peak picking reports double the true rate.
Signal quality index
A composite of four terms — spectral concentration, SNR, kurtosis, and a band-edge penalty — collapsed to 0..1. This is what lets the firmware say 'I do not know' instead of reporting a confident wrong number, which for anything health-adjacent is the more important output.
State machine, then Kalman
An ACQUIRE/LOCKED machine gates whether an estimate is published at all, and a Kalman filter smooths the accepted track. The ordering matters: filtering before gating produces a beautifully smooth line through garbage.
What it actually achieves
| Condition | Measured | True | Error |
|---|---|---|---|
| Rest, good coupling | 89.7 BPM | 90 | 0.3 |
| Rest, mediocre coupling | 85.0 BPM | 82 | 3.0 |
| Typing | 84.3 BPM | 82 | 2.3 |
| Slow arm movement | 76.0 BPM | 82 | 6.0 |
True rates are hand-counted. At rest with good strap coupling the error is 0.3 BPM, which is as good as the reference method. Typing is survivable — the tracker coasts through the artifact rather than chasing it. Slow arm movement still locks, but the spread widens to roughly ±24 BPM, which is wide enough that I would not report it to a user.
What it does not solve
Vigorous motion. Not 'degrades under' — the pulse genuinely disappears. During typing the raw dominant spectral peak sits at 46 BPM, and even a best-case least-squares subtraction of the red channel cannot recover the true rate, because the motion artifact is not common-mode between the channels. There is nothing left in the signal to find.
Closing that needs an accelerometer and TROIKA- or JOSS-class cancellation, where motion is removed using an independent measurement of the motion itself rather than inferred from a second optical channel. That is a hardware decision, not a firmware one, and no amount of additional filtering substitutes for it.
The number to watch during bring-up
Perfusion, in parts per million, is the single most useful diagnostic. Lock rate tracks it almost linearly:
| Perfusion | Time locked |
|---|---|
| 1746 ppm | 79% |
| 740 ppm | 56% |
So when the readings are poor, the question is almost never the algorithm — it is the strap. Log perfusion and tune the fit until it stops improving, then look at the code.
The IR DC level is the other one worth logging: roughly 100,000 on skin and 5,000 off it, which gives you contact detection for free with no extra sensor.
If you are starting this today
- Use a green-channel part (MAX30105 or similar). Everything above gets easier.
- Add an accelerometer from day one if the device will be worn in motion. You cannot retrofit motion cancellation without one.
- Log perfusion and DC level before you tune anything else — most 'algorithm' problems are mechanical.
- Build the signal-quality index early. Knowing when to stay silent is worth more than a marginally better estimate.
- Validate against a hand count, not against another wearable. Two devices agreeing on the wrong number is a common and expensive illusion.