Voice announcement: added heart rate info to voice announcements

Also added setting to toggle announcement of heart rate

Defaults to off to preserve current behavior
This commit is contained in:
Jonas Kalderstam
2022-02-18 23:55:36 +01:00
parent d7fbae9321
commit 1075021e21
13 changed files with 177 additions and 50 deletions
@@ -34,6 +34,7 @@ import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.stats.SensorStatistics;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.ui.intervals.IntervalStatistics;
@@ -171,8 +172,12 @@ public class VoiceAnnouncement {
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.one(isMetricUnits));
intervalStatistics.addTrackPoints(trackPointIterator);
IntervalStatistics.Interval lastInterval = intervalStatistics.getLastInterval();
SensorStatistics sensorStatistics = null;
if (track.getId() != null) {
sensorStatistics = contentProviderUtils.getSensorStats(track.getId());
}
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval, sensorStatistics);
// We don't care about the utterance id. It is supplied here to force onUtteranceCompleted to be called.
tts.speak(announcement, TextToSpeech.QUEUE_FLUSH, null, "not used");
@@ -1,5 +1,7 @@
package de.dennisguse.opentracks.services.announcement;
import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnnounceHeartRate;
import android.content.Context;
import androidx.annotation.Nullable;
@@ -9,6 +11,7 @@ import java.time.Duration;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.stats.SensorStatistics;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.ui.intervals.IntervalStatistics;
@@ -17,7 +20,14 @@ class VoiceAnnouncementUtils {
private VoiceAnnouncementUtils() {
}
static String getAnnouncement(Context context, TrackStatistics trackStatistics, boolean isMetricUnits, boolean isReportSpeed, @Nullable IntervalStatistics.Interval currentInterval) {
static String getAnnouncement(
Context context,
TrackStatistics trackStatistics,
boolean isMetricUnits,
boolean isReportSpeed,
@Nullable IntervalStatistics.Interval currentInterval,
@Nullable SensorStatistics sensorStatistics
) {
Distance distance = trackStatistics.getTotalDistance();
Speed distancePerTime = trackStatistics.getAverageMovingSpeed();
Speed currentDistancePerTime = currentInterval != null ? currentInterval.getSpeed() : null;
@@ -32,6 +42,7 @@ class VoiceAnnouncementUtils {
String rate;
String currentRate;
String currentRateMsg;
String heartRateMsg = "";
if (isReportSpeed) {
int speedId = isMetricUnits ? R.plurals.voiceSpeedKilometersPerHour : R.plurals.voiceSpeedMilesPerHour;
double speedInUnit = distancePerTime.to(isMetricUnits);
@@ -53,7 +64,21 @@ class VoiceAnnouncementUtils {
currentRateMsg = currentInterval == null ? "" : " " + currentRateMsg;
return context.getString(R.string.voice_template, totalDistance, getAnnounceTime(context, trackStatistics.getMovingTime()), rate) + currentRateMsg;
if (shouldVoiceAnnounceHeartRate()) {
if (sensorStatistics != null && sensorStatistics.hasHeartRate()) {
heartRateMsg = context.getString(R.string.average_heart_rate, Math.round(sensorStatistics.getAvgHeartRate().getBPM()));
}
if (currentInterval != null && currentInterval.hasAverageHeartRate()) {
if (!heartRateMsg.isEmpty()) {
heartRateMsg += " ";
}
heartRateMsg += context.getString(R.string.current_heart_rate, Math.round(currentInterval.getAverageHeartRate().getBPM()));
}
}
heartRateMsg = heartRateMsg.isEmpty() ? "" : " " + heartRateMsg;
return context.getString(R.string.voice_template, totalDistance, getAnnounceTime(context, trackStatistics.getMovingTime()), rate) + currentRateMsg + heartRateMsg;
}
//TODO We might need to localize this using strings.xml if order is relevant.
@@ -172,6 +172,13 @@ public class PreferencesUtils {
setString(keyId, resources.getString(valueId));
}
@VisibleForTesting
public static void setBoolean(int keyId, Boolean value) {
Editor editor = sharedPreferences.edit();
editor.putBoolean(getKey(keyId), value);
editor.apply();
}
static void setInt(int keyId, int value) {
Editor editor = sharedPreferences.edit();
editor.putInt(getKey(keyId), value);
@@ -316,6 +323,15 @@ public class PreferencesUtils {
return getFloat(R.string.voice_speed_rate_key, DEFAULT);
}
public static boolean shouldVoiceAnnounceHeartRate() {
return getBoolean(R.string.voice_announce_heart_rate_key, false);
}
@VisibleForTesting
public static void setVoiceAnnounceHeartRate(boolean value) {
setBoolean(R.string.voice_announce_heart_rate_key, value);
}
public static Distance getRecordingDistanceInterval() {
return Distance.of(getInt(R.string.recording_distance_interval_key, getRecordingDistanceIntervalDefaultInternal()));
}
@@ -25,6 +25,7 @@ import java.time.Instant;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
@@ -56,6 +57,8 @@ public class TrackStatistics {
private Speed maxSpeed;
private Float totalAltitudeGain_m = null;
private Float totalAltitudeLoss_m = null;
// The average heart rate seen on this track
private HeartRate avgHeartRate = null;
public TrackStatistics() {
reset();
@@ -76,6 +79,7 @@ public class TrackStatistics {
altitudeExtremities.set(other.altitudeExtremities.getMin(), other.altitudeExtremities.getMax());
totalAltitudeGain_m = other.totalAltitudeGain_m;
totalAltitudeLoss_m = other.totalAltitudeLoss_m;
avgHeartRate = other.avgHeartRate;
}
@VisibleForTesting
@@ -108,6 +112,19 @@ public class TrackStatistics {
stopTime = stopTime.isAfter(other.stopTime) ? stopTime : other.stopTime;
}
if (avgHeartRate == null) {
avgHeartRate = other.avgHeartRate;
} else {
if (other.avgHeartRate != null) {
// Using total time as weights for the averaging.
// Important to do this before total time is updated
avgHeartRate = HeartRate.of(
(totalTime.getSeconds() * avgHeartRate.getBPM() + other.totalTime.getSeconds() * other.avgHeartRate.getBPM())
/ (totalTime.getSeconds() + other.totalTime.getSeconds())
);
}
}
totalDistance = totalDistance.plus(other.totalDistance);
totalTime = totalTime.plus(other.totalTime);
movingTime = movingTime.plus(other.movingTime);
@@ -230,6 +247,15 @@ public class TrackStatistics {
return totalTime.minus(movingTime);
}
public boolean hasAverageHeartRate() {
return avgHeartRate != null;
}
@Nullable
public HeartRate getAverageHeartRate() {
return avgHeartRate;
}
/**
* Gets the average speed.
* This calculation only takes into account the displacement until the last point that was accounted for in statistics.
@@ -287,6 +313,12 @@ public class TrackStatistics {
}
}
public void setAverageHeartRate(HeartRate heartRate) {
if (heartRate != null) {
avgHeartRate = heartRate;
}
}
public boolean hasTotalAltitudeGain() {
return totalAltitudeGain_m != null;
}
@@ -26,6 +26,7 @@ import java.util.List;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.TrackPoint;
@@ -64,6 +65,8 @@ public class TrackStatisticsUpdater {
private final AltitudeRingBuffer altitudeBuffer;
private final SpeedRingBuffer speedBuffer;
private float averageHeartRateBPM;
private Duration totalHeartRateDuration = Duration.ZERO;
// The current segment's statistics
private final TrackStatistics currentSegment;
@@ -85,6 +88,7 @@ public class TrackStatisticsUpdater {
altitudeBuffer = new AltitudeRingBuffer(ALTITUDE_SMOOTHING_FACTOR);
speedBuffer = new SpeedRingBuffer(SPEED_SMOOTHING_FACTOR);
resetAverageHeartRate();
}
public TrackStatisticsUpdater(TrackStatisticsUpdater toCopy) {
@@ -95,6 +99,7 @@ public class TrackStatisticsUpdater {
this.speedBuffer = new SpeedRingBuffer(toCopy.speedBuffer);
this.lastTrackPoint = toCopy.lastTrackPoint;
resetAverageHeartRate();
}
public TrackStatistics getTrackStatistics() {
@@ -142,6 +147,17 @@ public class TrackStatisticsUpdater {
currentSegment.updateAltitudeExtremities(newAverage);
}
// Update heart rate
if (trackPoint.hasHeartRate() && lastTrackPoint != null) {
Duration trackPointDuration = Duration.between(lastTrackPoint.getTime(), trackPoint.getTime());
Duration newTotalDuration = totalHeartRateDuration.plus(trackPointDuration);
averageHeartRateBPM = (totalHeartRateDuration.toMillis() * averageHeartRateBPM + trackPointDuration.toMillis() * trackPoint.getHeartRate().getBPM()) / newTotalDuration.toMillis();
totalHeartRateDuration = newTotalDuration;
currentSegment.setAverageHeartRate(HeartRate.of(averageHeartRateBPM));
}
// Update total distance
if (trackPoint.hasSensorDistance()) {
// Sensor-based distance/speed
@@ -184,6 +200,12 @@ public class TrackStatisticsUpdater {
lastTrackPoint = null;
altitudeBuffer.reset();
speedBuffer.reset();
resetAverageHeartRate();
}
private void resetAverageHeartRate() {
averageHeartRateBPM = 0.0f;
totalHeartRateDuration = Duration.ZERO;
}
/**
@@ -8,6 +8,7 @@ import java.util.List;
import de.dennisguse.opentracks.data.TrackPointIterator;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.TrackPoint;
import de.dennisguse.opentracks.stats.TrackStatistics;
@@ -102,6 +103,7 @@ public class IntervalStatistics {
private Duration time = Duration.ofSeconds(0);
private Float gain_m;
private Float loss_m;
private HeartRate avgHeartRate;
public Interval() {
}
@@ -117,6 +119,7 @@ public class IntervalStatistics {
time = i.time;
gain_m = i.gain_m;
loss_m = i.loss_m;
avgHeartRate = i.avgHeartRate;
}
public Interval(Interval i) {
@@ -124,6 +127,7 @@ public class IntervalStatistics {
time = i.time;
gain_m = i.gain_m;
loss_m = i.loss_m;
avgHeartRate = i.avgHeartRate;
}
private void adjust(double adjustFactor) {
@@ -155,11 +159,20 @@ public class IntervalStatistics {
return loss_m;
}
public boolean hasAverageHeartRate() {
return avgHeartRate != null;
}
public HeartRate getAverageHeartRate() {
return avgHeartRate;
}
private void add(TrackStatistics trackStatistics, @Nullable TrackPoint lastTrackPoint) {
distance = distance.plus(trackStatistics.getTotalDistance());
time = time.plus(trackStatistics.getTotalTime());
gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m;
loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m;
avgHeartRate = trackStatistics.getAverageHeartRate();
if (lastTrackPoint == null) {
return;
}
@@ -176,6 +189,7 @@ public class IntervalStatistics {
time = trackStatistics.getTotalTime();
gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m;
loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m;
avgHeartRate = trackStatistics.getAverageHeartRate();
}
}
}