Have you ever wondered if your "flow state" is actually just high-functioning anxiety? Or why your recovery feels sluggish after a late-night coding session? The answer lies in your Heart Rate Variability (HRV).
In this tutorial, we are going to build a high-performance stress monitor using the Web Bluetooth API, RxJS, and Signal Processing techniques. We'll be pulling raw RR-intervals (the time between heartbeats) directly from a Polar H10 chest strap and performing a Fast Fourier Transform (FFT) in the browser to calculate the LF/HF ratioโthe gold standard for assessing Autonomic Nervous System (ANS) balance.
Whether you're into biohacking, real-time data visualization, or just want to see how the Web Bluetooth API can turn a browser into a medical-grade dashboard, youโre in the right place.
Pro Tip: If you're looking for more production-ready patterns on integrating wearables with enterprise cloud architectures, definitely check out the deep dives over at WellAlly Tech Blog.
The Architecture: From Heartbeat to Insight
Before we dive into the code, let's look at how the data flows from your chest strap to a real-time stress graph. We use RxJS to handle the asynchronous stream of Bluetooth packets and D3.js to render the results.
graph TD
A[Polar H10 Chest Strap] -->|Bluetooth LE| B[Web Bluetooth API]
B -->|Raw Buffer| C[RxJS Data Stream]
C -->|Parse RR Intervals| D[Signal Processing Pipe]
D -->|Resampling & FFT| E[LF/HF Ratio Calculation]
E -->|Stress Metric| F[D3.js Real-time Dashboard]
F -->|Visual Feedback| G[User]
Prerequisites
To follow along, you'll need:
- A Polar H10 or any heart rate monitor that supports the standard Heart Rate GATT service (RR-Intervals).
- A browser with Web Bluetooth support (Chrome, Edge, or Opera).
- Basic knowledge of RxJS and TypeScript.
Step 1: Connecting to the Heart ๐
The Web Bluetooth API allows us to connect to GATT (Generic Attribute Profile) devices. The Heart Rate service UUID is standardized as 0x180D.
async function connectToHeartRateMonitor() {
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['heart_rate'] }],
optionalServices: ['battery_service']
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService('heart_rate');
const characteristic = await service.getCharacteristic('heart_rate_measurement');
return characteristic;
}
Step 2: Streaming Data with RxJS ๐
The data coming from the sensor is a DataView. We need to parse it to extract the RR-intervals (stored in units of 1/1024 seconds for Polar). RxJS is perfect here because heartbeats are discrete events in time.
import { fromEvent, map, scan } from 'rxjs';
function createHeartRateStream(characteristic) {
return fromEvent(characteristic, 'characteristicvaluechanged').pipe(
map((event: any) => {
const value = event.target.value;
const flags = value.getUint8(0);
const rate16Bits = flags & 0x1;
const hasRR = flags & 0x10;
let offset = rate16Bits ? 3 : 2;
const rrIntervals = [];
if (hasRR) {
for (; offset + 1 < value.byteLength; offset += 2) {
// Convert to milliseconds
rrIntervals.push((value.getUint16(offset, true) / 1024) * 1000);
}
}
return rrIntervals;
})
);
}
Step 3: Frequency Domain Analysis (The Math bit) ๐งฎ
To find the "Stress Ratio," we analyze the frequencies within the HRV.
- Low Frequency (LF): 0.04 - 0.15 Hz (Sympathetic + Parasympathetic activity)
- High Frequency (HF): 0.15 - 0.40 Hz (Parasympathetic/Vagal tone activity)
- LF/HF Ratio: High ratio = High Stress.
Since RR-intervals are unevenly spaced, we must resample the data (usually at 4Hz) before applying a Fast Fourier Transform (FFT).
// A simplified conceptual approach to LF/HF calculation
import { fft } from 'some-signal-processing-lib';
function calculateStressMetrics(rrIntervals: number[]) {
// 1. Resample RR-intervals to a uniform time grid
const resampled = cubicSplineInterpolation(rrIntervals);
// 2. Apply Windowing (e.g., Hann Window)
const windowed = applyHannWindow(resampled);
// 3. Run FFT
const spectrum = fft(windowed);
// 4. Integrate Power in LF and HF bands
const lfPower = integrate(spectrum, 0.04, 0.15);
const hfPower = integrate(spectrum, 0.15, 0.40);
return lfPower / hfPower; // The Stress Ratio!
}
Step 4: Visualizing with D3.js ๐
We want to see our stress levels change in real-time. Using D3.js, we can create a rolling line chart that updates every time a new RR-interval is processed.
const margin = { top: 20, right: 20, bottom: 30, left: 50 };
const width = 600 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
const svg = d3.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.transform(`translate(${margin.left},${margin.top})`);
// Update function called within the RxJS subscribe block
function updateChart(data) {
const x = d3.scaleTime().domain(d3.extent(data, d => d.time)).range([0, width]);
const y = d3.scaleLinear().domain([0, d3.max(data, d => d.value)]).range([height, 0]);
svg.selectAll(".line")
.data([data])
.join("path")
.attr("class", "line")
.attr("d", d3.line()
.x(d => x(d.time))
.y(d => y(d.value))
);
}
Advanced Patterns & Deep Dives ๐
Building a prototype is easy, but making it production-ready involves handling Bluetooth disconnections, signal noise filtering, and power-efficient data processing.
If you are interested in medical-grade signal processing or how to scale this architecture to thousands of concurrent users using WebSockets and Cloud Workers, I highly recommend reading the engineering articles at WellAlly Tech Blog. They have some fantastic resources on the intersection of healthcare and cutting-edge web tech.
Conclusion
The Web Bluetooth API has opened up a world where the browser is no longer just a document viewerโit's a powerful interface for the physical world. By combining it with RxJS for stream management and D3.js for visualization, we can build tools that were previously restricted to expensive proprietary software.
Now it's your turn:
- Try implementing a "Focus Score" based on the LF/HF ratio.
- Add a vibration alert via the
navigator.vibrateAPI when stress gets too high. - Share your results in the comments below! ๐ฅ
Happy hacking! ๐
Top comments (0)