ppg-js · Open source · MIT

Turn any phone into a pulse sensor

Put a fingertip on the camera, hold still, and get heart rate and heart-rate variability right in the browser. No app store, no hardware, and nothing leaves the phone.

ppg-js CI status badgeppg-js npm version badgeMIT license badge
npm install @sontakey/ppg-js

What it can do

ppg-js is a small JavaScript library. You give it a fingertip on a camera, it gives you these:

Heart rate

Live beats per minute, cross-checked two independent ways before a number is ever shown.

Heart-rate variability

RMSSD and SDNN (two standard variability measures) over the last minute, with the measurement noise floor shown next to them.

Breathing rate

Estimated from how your pulse timing and strength shift as you breathe. No chest strap needed.

An honest quality check

Finger not settled, too much motion, weak signal: it tells you what is wrong instead of guessing a number.

Record and replay

Every session can be saved and replayed through the exact same engine, so results are reproducible.

Private by default

No network calls. Camera frames and processing stay on the phone, and nothing is stored unless you ask for it.

Why it's useful

A heart reading normally needs a wearable or a clinic. Here are three things that get easier when a phone camera is enough.

1

A spot check in a wellness app

Add a 3-minute heart and stress reading to an app people already have. No hardware to buy, ship, or charge.

2

A classroom or research demo

Show real biosignal processing on devices students already carry. The whole pipeline is open, so every step can be inspected and discussed.

3

A prototype before you buy sensors

Find out whether camera-based pulse sensing fits your product idea before spending on hardware. The demo doubles as a test bench.

How it works

The trick is that your fingertip is translucent, and your heartbeat is not invisible.

  1. Illustration of a fingertip resting over a phone's rear camera and flash, glowing softly from the light beneath it

    Put your finger on the camera

    Cover the rear camera and flash with a fingertip, gently, like resting your finger on a tiny flashlight.

  2. Cross-section illustration of light entering fingertip tissue, passing through a blood vessel, and returning dimmer to a sensor

    Light shines through your blood

    The flash lights up your fingertip from the inside. Each heartbeat pushes a little more blood through, and blood absorbs light.

  3. Illustration of a noisy signal trace on the left resolving into a clean pulse waveform with marked heartbeats on the right

    The camera sees a tiny ripple

    Brightness dips and rises by a fraction of a percent with every beat. The library cleans that ripple into a clear pulse wave and marks each beat.

  4. Illustration of a floating readout card with a confident bar for the reported reading and a dimmer withheld bar beside it

    You get numbers you can trust

    Beats per minute, the rhythm between beats, and breathing rate. If the signal is not clean enough, you get the reason instead of a number.

For developers: the full pipeline(the seven processing stages and the quality contract)

PPG means photoplethysmography: measuring blood volume changes with light. The same PpgEngine runs the live camera path and the repo's replay tool, so a recorded session replays to identical windows.

  1. 1

    Camera frame

    requestVideoFrameCallback (or requestAnimationFrame as fallback) reads the centre ROI, downscaled, and computes channel means plus the clipped-pixel fraction.

  2. 2

    Finger state machine

    Every sample updates NO_FINGER → SETTLING → MEASURING based on relative presence and drift. Exposure, white balance, and focus lock once MEASURING is first reached.

  3. 3

    Windowed analysis (every 5s)

    The last 8s is interpolated onto one absolute 60 Hz grid, then zero-phase Butterworth bandpassed (4th-order HP 0.6 Hz, 2nd-order LP 4.6 Hz).

  4. 4

    Channel selection & spectral HR

    The channel with the largest pulsatile amplitude is selected. A Hann-windowed, zero-padded FFT with a sub-harmonic guard gives a spectral heart-rate estimate that seeds a refractory prior for peak detection.

  5. 5

    Peak detection & beat validation

    Adaptive-threshold peaks with 0.3s vertex fit produce absolute beat times. Missed-beat recovery looks for a weak pulse inside a 2x gap (flagged lowSnr). Template correlation and interval validation run over the continuous stream.

  6. 6

    HR / IBI / HRV / respiration

    RMSSD and SDNN are computed over the last 60s of accepted beats; respiration comes from breathing-driven modulation of beat timing, amplitude, and baseline.

  7. 7

    Quality gate

    The final verdict: good / reason / code. A number is only reported when every check in the signal quality contract passes.

Signal quality contract: it says why, not just no

quality.good is true only when, in this order, all eight checks hold. reason and code name the first that fails, and heartRate, rmssd, and sdnn are 0 whenever good is false.

#CheckReason code(s)
1fingerState === 'MEASURING'no_finger, settling
2clipped-pixel fraction ≤ 5%saturated
3device motion ≤ threshold (when attached)motion
4pulsatile AC/DC ≥ 0.2%weak_pulse
5≤ 20% of candidate intervals rejected in the last 60sirregular
6≥ 8 accepted intervals in the last 60scollecting
7median template correlation ≥ 0.6morphology
8interval-based and spectral heart rate agree within 25%double_count, missed_beats, fft_disagree

Example verdict

jsmetrics event detail
{ good: false, reason: "weak_pulse", code: "weak_pulse" }
// pulsatile AC/DC is below 0.2%: press a little firmer on the
// lens, or check the flash actually turned on.

Quick start

Three ways in, depending on how you build. Each one is the real API, copied from the README.

1. No bundler: one script tag

htmlindex.html
<script src="https://unpkg.com/@sontakey/ppg-js/dist/index.global.js"></script>
<script>
  const ppg = new PPG.PPG();
  ppg.addEventListener('metrics', (e) => {
    console.log(e.detail.heartRate, e.detail.quality);
  });
  // Camera permission needs a user gesture.
  document.querySelector('#start').onclick = () => ppg.start();
</script>

2. npm, plain JavaScript

npm install @sontakey/ppg-js, then listen for state transitions and metrics, and call start() from a tap.

jsapp.js
import { PPG } from '@sontakey/ppg-js';

const ppg = new PPG();

ppg.addEventListener('state', (e) => {
  // NO_FINGER -> SETTLING -> MEASURING
  console.log(e.detail.state, e.detail.reason);
});

ppg.addEventListener('metrics', (e) => {
  const m = e.detail;
  if (!m.quality.good) {
    console.log('not ready:', m.quality.reason, m.guidanceMessage);
    return;
  }
  console.log(
    `${m.heartRate} bpm, RMSSD ${m.rmssd.toFixed(0)} ms`,
    `(noise floor ±${m.rmssdFloorMs.toFixed(0)} ms)`,
  );
});

// Call from a user gesture (tap): camera, wake lock and motion permission all need one.
button.onclick = () => ppg.start();

3. React hook

tsxusePulse.ts
import { useEffect, useRef, useState } from 'react';
import { PPG } from '@sontakey/ppg-js';

export function usePulse() {
  const ppgRef = useRef<PPG | null>(null);
  const [bpm, setBpm] = useState(0);
  const [status, setStatus] = useState('Tap start, then cover the camera');

  useEffect(() => {
    const ppg = new PPG();
    ppgRef.current = ppg;
    ppg.addEventListener('metrics', (e) => {
      const m = e.detail;
      if (m.quality.good) setBpm(m.heartRate);
      else setStatus(m.guidanceMessage);
    });
    return () => ppg.destroy();
  }, []);

  // Call from a button's onClick: the camera needs a user gesture.
  const start = () => ppgRef.current?.start();
  return { bpm, status, start };
}
Read the full quick-start docs →

Accuracy and limits

In plain words: heart rate is reliable in the tested range, variability readings carry a noise floor that is always shown next to them, and the library has not been clinically validated. The numbers below come from the library's own test suite: a signal simulator, plus six real iPhone recordings.

What the tests enforce: simulated signals (first five rows) and real recordings (last two).
CaseResult
Heart rate, 60-90 bpm, 24/30/60 fpswithin 1 bpm
RMSSD floor on a zero-variability pulse, fractional ROI means9-13 ms (bound 15 ms)
Same with whole-count quantised means11-16 ms (bound 22 ms)
10% dropped framesraises RMSSD by < 2 ms (bound 5 ms)
Respiration at 6 breaths/min6.0 ± 1
Live vs replay, 3 min session (real recording)212 of 212 beats reproduced
Time to first heart rate, clean start (real recording)10s after MEASURING (20s after finger placement)

Not validated: no ECG or chest-strap comparison across a population, no claim across skin tones or ambient light beyond the recordings in test/fixtures/. This is not a medical device. It is for research and personal curiosity, not diagnosis, treatment, or any decision that needs a validated instrument. Requires HTTPS (or localhost) and getUserMedia.

Where it works

Any modern phone browser with a rear camera. Desktop works for development, but without a flash the signal is weak.

PlatformCameraFlash (torch)Notes
iOS Safari 17+ and every iOS browser (WebKit)YesYesThe engine locks exposure and white balance itself, since iPhones do not expose exposureMode.
Android ChromeYesYesTorch, exposure/white-balance/focus locks, zoom, Web Bluetooth. Lens selection by label is best-effort.
Desktop Chrome/Edge/FirefoxYesNoWorks with a desk lamp for development; not a measurement setup.
In-app browsers (Instagram, Facebook, some WebViews)Often noNostart() rejects with unsupported.

See the demo

The demo is a 3-minute HRV spot check built on the library, hosted right here on this site. This is what a session looks like:

ppg-js HRV Spot Check start screen: cover the camera and flash, hold still for 3 minutes, wait for the ring
Start: cover the camera and flash, hold still.
ppg-js measuring screen: 69 bpm, good signal, RMSSD and SDNN with the noise floor, live pulse waveform
Measuring: live bpm, waveform, and quality state.
ppg-js report screen: ANS balance, time-domain HRV with the measurement floor next to RMSSD
Report: ANS balance and HRV with the noise floor shown.
Try the 3-minute demo

FAQ

Does it work on desktop?

Yes, in Chrome, Edge, and Firefox, but only for development. Desktop webcams have no flash, so the signal is weak. For a real reading, use a phone.

iPhone or Android, does it matter?

Both work. iPhones need iOS 17 or later, and every iOS browser uses the same WebKit engine underneath. Android Chrome adds zoom control and Bluetooth chest-strap support for validation.

Is it accurate?

In the test suite, heart rate lands within 1 bpm on simulated signals, and a 3-minute real iPhone session reproduced 212 of 212 beats on replay. It has not been validated across a population or across skin tones.

Is it a medical device?

No. It is for research and personal curiosity. Do not use it for diagnosis, treatment, or any decision that needs a validated instrument.

Does my data leave the phone?

No. The library makes no network calls. Camera frames and all processing stay in the browser, and nothing is stored unless you turn on the debug log yourself.

How does it compare to a wearable?

A wearable measures all day with a purpose-built sensor; ppg-js gives a 3-minute spot check from a phone camera. You can record a Bluetooth chest strap alongside it to judge accuracy on your own device.

Can I use it with React or plain JavaScript?

Yes. The library renders no UI and works with any framework, or none. There is an npm package for bundlers and a single-file browser build you can drop in with a script tag.

What is the license?

MIT. Free for personal and commercial use.

How can I contribute?

Android recordings are the most valuable contribution right now, since testing so far is concentrated on one iPhone. See CONTRIBUTING.md in the repository.

Why does it sometimes refuse to show a number?

Because the signal was not clean enough to trust. The quality gate checks finger placement, light, motion, and pulse strength, and tells you which check failed so you can fix it.

Does it need HTTPS?

Yes. Browsers only allow camera access on HTTPS or localhost. The demo is hosted on this site at /ppg-js-demo; a copy also runs at ppg-js.vercel.app.

Credits

ppg-js is written and maintained by Sameer Sontakey, MIT licensed. See CONTRIBUTING.md to get involved, Android recordings are the most valuable current contribution.