npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sontakey/ppg-js

v0.3.0

Published

Camera PPG in the browser: heart rate, pulse-rate variability, respiration and signal quality from a fingertip on a phone camera. Zero dependencies, typed, no UI.

Downloads

358

Readme

ppg-js

Heart rate, pulse-rate variability, respiration and an honest signal-quality gate from a fingertip on a phone camera, in the browser. Zero dependencies, typed, no UI.

CI npm version license

ppg-js turns the camera (and the flash, where the browser exposes it) into a photoplethysmography sensor: cover the rear lens with a fingertip and it reports heart rate, inter-beat intervals, RMSSD/SDNN, breathing rate and a per-window quality verdict that says why a number is withheld rather than guessing. Every session is recorded and can be replayed offline through the exact same engine.

Live demo: https://ppg-js.vercel.app (needs a phone and HTTPS)

The demo app in examples/app is the three-minute HRV Spot Check shown above: start, measuring (screen recording from an iPhone), and the report (frequency domain, full page) rendered from a real recording in test/fixtures/.

Contents

Install

npm install @sontakey/ppg-js

Browser global (no bundler):

<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));
  document.querySelector('#start').onclick = () => ppg.start();
</script>

The global build exposes PPG.PPG, PPG.PPGMonitor, PPG.PpgEngine, PPG.hrv, PPG.dsp and PPG.sources.

Quick start

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)`);
});

ppg.addEventListener('respiration', (e) => console.log(`${e.detail.rateBpm.toFixed(0)} breaths/min`));
ppg.addEventListener('error', (e) => console.error(e.detail.error.code, e.detail.error.guidance));

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

After a session:

import { hrv } from '@sontakey/ppg-js';

const beats = ppg.getTachogram({ goodOnly: true, hrvOnly: true }).filter(b => b.valid);
const report = hrv.analyzeHRV(beats, { rmssdFloorMs: ppg.getMetrics().rmssdFloorMs });
console.log(report.timeDomain.rmssd, report.frequencyDomain.lfhf, report.stressIndex.sqrt);

What you get

| Field | Meaning | |---|---| | heartRate | bpm, median of the last accepted intervals, cross-checked against a spectral estimate; 0 unless quality.good | | ibi, rmssd, sdnn | most recent interval and variability over the last 60 s of accepted beats; 0 unless quality.good | | rmssdFloorMs, timingUncertaintyMs | the RMSSD the pipeline's own beat-timing noise would produce on a perfectly regular pulse, estimated per window (accurate to roughly ±50%) | | respiration | { rateBpm, confidence, basis } from breathing-driven modulation of beat timing, pulse amplitude and baseline (5.5-30 breaths/min). Confidence ≥ 0.6 when at least two estimates agree, 0.5 for a pair with one weak source, 0.3 for a single clear source (provisional), 0.4 while a recent firm rate is held. First estimate about 45 s after MEASURING | | quality | { good, reason, code }: the gate verdict, why, and a stable code (see the contract below). Its inputs sit beside it: acDcRatio, artifactRatio, ibiFftDisagree, clippedFraction, motion, templateSqi | | templateSqi | median correlation of each pulse with the window's mean pulse shape (0-1) | | acDcRatio, perfusionIndex | pulsatile amplitude over DC of the selected channel (bandpassed per-beat, not raw range) | | heartRateFFT, heartRateIBI, heartRateSource, harmonicCorrected | the two independent estimates and which one won | | fingerState, settleRemainingSec, guidanceMessage | state machine and coaching copy | | sampleRate, selectedChannel, redDc, greenDc, clippedFraction, motion | capture diagnostics |

Every beat event carries { time, ibiMs, valid, good, lowSnr, sqi, reason, heartRate }: valid is the interval-level artifact decision, good is whether the window it came from passed the quality gate, and lowSnr marks an interval next to a recovered weak beat (counted for heart rate, excluded from variability).

How it works

camera frame (requestVideoFrameCallback, captureTime when available)
  -> centre ROI, downscaled, channel means + clipped-pixel fraction
  -> PpgEngine.push({ t, r, g, b, clipped, motion })
       -> finger state machine every sample (relative presence, drift)
       -> every 5 s of signal time:
            interpolate the last 8 s onto one absolute 60 Hz grid
            zero-phase Butterworth bandpass (4th-order HP 0.6 Hz, 2nd-order LP 4.6 Hz)
            channel selection by pulsatile amplitude
            spectral HR (Hann, zero-padded, sub-harmonic guard) -> refractory prior
            peaks (adaptive threshold + 0.3 s vertex fit) -> absolute beat times
            missed-beat recovery (weak pulse inside a 2x gap, flagged lowSnr)
            template correlation per beat -> interval validation over the continuous stream
            (range, missed-beat multiples, jump vs the recent median; four consistent
             rejects re-seed the median so a noisy start or a real rate change is followed)
            RMSSD / SDNN over 60 s, respiration from beat modulation
            quality gate -> good / reason / code

The same PpgEngine runs the live camera path and tools/replay.js, so a recorded session replays to identical windows (test/live-parity.test.js asserts it).

Finger state machine

NO_FINGER --finger placed--> SETTLING --6 s, low drift, pulse amplitude ok--> MEASURING
    ^                            |                                              |
    |                       finger lifted                                  large drift
    +----------------------------+----------------------------------------------+

Exposure, white balance and focus are locked when MEASURING is first reached (the auto algorithms have converged on the finger by then) and released when the finger lifts.

API

new PPG(options?)

Typed-event facade, always headless. Events: ready, state, beat, metrics, quality, waveform (every frame), respiration, error. Methods: start(abortSignal?), stop(), destroy(), getMetrics(), getTachogram({ goodOnly, hrvOnly }), getSessionSummary(), exportDebugLog(), downloadDebugLog(prefix?), getConfig(); getters state, capabilities, engine.

new PPGMonitor(container, options?)

The callback-style class the facade wraps (container is accepted for compatibility and ignored; the core renders no UI). Callbacks: onReady, onState, onQualityUpdate (per window), onSignalUpdate (per frame), onFrame, onError. Same methods as PPG plus getDebugLog(), copyDebugLogToClipboard(), getEngine(), and the video element as a property for previews.

Options (all optional)

{
  signal: {                     // PpgEngine options (the common ones; see EngineOptions for all)
    windowSec: 5,               // analysis window, seconds of signal time
    hopSec: 5,                  // interval between windows
    contextSec: 3,              // extra history filtered with each window
    gridHz: 60,                 // uniform analysis grid
    hrSlewPerWindow: 8,         // max displayed HR change between consecutive good windows
    recoverMissedBeats: true,   // look for a weak beat inside a 2x gap (flagged lowSnr)
    cardiacBandLow: 0.75,       // Hz, heart-rate search band (45 bpm)
    cardiacBandHigh: 4.0,       // Hz (240 bpm)
    quality: { minAcDc: 0.002, maxArtifactRatio: 0.2, minIbiCount60s: 8,
               maxClippedFraction: 0.05, maxMotion: 1.5, minTemplateSqi: 0.6 },
    fingerState: { settleSec: 6, driftEnterFraction: 0.03, driftExitFraction: 0.06,
                   presence: { minRed: 60, minRedShare: 0.5 } },
    respiration: true, templateSqi: true
  },
  camera: {
    width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 60 },
    facingMode: 'environment',
    torch: true,                // request the flash when the device advertises it
    lockExposure: 'measuring',  // 'measuring' | 'start' | 'never'
    zoom: null                  // opt-in digital zoom (some Androids switch lens above 1)
  },
  roi: { widthFraction: 0.3, heightFraction: 0.3 },
  wakeLock: true,               // keep the screen on while measuring
  motion: false,                // DeviceMotion gate (asks permission on iOS)
  debug: { persistLastSession: false, includeUserAgent: false, sampleCap: 36000 },
  video: null                   // supply your own <video> element
}

Errors

start() rejects with a PPGError whose code is one of insecure_context, unsupported, permission_denied, no_camera, camera_busy, overconstrained, aborted, unknown, and whose guidance is a sentence you can show to the user.

PpgEngine (@sontakey/ppg-js/engine)

Feed it your own samples: engine.push({ t, r, g, b, clipped?, motion? }) returns { state, stateChanged, stateReason, present, waveform, window } (window is null except every hopSec). Use it for other capture paths (a WebView bridge, a wearable's raw PPG, a file).

DSP (@sontakey/ppg-js/dsp)

Pure functions: designBandpass, filtfilt, filtfiltPadded, resampleToGrid, computeFFT, calculateSNRFromPSD, detectPeaksDetailed, computeIBIs, crossCheckHeartRate, templateCorrelation, estimateRespiration, evaluateQuality, FingerStateMachine.

Signal quality contract

quality.good is true only when, in this order, all of these hold; reason and code name the first that fails:

  1. fingerState === 'MEASURING' (no_finger, settling)
  2. clipped-pixel fraction ≤ 5% (saturated)
  3. device motion ≤ threshold, when a motion source is attached (motion)
  4. pulsatile AC/DC ≥ 0.2% (weak_pulse)
  5. ≤ 20% of candidate intervals rejected in the last 60 s (irregular); intervals from before the beat reference settled after a rough start count as settling, not rejects
  6. ≥ 8 accepted intervals in the last 60 s (collecting)
  7. median template correlation ≥ 0.6 (morphology)
  8. interval-based and spectral heart rate agree within 25% (double_count, missed_beats, fft_disagree)

heartRate, rmssd and sdnn are 0 whenever good is false; heartRateRaw is always populated.

Signal quality indices

Every window carries metrics.sqi, the standard indices from the PPG quality literature plus one documented composite, so you can build your own acceptance rule or compare with other toolkits:

| Field | Definition | |---|---| | score | 0-1 composite: geometric mean of bounded sub-scores for template correlation, artifact ratio, pulse amplitude, SNR, clipping, motion and detector agreement (components lists each) | | skewness, kurtosis | of the bandpassed pulse (Elgendi 2016: skewness is the most informative single index; a clean pulse is positively skewed) | | perfusion | pulsatile amplitude / DC, percent | | relativePower, snrDb | cardiac-band power over total power, linear and in dB | | zeroCrossingRate | zero crossings per second of the bandpassed pulse (about 2 per beat when clean) | | templateCorrelation | median per-beat correlation with the window's mean beat (Orphanidou 2015) | | detectorAgreement | 1 − |HR from intervals − HR from spectrum| / HR from spectrum | | artifactRatio, clippedFraction, motion | the gate's inputs |

Each beat event and tachogram point also carries sqi, that beat's template correlation (the lower of its two peaks), so beats can be weighted or filtered individually. The composite is for ranking and display; the accept/reject decision remains quality.good.

HRV module

@sontakey/ppg-js/hrv works on any list of intervals (numbers in ms, or { ibiMs, t } with beat times so gaps are handled correctly):

  • timeDomain: mean RR/HR, SDNN, RMSSD, lnRMSSD, NN50/pNN50, min/max HR, triangular index, least-squares TINN, RR range
  • frequencyDomain: Welch PSD on a 4 Hz linear resample using real beat times, LF/HF (VLF only for ≥ 5 min), normalised units, HF-peak respiration, coherence (0.04-0.26 Hz peak share); refuses recordings with > 20% gaps. Pass respirationRateBpm (the engine's fusion estimate) and respirationInLf tells you when breathing under 9/min has moved RSA into the LF band, so LF/HF is breathing-driven rather than sympathetic
  • nonlinear: Poincaré SD1/SD2, sample entropy, DFA α1
  • stressIndex: Baevsky SI and its square root (the form Kubios reports)
  • ansIndices (experimental): PNS/SNS z-scores against cited references
  • ultraShortRmssd (last 60 s), lnRmssdBaseline (7-day rolling mean, CV, smallest-worthwhile-change band)
  • analyzeHRV: all of the above

What a camera measures is pulse-rate variability. At rest it tracks heart-rate variability closely; under posture or temperature change the two diverge. Treat the numbers accordingly.

Other sources

@sontakey/ppg-js/sources has BleHeartRateSource (Web Bluetooth Heart Rate Service: RR intervals from a Polar/Garmin chest strap, Chrome on Android and desktop) and ArraySource (recorded samples through the engine). Recording a strap alongside the camera is the cheapest way to validate this library on your own device.

Browser and device support

| Platform | Camera | Flash (torch) | Notes | |---|---|---|---| | iOS Safari 17+ and every iOS browser (WebKit) | yes | yes | exposureMode is not exposed on iPhone rear cameras; white balance is. The engine's late lock handles both. | | Android Chrome | yes | yes | Torch, exposure/white-balance/focus locks, zoom, Web Bluetooth. Lens selection by label is best-effort. | | Desktop Chrome/Edge/Firefox | yes | no | Works with a desk lamp for development; not a measurement setup. | | In-app browsers (Instagram, Facebook, some WebViews) | often no | no | start() rejects with unsupported. |

Requirements: HTTPS (or localhost), getUserMedia. requestVideoFrameCallback is used when available; requestAnimationFrame otherwise.

Tested against six real recordings from one iPhone (iOS 26, Safari and Chrome, 60 fps, in test/fixtures/: clean, muted, slow breathing, periodic 30 fps dips, a noisy onset) and against a simulator at 24/30/60 fps with dropped frames, saturation, motion, respiratory modulation and dominant dicrotic waves, and benchmarked against HeartPy and vital_sqi (docs/audit/BENCHMARK-2026-09.md). Android recordings are the most valuable thing you can contribute right now; see CONTRIBUTING.md.

Debug recording and replay

Every session records per-frame channel means, timestamps, camera capabilities and settings, applied constraints, dropped-frame counts, every peak and interval decision, and per-window quality records. getDebugLog() returns it; downloadDebugLog() saves it. Nothing is written to localStorage unless debug.persistLastSession is set.

npm run replay -- path/to/ppg-debug.json

replays the recording through the same engine the browser used and prints state transitions, per-window quality, the HR timeline with the RMSSD floor, every interval decision, respiration, and a comparison with the peaks the phone recorded live.

Accuracy and limitations

Measured on the simulator (what the tests enforce):

| Case | Result | |---|---| | Heart rate, 60-90 bpm, 24/30/60 fps | within 1 bpm | | RMSSD floor on a zero-variability pulse, fractional ROI means | 9-13 ms (bound 15 ms) | | Same with whole-count quantised means | 11-16 ms (bound 22 ms) | | 10% dropped frames | raises RMSSD by < 2 ms (bound 5 ms) | | Respiration at 6 breaths/min | 6.0 ± 1 |

And on the real recordings (what test/fixtures.test.js enforces):

| Case | Result | |---|---| | Live vs replay, 3 min session | 212 of 212 beats reproduced | | Time to first heart rate, clean start | 10 s after MEASURING (20 s after finger placement: 6 s settle, then 8 accepted beats) | | Time to first heart rate, noisy onset (junk intervals seeded the reference) | 15 s after MEASURING (25 s after placement); before 0.3.0 the session never recovered | | Periodic 30 fps dips (every other frame dropped for 20 s) | HR unchanged, RMSSD floor 7 -> 14 ms during the dip | | Breathing at 6.7/min | first estimate 40 s after MEASURING, then available in every window but one |

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.

Testing

npm test          # strict typecheck + all unit suites
npm run test:e2e  # real Chromium + fake camera fed from a fixture (after npm run build)

Contributing and license

See CONTRIBUTING.md. MIT, see LICENSE.

The audit that drove the 0.3 rebuild, with its measurements and references, is in docs/audit/AUDIT-2026-09.md.