Events
One channel for what happens, two reads for what moves at capture rate.
One channel
Everything the library reports goes through options.onEvent, called with a single object carrying a type. Two families: realtime:* says where the library is, measure:* carries what the server measures.
| Event | Payload | When |
|---|---|---|
realtime:attached | width, height | start() has attached the source and loaded what it needed; only the session is left to open. Re-emitted when the source changes. |
realtime:started | — | The session is open and capture is running. |
realtime:stopped | reason | The measurement stopped: requested once stop() has finished, aborted after abort() or destroy(), transport-closed when the link dropped. One per session. |
realtime:destroyed | — | The last event of an instance: nothing follows. |
realtime:error | message | Attaching or starting failed. |
token:error | reason | The token could not be obtained. |
measure:bvp | indexBase, bvp | One pulse-waveform point, at capture rate. |
measure:breathing | index, value | One breathing-waveform point, per measured torso frame. |
measure:variables | variables | The vitals, once a second. |
measure:presence | face, body, faceBox, bodyBox | A subject entered or left the frame. |
measure:stats | stats | Session counters, once a second. |
RealtimeMeasure.create({
onEvent: event => {
switch (event.type) {
case "measure:bvp": chart.push(event.indexBase, event.bvp); break
case "measure:breathing": breathing.push(event.index, event.value); break
case "measure:variables": panel.show(event.variables); break
case "measure:presence": hint.toggle(event.face); break
case "realtime:error": banner.show(event.message); break
}
}
})
What the vitals carry
measure:variables carries the same shape the server computes: a heart rate, a breathing rate and where it was read from, variability indices, a quality score, and two flags worth reading before you display anything.
| Field | What to do with it |
|---|---|
heartRate, breathingRate | The two numbers a user expects. |
breathingSource, breathingQuality, breathingWindowS | Where the breathing rate was read, how clearly it stood out, and over how many seconds. |
stars, sqi | Signal quality, as five steps and as a raw index. |
mature | false while the window is still too short. Say “measuring”, not a number. |
confident | false when quality does not clear the bar. Show it as provisional. |
sdnn, rmssd, pnn50, sd1, sd2, lfHf | Variability, null when quality did not allow computing it. |
heartRateIntervals, beats, rejected | The interval-based rate, and how many beats it kept and dropped. |
elapsed, healthIndex | Seconds measured, and the composite index when an age was provided. |
Nothing arrives at all until the window is long enough, so silence early on means not yet, never nothing to measure. And a variability field at null means no measurement — never zero, which would be the reading of a perfectly regular heart.
The breathing rate is read one of two ways, and breathingSource says which. motion is the rise and fall of the chest, measured on the torso frames — where breathing actually happens. bvp is the slow modulation the pulse wave carries, the fallback for a session that sends no torso. The two are not worth the same, so a display that shows the number without reading its source shows two different things under one label. breathingQuality, between 0 and 1, says how clearly the rhythm stood out of the signal, and breathingWindowS the interval it was read over — several are tried at each publication and the one the signal resolves best on is kept, so it changes during a measurement. Both are null when the source is bvp.
measure:bvp carries indexBase, which identifies the capture it belongs to. It cannot be inferred from arrival order: a frame with no face is never sent, so the sequence has gaps. Those gaps are the honest picture of a measurement — a face that left the frame, a moment of movement — and drawing them as gaps rather than closing the line is what keeps a reader from believing the signal was continuous.
measure:breathing is the same idea on the torso: one point per measured torso frame, index naming that frame and skipping the same way — a capture in which no torso was found sends nothing. Its value is the cumulative vertical displacement of the chest, in pixels of the crop, and only its variation means anything: the origin is wherever the first frame happened to sit, and it drifts slowly. Scale a chart to the minimum and maximum of what it shows, never to zero, and read a rhythm in it rather than a level. The rate itself is in the vitals.
Nothing is computed in the browser
The waveform points — pulse and breathing alike — arrive as the server computed them. Deriving your own heart rate or breathing rate from them would produce a second number, different and with no authority behind it.Reading the detection, rather than being told about it
What the detector sees moves at capture rate. Turning that into an event would put thirty messages a second on a channel that, in a mobile integration, crosses a WebView bridge — so it is a read instead:
function drawOverlay() {
const { source, face, body } = measure.getDetection()
if (face !== null) {
face.box // { left, top, width, height } in source pixels — the crop actually sent
face.normalizedBox // the same, between 0 and 1: multiply by your display size
face.landmarks // 478 points, { x, y, z }, normalized
}
// … draw, then:
requestAnimationFrame(drawOverlay)
}
Call it from your own render loop. The box comes in both frames of reference because it is eight numbers; the points come normalized only, because there are up to fourteen hundred of them and source ({ width, height }) is all you need to place them in pixels. The torso box is not the bounding box of its points: it is built on the shoulders and hips alone — a raised wrist would otherwise stretch it across the whole image — while landmarks gives you all thirty-three.
The detector runs between start() and whichever gesture ends the measurement — stop(), abort() or destroy(): before a session, face and body are null.
measure:presence still tells you when a subject enters or leaves, which is the part you would otherwise have to poll for, and it carries faceBox/bodyBox — the box at that instant, in both frames of reference. It is not tracked afterwards: that is what the read above is for.
Counters
getStats() returns cumulative counters — frames captured and dropped, detections, bytes sent per stream, bytes the compression saved, the write queue and its peak, and the round-trip latency of a frame to its waveform point. The same object arrives as measure:stats once a second, so you rarely need to call it yourself.
Cumulative, never a rate: a rate needs a window, and that window is a display decision. Subtract two snapshots if you want throughput.
Two of them are easy to misread. savedBytes is the sum of faceSavedBytes and bodySavedBytes, so counting all three counts the saving twice. And latencySamples says how many round trips the three latency figures rest on: zero means nothing has come back yet, which a display has to tell apart from a latency of zero.
A queue that does not come back down
queued and queuedPeak are the early sign that capture is producing faster than the link absorbs — visible well before latency shows it. Lowering fps is the lever.