HRV.m#

HRV.m contains the Heart Rate Variability measurement. These file contains the peak detection algorithm, time domain HRV feature and the frequency domain HRV feature.

Preprocessing#

Read File#

[audio, fs] = audioread('audio/240729_sleep_modified.wav');
downsampling_const = 10;
audio = downsample(audio, downsampling_const);
fs = fs/downsampling_const;
if size(audio, 2) == 2
    audio = mean(audio, 2);
end

audio = audio / max(abs(audio));

The code block aboves reads the specified file in audioread, and then perform these following operation:

  • Downsample by downsampling_const

  • Average out both signal (is not relevant here, since our current microphone records mono)

  • Normalize sample to make inspection easier

Filtering#

max_time = length(audio)/fs;
time = 0:1/fs:max_time - 1/fs;

cutoffFreq = 60;
segmentTimeWindow = 5;
secondOverlap = 1;

[timeVec, filtered_audio] = butterworthFilter(audio, cutoffFreq, fs, max_time);
filtered_audio = medfilt1(filtered_audio,25);
filtered_audio = filtered_audio / max(abs(filtered_audio));

The above block of code does two things, mainly creating the time vector for plotting and filter the audio.

Filter is done by cutting off certain frequency, specified by cutoffFreq and the butterworthFilter function, change these variable as suitable. Median filtering is also done to remove transient peaks

Peaks detection#

Peak detection works in three steps:

  • Envelope of the whole function (using hilbert transform)

  • Create a local maximum for peak detection amplitude threshold

  • Peak detection loop

After that the code calculates the BPM, as now we have all the data we need to calculate BPM

Envelope#

% Detect peaks
envelope = abs(hilbert(filtered_audio));
edge_compensation = 10;
envelope = envelope(1:length(envelope)-edge_compensation);

These block of code makes an envelope function of your original signal (using hilbert transform). Notice the edge_compensation, this variable is used to account for the edge distortion of hilbert transform, see this excellent explanation for further explanation.

local_amplitude_window = 10;
peak_window_size = local_amplitude_window * fs; 
peak_step_size = local_amplitude_window * fs; %decided that no overlap is better
all_peaks = [];
all_locs = [];
num_peak_windows = floor((length(envelope) - peak_window_size) / peak_step_size) + 1;

%loop
for j = 1:num_peak_windows
    peak_start_idx = (j-1) * peak_step_size + 1;
    peak_end_idx = peak_start_idx + peak_window_size - 1;
    if peak_end_idx > length(envelope)
        peak_end_idx = length(envelope);
    end
    envelope_window = envelope(peak_start_idx:peak_end_idx);
    local_max = max(envelope_window);
    [window_peaks, window_locs] = findpeaks(envelope_window, MinPeakHeight = local_max * 0.6, MinPeakDistance = fs * 0.5);
    all_peaks = [all_peaks; window_peaks];
    all_locs = [all_locs; window_locs + peak_start_idx - 1];
end

The block of code above initialize the vector (although one improvement you can do is to make a big vector with predefine size to speed up calculation) and create local amplitude for peak detection amplitude threshold. In long recording session, we can’t really rely on one max amplitude, which is why these algorithm was implemented. One thing I wanted to do was to make the local amplitude change, say if it doesn’t detect any peaks during for 2 seconds, you know we need to change the local amplitude. Right now the code uses a set amount of time defined in local_amplitude_window.

The rest of the code from here is a simple bpm calculation and plotting

HRV Time Domain Calculation#

s1s1_intervals = diff(all_locs);
diff_s1s1_intervals = diff(s1s1_intervals);
mean_s1s1 = mean(s1s1_intervals);
SD1 = sqrt(var(s1s1_intervals(1:end-1) - s1s1_intervals(2:end)) / 2);
SD2 = sqrt(2 * var(s1s1_intervals) - SD1^2);
SDNN = std(s1s1_intervals);

NN50 = sum(abs(diff_s1s1_intervals) > 50/1000);
pNN50 = NN50 / length(s1s1_intervals) * 100;

% Calculate RMSSD
RMSSD = sqrt(mean(diff_s1s1_intervals .^ 2));

% Calculate Triangular Index (Histogram Width)
[hist_counts, hist_edges] = histcounts(s1s1_intervals, 'BinWidth', 1); % Adjust BinWidth as needed
Triangular_Index = length(s1s1_intervals) / max(hist_counts);

% Calculate HRV Index
HRV_Index = length(s1s1_intervals) / max(hist_counts);

% For poincare plot
% Calculate area of the ellipse S
S = pi * SD1 * SD2;

% Calculate the ratio SD1/SD2
SD1_SD2_ratio = SD1 / SD2;

% Display results
fprintf('SDNN: %.2f s\n', SDNN);
fprintf('NN50: %d\n', NN50);
fprintf('pNN50: %.2f%%\n', pNN50);
fprintf('RMSSD: %.2f s\n', RMSSD);
fprintf('Triangular Index: %.2f\n', Triangular_Index);
fprintf('HRV Index: %.2f\n\n', HRV_Index);

This MATLAB script calculates several Heart Rate Variability (HRV) metrics from S1-S1 intervals (usually people use RR peak, an ecg measurement), which represent the time between successive S1 heart sounds. The script also generates several visualization including

  • The time series plot of S1-S1 intervals

  • Histogram of the interval distribution with a fitted normal curve

  • Poincaré plot

Then from S1-S1 you can basically calculate whatever metric you want. The hard part is actually the definition(ie. what does this metric even mean?), so here is the list of calculated metrics and their definition:

Note

Table 1 HRV Time Variable#

Term

Definition

S1-S1 Intervals (s1s1_intervals)

The time differences between consecutive S1 heart sounds

Difference in S1-S1 Intervals (diff_s1s1_intervals)

The difference between consecutive S1-S1 intervals.

Mean S1-S1 Interval (mean_s1s1)

The average duration of the S1-S1 intervals

Standard Deviation 1 (SD1) (SD1)

Reflects the short-term variability of the S1-S1 intervals

Standard Deviation 2 (SD2) (SD2)

Reflects the long-term variability of the S1-S1 intervals

Standard Deviation of Normal-to-Normal Intervals (SDNN) (SDNN)

A global HRV measure representing the overall variability

NN50 (NN50)

The number of successive interval differences greater than 50 ms

pNN50 (pNN50)

The proportion of NN50 to the total number of intervals, expressed as a percentage

Root Mean Square of Successive Differences (RMSSD) (RMSSD)

A measure of short-term HRV

Triangular Index (Triangular_Index)

The total number of S1-S1 intervals divided by the height of the histogram’s peak

HRV Index (HRV_Index)

A similar measure to the Triangular Index, representing the variability distribution

Then the rest of the code is for plotting

% Time vector for S1-S1 intervals (you can create one if not available)
S1_time = cumsum(s1s1_intervals);

% 1. Time Series Plot of S1-S1 Intervals
figure;
plot(S1_time, s1s1_intervals, '-o');
xlabel('Time (s)');
ylabel('S1-S1 Intervals (s)');
title('Time Series of S1-S1 Intervals');
grid on;

% Histogram with fitted normal distribution
figure;
histfit(s1s1_intervals * 1000, 50); % Convert to milliseconds for display
hold on;
xlabel('S1-S1 Intervals (ms)');
ylabel('Frequency')
title('Distribution of S1-S1 Intervals');
legend('Histogram', 'Fitted Normal Distribution')
grid on


time_color = linspace(min(S1_time(1:end-1)), max(S1_time(1:end-1)), length(s1s1_intervals) - 1);

figure;
scatter(s1s1_intervals(1:end-1)-mean_s1s1, s1s1_intervals(2:end)-mean_s1s1, 10, time_color, 'filled');
xlabel('S1-S1 Interval (n) (s)');
ylabel('S1-S1 Interval (n+1) (s)');
title('Poincare Plot');
grid on;
cbar = colorbar;
colormap(parula);
caxis([min(time_color), max(time_color)]);
ylabel(cbar, 'Time (s)');

% Ellipse fitting for Poincare Plot
hold on;
theta = linspace(0, 2*pi, 100);
theta_angle = atan2(SD2, SD2);
ellipse_x = SD1 * cos(theta);
ellipse_y = SD2 * sin(theta);
R = [cos(theta_angle), -sin(theta_angle); sin(theta_angle), cos(theta_angle)];
ellipse_coords = R * [ellipse_x; ellipse_y];
% ellipse_coords = [ellipse_x'; ellipse_y'];
plot(ellipse_coords(1,:), ellipse_coords(2,:), 'k', LineWidth=2);
show_std_diff = 10;
xlim([- show_std_diff*SD1, show_std_diff*SD1]);
ylim([- show_std_diff*SD2, show_std_diff*SD2]);
% Add arrows for SD1 and SD2
line_length = 10;
quiver(0, 0, line_length*SD1*cos(theta_angle), line_length*SD1*sin(theta_angle), 0, 'r', LineWidth= 1, MaxHeadSize=0.1); % SD1 line
quiver(0, 0, line_length*-SD1*cos(theta_angle), line_length*-SD1*sin(theta_angle), 0, 'r', LineWidth=1, MaxHeadSize=0); % SD1 opposite line

quiver(0, 0, line_length*SD2*cos(theta_angle+pi/2), line_length*SD2*sin(theta_angle+pi/2), 0, 'k', LineWidth=1, MaxHeadSize=0.1); % SD2 line
quiver(0, 0, line_length*-SD2*cos(theta_angle+pi/2), line_length*-SD2*sin(theta_angle+pi/2), 0, 'k', LineWidth=1, MaxHeadSize=0); % SD2 opposite line

% Add lines for SD1 and SD2
legend('S1-S1', '95% interval', 'SD1', '', 'SD2')
hold off;

HRV Frequency Domain Calculation#

Fs = 4; % Sampling frequency for pwelch

% Create a time vector for interpolation
time_vector = all_locs(1):1/Fs:all_locs(end);

% Perform cubic spline interpolation to resample the S1_S1 intervals
s1s1_interpolated = interp1(all_locs(1:end-1), s1s1_intervals, time_vector, 'spline');

% Detrend the signal (optional)
s1s1_detrended = detrend(s1s1_interpolated);

[PSD, f] = pwelch(s1s1_detrended, [], [], [], Fs);

% Define the frequency bands
VLF_band = [0.0033 0.04]; % VLF band (0.0033-0.04 Hz)
LF_band = [0.04 0.15];    % LF band (0.04-0.15 Hz)
HF_band = [0.15 0.4];     % HF band (0.15-0.4 Hz)

% Calculate the power in each band using the bandpower function
VLF_power = bandpower(PSD, f, VLF_band, 'psd');
LF_power = bandpower(PSD, f, LF_band, 'psd');
HF_power = bandpower(PSD, f, HF_band, 'psd');

Finally it’s the frequency domain calculation. For frequency domain, we mainly look at 3 band:

  1. Very Low Frequency (VLF): 0.0033 to 0.04 Hz

  2. Low Frequency (LF): 0.04 to 0.15 Hz

  3. High Frequency (HF): 0.15 to 0.4 Hz As a brief summary of each component, here is what each frequency band tries to quantify:

Note

Term

Definition

VLF

Reflects slow regulatory mechanisms, possibly related to sympathetic activity, but its interpretation is complex

LF

Reflects a combination of sympathetic and parasympathetic influences

HF

Reflects parasympathetic activity, closely tied to respiratory influences

In the literature, people actually isn’t sure about the legitimacy of VLF, but I included it in the code for completion sake anyway. The rest of the code is just plotting

% Plot the PSD in s²/Hz
figure;
plot(f, PSD, 'k'); % Plot in linear scale with black line
xlabel('Frequency (Hz)');
ylabel('Power/Frequency (s^2/Hz)');
title('Power Spectral Density of S1-S1 Intervals');
grid on;
hold on;

% Shade VLF band under the curve
area(f(f >= VLF_band(1) & f <= VLF_band(2)), PSD(f >= VLF_band(1) & f <= VLF_band(2)), ...
    'FaceColor', 'green', 'FaceAlpha', 0.3);

% Shade LF band under the curve
area(f(f >= LF_band(1) & f <= LF_band(2)), PSD(f >= LF_band(1) & f <= LF_band(2)), ...
    'FaceColor', 'blue', 'FaceAlpha', 0.3);

% Shade HF band under the curve
area(f(f >= HF_band(1) & f <= HF_band(2)), PSD(f >= HF_band(1) & f <= HF_band(2)), ...
    'FaceColor', 'red', 'FaceAlpha', 0.3);

legend('PSD', 'VLF Band', 'LF Band', 'HF Band');
xlim([0 0.5])
% Print the band powers
fprintf('VLF Power: %f\n', VLF_power);
fprintf('LF Power: %f\n', LF_power);
fprintf('HF Power: %f\n', HF_power);

Congratulations, this is the most jam packed matlab script in this project, thanks for reading this :D

Full Code#

clear; close all; clc;
[audio, fs] = audioread('audio/240729_sleep_modified.wav');
downsampling_const = 10;
audio = downsample(audio, downsampling_const);
fs = fs/downsampling_const;
if size(audio, 2) == 2
    audio = mean(audio, 2);
end

max_time = length(audio)/fs;
time = 0:1/fs:max_time - 1/fs;

cutoffFreq = 60;
segmentTimeWindow = 5;
secondOverlap = 1;

[timeVec, filtered_audio] = butterworthFilter(audio, cutoffFreq, fs, max_time);
filtered_audio = medfilt1(filtered_audio,25);
filtered_audio = filtered_audio / max(abs(filtered_audio));

% Detect peaks
envelope = abs(hilbert(filtered_audio));
edge_compensation = 10;
envelope = envelope(1:length(envelope)-edge_compensation);

local_amplitude_window = 10;
peak_window_size = local_amplitude_window * fs; 
peak_step_size = local_amplitude_window * fs; %decided that no overlap is better
all_peaks = [];
all_locs = [];
num_peak_windows = floor((length(envelope) - peak_window_size) / peak_step_size) + 1;


for j = 1:num_peak_windows
    peak_start_idx = (j-1) * peak_step_size + 1;
    peak_end_idx = peak_start_idx + peak_window_size - 1;
    if peak_end_idx > length(envelope)
        peak_end_idx = length(envelope);
    end
    envelope_window = envelope(peak_start_idx:peak_end_idx);
    local_max = max(envelope_window);
    [window_peaks, window_locs] = findpeaks(envelope_window, MinPeakHeight = local_max * 0.6, MinPeakDistance = fs * 0.5);
    all_peaks = [all_peaks; window_peaks];
    all_locs = [all_locs; window_locs + peak_start_idx - 1];
end

window_size = 10 * fs; 
step_size = 0.5 * fs;
num_windows = floor((length(audio) - window_size) / step_size) + 1;

time_stamps = zeros(num_windows, 1);
bpm_values = zeros(num_windows, 1);

for i = 1:num_windows
    start_idx = (i-1) * step_size + 1;
    end_idx = start_idx + window_size - 1;
    window_peaks = all_locs(all_locs >= start_idx & all_locs <= end_idx);
    
    if length(window_peaks) > 1
        intervals = diff(window_peaks) / fs;
        avg_interval = mean(intervals);
        bpm_values(i) = 60 / avg_interval;
    else
        bpm_values(i) = NaN; % Not enough peaks to calculate BPM
    end
    
    time_stamps(i) = (start_idx + end_idx) / (2 * fs); % Midpoint of the window
end

%fig to check whether algorithm works or not
all_locs = all_locs/fs;
% Plot
figure;
subplot(211)
plot(time_stamps, bpm_values, '-o');
xlabel('Time (seconds)');
ylabel('BPM');
title('Time vs Heartbeat Per Minute');
ylim([40 160])
zoom xon
% xlim([0 300])
grid on;
% xlim([0,120])
% ylim([40, 80])
subplot(212)
plot(time, audio)
xlabel('Time (seconds');
ylabel('Amplitude')
xlim([0,120])
ylim([-1,1])
zoom xon
% plot(,filtered_audio)

fprintf('Heartbeat per minute: %.2f BPM\n', mean(bpm_values, 'omitnan'));
figure;
subplot(311);
plot(time, audio);
title('Original Audio');
zoom xon
% xlim([320 440])
subplot(312);
plot(timeVec, filtered_audio);
title('Filtered Audio');
% xlim([320 440])
subplot(313);
plot(timeVec(1:length(timeVec)-edge_compensation), envelope);
hold on;
plot(all_locs, all_peaks, 'ro');
zoom xon
% xlim([0 120])
% xlim([320 440])
title('Envelope with Detected Peaks');

function [timeVec, filteredSound] = butterworthFilter(audioVec, cutoffFreq, originalFs, max_time)
    % Parameters:
    % audioVec           - Input audio vector
    % cutoffFreq         - Cutoff frequency for low-pass filter
    % downsamplingConst  - Downsampling factor
    % segmentTimeWindow  - Length of each segment time window in seconds
    % secondOverlap      - Overlap time in seconds

    % Sampling frequency of the original signal (assumed)
    % New sampling frequency after downsampling
    
    % Design the Butterworth filter
    [b, a] = butter(5, cutoffFreq / (originalFs / 2), 'low');
    
    % Apply the Butterworth filter
    filteredSound = filtfilt(b, a, audioVec);
    fprintf("filtered sound size: %s\n", mat2str(size(filteredSound)));
    % Downsample the filtered signal
    timeVec = linspace(0, max_time,originalFs*max_time);
end
%% 
s1s1_intervals = diff(all_locs);
diff_s1s1_intervals = diff(s1s1_intervals);
mean_s1s1 = mean(s1s1_intervals);
SD1 = sqrt(var(s1s1_intervals(1:end-1) - s1s1_intervals(2:end)) / 2);
SD2 = sqrt(2 * var(s1s1_intervals) - SD1^2);
SDNN = std(s1s1_intervals);

NN50 = sum(abs(diff_s1s1_intervals) > 50/1000);
pNN50 = NN50 / length(s1s1_intervals) * 100;

% Calculate RMSSD
RMSSD = sqrt(mean(diff_s1s1_intervals .^ 2));

% Calculate Triangular Index (Histogram Width)
[hist_counts, hist_edges] = histcounts(s1s1_intervals, 'BinWidth', 1); % Adjust BinWidth as needed
Triangular_Index = length(s1s1_intervals) / max(hist_counts);

% Calculate HRV Index
HRV_Index = length(s1s1_intervals) / max(hist_counts);

% For poincare plot
% Calculate area of the ellipse S
S = pi * SD1 * SD2;

% Calculate the ratio SD1/SD2
SD1_SD2_ratio = SD1 / SD2;

% Display results
fprintf('SDNN: %.2f s\n', SDNN);
fprintf('NN50: %d\n', NN50);
fprintf('pNN50: %.2f%%\n', pNN50);
fprintf('RMSSD: %.2f s\n', RMSSD);
fprintf('Triangular Index: %.2f\n', Triangular_Index);
fprintf('HRV Index: %.2f\n\n', HRV_Index);

% Time vector for S1-S1 intervals (you can create one if not available)
S1_time = cumsum(s1s1_intervals);

% 1. Time Series Plot of S1-S1 Intervals
figure;
plot(S1_time, s1s1_intervals, '-o');
xlabel('Time (s)');
ylabel('S1-S1 Intervals (s)');
title('Time Series of S1-S1 Intervals');
grid on;

% Histogram with fitted normal distribution
figure;
histfit(s1s1_intervals * 1000, 50); % Convert to milliseconds for display
hold on;
xlabel('S1-S1 Intervals (ms)');
ylabel('Frequency')
title('Distribution of S1-S1 Intervals');
legend('Histogram', 'Fitted Normal Distribution')
grid on


time_color = linspace(min(S1_time(1:end-1)), max(S1_time(1:end-1)), length(s1s1_intervals) - 1);

figure;
scatter(s1s1_intervals(1:end-1)-mean_s1s1, s1s1_intervals(2:end)-mean_s1s1, 10, time_color, 'filled');
xlabel('S1-S1 Interval (n) (s)');
ylabel('S1-S1 Interval (n+1) (s)');
title('Poincare Plot');
grid on;
cbar = colorbar;
colormap(parula);
caxis([min(time_color), max(time_color)]);
ylabel(cbar, 'Time (s)');

% SD2 = 1;
% SD1 = 2;
% Ellipse fitting for Poincare Plot
hold on;
theta = linspace(0, 2*pi, 100);
theta_angle = atan2(SD2, SD2);
ellipse_x = SD1 * cos(theta);
ellipse_y = SD2 * sin(theta);
R = [cos(theta_angle), -sin(theta_angle); sin(theta_angle), cos(theta_angle)];
ellipse_coords = R * [ellipse_x; ellipse_y];
% ellipse_coords = [ellipse_x'; ellipse_y'];
plot(ellipse_coords(1,:), ellipse_coords(2,:), 'k', LineWidth=2);
show_std_diff = 10;
xlim([- show_std_diff*SD1, show_std_diff*SD1]);
ylim([- show_std_diff*SD2, show_std_diff*SD2]);
% Add arrows for SD1 and SD2
line_length = 10;
quiver(0, 0, line_length*SD1*cos(theta_angle), line_length*SD1*sin(theta_angle), 0, 'r', LineWidth= 1, MaxHeadSize=0.1); % SD1 line
quiver(0, 0, line_length*-SD1*cos(theta_angle), line_length*-SD1*sin(theta_angle), 0, 'r', LineWidth=1, MaxHeadSize=0); % SD1 opposite line

quiver(0, 0, line_length*SD2*cos(theta_angle+pi/2), line_length*SD2*sin(theta_angle+pi/2), 0, 'k', LineWidth=1, MaxHeadSize=0.1); % SD2 line
quiver(0, 0, line_length*-SD2*cos(theta_angle+pi/2), line_length*-SD2*sin(theta_angle+pi/2), 0, 'k', LineWidth=1, MaxHeadSize=0); % SD2 opposite line

% Add lines for SD1 and SD2
legend('S1-S1', '95% interval', 'SD1', '', 'SD2')
hold off;
%%
S1_S1_intervals = diff(all_locs); % Calculate the differences between consecutive peaks
% Define the desired sampling frequency (in Hz)
Fs = 4; % This is a common choice, but you can adjust as needed

% Create a time vector for interpolation
time_vector = all_locs(1):1/Fs:all_locs(end);

% Perform cubic spline interpolation to resample the S1_S1 intervals
S1_S1_interpolated = interp1(all_locs(1:end-1), S1_S1_intervals, time_vector, 'spline');

% Detrend the signal (optional)
S1_S1_detrended = detrend(S1_S1_interpolated);

[PSD, f] = pwelch(S1_S1_detrended, [], [], [], Fs);

% Define the frequency bands
VLF_band = [0.0033 0.04]; % VLF band (0.0033-0.04 Hz)
LF_band = [0.04 0.15];    % LF band (0.04-0.15 Hz)
HF_band = [0.15 0.4];     % HF band (0.15-0.4 Hz)

% Calculate the power in each band using the bandpower function
VLF_power = bandpower(PSD, f, VLF_band, 'psd');
LF_power = bandpower(PSD, f, LF_band, 'psd');
HF_power = bandpower(PSD, f, HF_band, 'psd');

% Plot the PSD in s²/Hz
figure;
plot(f, PSD, 'k'); % Plot in linear scale with black line
xlabel('Frequency (Hz)');
ylabel('Power/Frequency (s^2/Hz)');
title('Power Spectral Density of S1-S1 Intervals');
grid on;
hold on;

% Shade VLF band under the curve
area(f(f >= VLF_band(1) & f <= VLF_band(2)), PSD(f >= VLF_band(1) & f <= VLF_band(2)), ...
    'FaceColor', 'green', 'FaceAlpha', 0.3);

% Shade LF band under the curve
area(f(f >= LF_band(1) & f <= LF_band(2)), PSD(f >= LF_band(1) & f <= LF_band(2)), ...
    'FaceColor', 'blue', 'FaceAlpha', 0.3);

% Shade HF band under the curve
area(f(f >= HF_band(1) & f <= HF_band(2)), PSD(f >= HF_band(1) & f <= HF_band(2)), ...
    'FaceColor', 'red', 'FaceAlpha', 0.3);

legend('PSD', 'VLF Band', 'LF Band', 'HF Band');
xlim([0 0.5])
% Print the band powers
fprintf('VLF Power: %f\n', VLF_power);
fprintf('LF Power: %f\n', LF_power);
fprintf('HF Power: %f\n', HF_power);