Web HR Monitor: Heart Rate and HRV in the Browser

I’ve been reading about HRV (Heart Rate Variability) for a while and wanted a way to record it on my computer and actually understand the underlying calculations, rather than just consume a number from a proprietary app. I also wanted an excuse to finally play with the Web Bluetooth API, which lets a browser connect directly to Bluetooth LE devices with no native app or server involved. The result is web-hr-monitor, a small app that runs entirely in the browser — no backend, no install.

The live version is at guyru.github.io/web-hr-monitor. Open it in Chrome or Edge, click Connect to HR Monitor, pair your BLE heart rate monitor, and you get a live readout along with running average, maximum, minimum, and a heart rate distribution histogram for the session.

HRV Analysis

The more interesting part is the HRV analysis. After connecting, you can run a 2-minute test that collects RR intervals — the time between successive heartbeats — and computes two standard metrics:

  • RMSSD (Root Mean Square of Successive Differences): measures short-term variability.
  • SDNN (Standard Deviation of NN intervals): measures overall variability.

I did most of my testing with a Polar H7, which worked well.

screenshot of HRV results panel with RMSSD/SDNN values

Synthesized RR Intervals

While testing I ran into something worth knowing: not all heart rate monitors report real RR intervals. Some devices don’t actually measure the time between beats — instead they synthesize a value as 60000 / HR. The result is mathematically consistent with the reported heart rate but has no actual beat-to-beat variability, making HRV analysis meaningless.

I discovered this when I switched from the Polar H7 to a Decathlon ANT+/Bluetooth heart rate monitor I had lying around. The app detects this automatically: if the reported RR intervals consistently fall within 1 ms of 60000 / HR, it warns you that the data is likely synthesized and HRV results are unreliable.

Browser Support

The Web Bluetooth API works in Chrome and Edge. Firefox and Safari don’t support it.

On Linux (and older Windows versions) you need to enable an experimental flag before it works:

  1. Open chrome://flags#enable-experimental-web-platform-features
  2. Enable the flag and restart the browser

On Windows 10/11 and Android it works out of the box. The app also requires an HTTPS connection — the GitHub Pages deployment covers that, and http://localhost works fine for local development.

Configuring LDAC Quality in PipeWire

You can set the LDAC quality to High, Standard, or Mobile, corresponding to 990/660/330 kbps. You can do it either statically or dynamically.

Static Configuration

Place the following configuration in ~/.config/wireplumber/wireplumber.conf.d/10-bluez.conf:

monitor.bluez.rules = [
  {
    matches = [
      {
        ## This matches all Bluetooth devices.
        device.name = "~bluez_card.*"
      }
    ]
    actions = {
      update-props = {
        bluez5.a2dp.ldac.quality = "sq"
      }
    }
  }
]

The value of quality can be set to either hq, sq, mq, or auto.

You can also change the match fragment to match only a specific device. Use the following command to list all currently available devices:

$ pw-cli ls | grep device.name

After changing the configuration, you’ll have to restart WirePlumber:

$ systemctl --user restart wireplumber

Dynamic Configuration

This method is less user-friendly. The first step is to find the id of the relevant output node. You can do this by examining the output of pw-cli ls or wpctl status. Make sure you pick the id of the corresponding node and not the device. Next, use pw-cli set-param to set the quality, for example:

$ pw-cli set-param 93 Props '{quality=0}'
Object: size 32, type Spa:Pod:Object:Param:Props (262146), id Spa:Enum:ParamId:Props (2)
  Prop: key Spa:Pod:Object:Param:Props:quality (269), flags 00000000
    Int 0

Here, 93 is our node id, and 0 corresponds to hq quality. Other possible values are -1 for auto, 0 for hq, 1 for sq, and 2 for mq.

Empirically Verifying Bitrate

You can deduce the actual bitrate by sniffing the Bluetooth traffic and analyzing the capture in Wireshark.

$ sudo btmon -w btsnoop.log
$ wireshark btsnoop.log

In Wireshark, go to Statistics -> Capture File Properties. There, you can see the average bits/s and compare it to the bitrate of the expected quality setting.

How to Display Battery Percentage for Bluetooth Headphones in GNOME

By default, the Power tab in GNOME’s Settings does not show the battery percentage for Bluetooth headphones like the Sony WH-1000XM3. However, you can enable this feature by activating the DBUS interface of BlueZ, the Linux Bluetooth protocol stack. The DBUS interface is hidden behind the --experimental flag for the BlueZ service. To enable it, follow these steps:

  1. Create an override file for the bluetooth service:
$ sudo systemctl edit bluetooth

This command will create the file /etc/systemd/system/bluetooth.service.d/override.conf.

  1. Add the following lines to the file:
[Service]
ExecStart=
ExecStart=/usr/libexec/bluetooth/bluetoothd --experimental

Note that both ExecStart= lines are required.

  1. Restart the Bluetooth service.
Battery percentage for Sony WH-1000XM3 under Settings->Power

Downgrade PipeWire 0.3.39 to 0.3.38

PipeWire 0.3.39 on Debian deprecates pipewire-media-session in favor of WirePlumber. The main issue I found with the new version is that it doesn’t support Bluetooth profile autoswitching, as it is unimplemented in WirePlumber. The best solution until this is resolved is simply to hold back the upgrade to 0.3.39. If you already upgraded, downgrading is a bit of a hassle.

The first step is to retrieve all the necessary packages in the last working version, which is 0.3.38-2.

$ cd `mktemp -d`
$ debsnap -a amd64 --binary -d . gstreamer1.0-pipewire 0.3.38-2
$ debsnap -a amd64 --binary -d . libpipewire-0.3-0 0.3.38-2
$ debsnap -a all --binary -d . libpipewire-0.3-common 0.3.38-2
$ debsnap -a amd64 --binary -d . libpipewire-0.3-modules 0.3.38-2
$ debsnap -a amd64 --binary -d . pipewire-audio-client-libraries 0.3.38-2
$ debsnap -a amd64 --binary -d . pipewire-bin 0.3.38-2
$ debsnap -a amd64 --binary -d . pipewire-pulse 0.3.38-2
$ debsnap -a amd64 --binary -d . pipewire 0.3.38-2
$ debsnap -a amd64 --binary -d . pipewire-media-session 0.3.38-2
$ debsnap -a amd64 --binary -d . libspa-0.2-modules 0.3.38-2
$ debsnap -a amd64 --binary -d . libspa-0.2-bluetooth 0.3.38-2

Install all the retrieved packages and mark some of them back as automatically installed.

$ sudo apt install ./*.deb
$ sudo apt-mark auto gstreamer1.0-pipewire libpipewire-0.3-0 libpipewire-0.3-common libpipewire-0.3-modules pipewire-bin pipewire libspa-0.2-modules

Mark pipewire-media-session as held so it won’t accidentally get removed again.

$ sudo apt-mark hold pipewire-media-session

Finally, restart PipeWire.

$ systemctl --user daemon-reload
$ systemctl --user restart pipewire pipewire-pulse