All notes

【TECH】Counting beats: peak detection and BPM estimation

How MonoPump turns a cleaned PPG waveform into BPM using peak spacing, timestamp intervals, outlier filtering, and EMA smoothing.

BPMpeak detectionPPGalgorithm
【TECH】Counting beats: peak detection and BPM estimation

A cleaned waveform looks reassuring, but the app still has to answer the only question the user came for: what is my heart rate right now? The answer is not hidden in the height of the peaks. It is hidden in the distance between them.

A pulse waveform with detected peaks highlighted on a health app display
BPM comes from the distance between peaks, not from the height of the waveform.

MonoPump uses a deliberately modest path: find plausible peaks, measure intervals with timestamps, remove suspicious intervals, convert seconds to BPM, then smooth the result so the UI does not jump around.

Flow diagram Preparing diagram
View diagram source
flowchart LR
    A[Cleaned PPG window] --> B[Adaptive peak threshold]
    B --> C[Local maxima]
    C --> D[Peak interval list]
    D --> E[Median outlier filter]
    E --> F[BPM = 60 / avg interval]
    F --> G[EMA smoothing]

Why I did not start with FFT

Frequency-domain methods are attractive. FFT gives a beautiful model: find the strongest frequency in the pulse band and convert it to BPM. The problem is that short, messy mobile-camera windows do not always reward that elegance. Motion, missed frames, and unstable contact can make the strongest frequency look confident for the wrong reason.

Peak detection is less glamorous, but it is easy to debug. If the estimate is wrong, I can inspect the peaks and intervals. In a consumer app, most failures come from contact quality, not from a lack of mathematical sophistication.

The peak is a timing marker

MonoPump looks for local maxima above an adaptive threshold. It also enforces a minimum distance between peaks so one beat does not split into several tiny peaks.

minPeakDistance = sampleRate * 60 / maxBPM
threshold = mean + 0.5 * standardDeviation

A fixed threshold works only when amplitude is predictable. A phone camera signal is not predictable enough, so the threshold follows the current window without letting every ripple become a beat.

Intervals are where the truth lives

After peaks are found, the code measures intervals using their timestamps. A single bad peak should not own the result, so MonoPump filters intervals around the median and averages what remains.

Flow diagram Preparing diagram
View diagram source
flowchart TD
    P1[Peak at t1] --> I1[t2 - t1]
    P2[Peak at t2] --> I2[t3 - t2]
    P3[Peak at t3] --> I3[t4 - t3]
    I1 --> M[Median interval]
    I2 --> M
    I3 --> M
    M --> F[Reject intervals too far from median]

Smoothing the number without hiding reality

Raw BPM estimates naturally move from update to update. Showing every jump makes the app feel nervous. Freezing the number makes it feel fake. MonoPump uses exponential moving average smoothing, then caps each update so the number can move but not teleport.

smoothed = 0.35 * current + 0.65 * previous
smoothed = clamp(smoothed, previous - maxDelta, previous + maxDelta)

Confidence is not decoration. It is tied to how many usable beat intervals were found, while data quality reflects whether recent values contain meaningful variation. A heart-rate app can always print a number. The harder engineering choice is deciding when not to.

From MonoWare

MonoPump has more context behind this note.

Open the product site or keep reading notes filtered to MonoPump.

Newsletter

Privacy and product engineering notes, sent occasionally.