Voice announcement: added TtsSpans to announcements

Fixes #876
This commit is contained in:
Jonas Kalderstam
2022-03-01 10:09:00 +01:00
parent 1075021e21
commit 9e7e8c60a6
4 changed files with 159 additions and 106 deletions
@@ -8,7 +8,6 @@ import android.util.Pair;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -26,7 +25,6 @@ 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;
import de.dennisguse.opentracks.util.StringUtils;
@RunWith(AndroidJUnit4.class)
public class VoiceAnnouncementUtilsTest {
@@ -50,10 +48,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, null, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, null, null).toString();
// then
assertEquals("total distance 20.00 kilometers in 1 hour 5 minutes 10 seconds at 18.4 kilometers per hour", announcement);
assertEquals("Total distance 20.00 kilometers. 1 hour 5 minutes 10 seconds. Speed 18.4 kilometers per hour.", announcement);
}
@Test
@@ -67,14 +65,15 @@ public class VoiceAnnouncementUtilsTest {
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, null).toString();
// then
assertEquals("total distance 14.21 kilometers in 16 minutes 39 seconds at 51.2 kilometers per hour Lap speed of 0.0 kilometers per hour", announcement);
assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour.", announcement);
}
@Test
@@ -87,10 +86,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, null, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, null, null).toString();
// then
assertEquals("total distance 20.00 kilometers in 1 hour 5 minutes 10 seconds at 3 minutes 15 seconds per kilometer", announcement);
assertEquals("Total distance 20.00 kilometers. 1 hour 5 minutes 10 seconds. Pace 3 minutes 15 seconds per kilometer.", announcement);
}
@Test
@@ -104,14 +103,15 @@ public class VoiceAnnouncementUtilsTest {
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, lastInterval, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, false, lastInterval, null).toString();
// then
assertEquals("total distance 14.21 kilometers in 16 minutes 39 seconds at 1 minute 10 seconds per kilometer Lap time of 0 minutes 0 seconds per kilometer", announcement);
assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Pace 1 minute 10 seconds per kilometer. Lap time 1 minute 10 seconds per kilometer.", announcement);
}
@Test
@@ -124,10 +124,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, null, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, null, null).toString();
// then
assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 11.4 miles per hour", announcement);
assertEquals("Total distance 12.43 miles. 1 hour 5 minutes 10 seconds. Speed 11.4 miles per hour.", announcement);
}
@Test
@@ -141,14 +141,15 @@ public class VoiceAnnouncementUtilsTest {
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, lastInterval, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, true, lastInterval, null).toString();
// then
assertEquals("total distance 8.83 miles in 16 minutes 39 seconds at 31.8 miles per hour Lap speed of 0.0 miles per hour", announcement);
assertEquals("Total distance 8.83 miles. 16 minutes 39 seconds. Speed 31.8 miles per hour. Lap speed 31.8 miles per hour.", announcement);
}
@Test
@@ -161,10 +162,10 @@ public class VoiceAnnouncementUtilsTest {
stats.setTotalAltitudeGain(6000f);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, null, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, null, null).toString();
// then
assertEquals("total distance 12.43 miles in 1 hour 5 minutes 10 seconds at 5 minutes 15 seconds per mile", announcement);
assertEquals("Total distance 12.43 miles. 1 hour 5 minutes 10 seconds. Pace 5 minutes 15 seconds per mile.", announcement);
}
@Test
@@ -178,14 +179,15 @@ public class VoiceAnnouncementUtilsTest {
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) {
assertEquals(trackPointIterator.getCount(), numberOfPoints);
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.of(1000));
intervalStatistics.addTrackPoints(trackPointIterator);
lastInterval = intervalStatistics.getIntervalList().get(intervalStatistics.getIntervalList().size() - 1);
}
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, lastInterval, null);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, false, false, lastInterval, null).toString();
// then
assertEquals("total distance 8.83 miles in 16 minutes 39 seconds at 1 minute 53 seconds per mile Lap time of 0 minutes 0 seconds per mile", announcement);
assertEquals("Total distance 8.83 miles. 16 minutes 39 seconds. Pace 1 minute 53 seconds per mile. Lap time 1 minute 53 seconds per mile.", announcement);
}
@Test
@@ -207,38 +209,9 @@ public class VoiceAnnouncementUtilsTest {
SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null);
// when
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, sensorStatistics);
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, stats, true, true, lastInterval, sensorStatistics).toString();
// then
assertEquals("total distance 14.21 kilometers in 16 minutes 39 seconds at 51.2 kilometers per hour Lap speed of 51.2 kilometers per hour Average heart rate 180 BPM Current heart rate 133 BPM", announcement);
}
/**
* Builds an returns the text representing the duration's time.
*
* @param duration Duration object.
* @param showFromMinutes show minutes tough it's 0.
* @return text representing the duratin's time.
*/
private String buildAndGetTimeText(Duration duration, boolean showFromMinutes) {
long hours = Math.abs(duration.getSeconds()) / 3600;
long minutes = (Math.abs(duration.getSeconds()) % 3600) / 60;
long seconds = Math.abs(duration.getSeconds()) % 60;
String hUnit = hours > 1 || hours == 0 ? "hours" : "hour";
String mUnit = minutes > 1 || minutes == 0 ? "minutes" : "minute";
String sUnit = seconds > 1 || seconds == 0 ? "seconds" : "second";
String res = hours > 0 ? hours + " " + hUnit : "";
if (hours > 0) {
res += minutes > 0 || showFromMinutes ? " " + minutes + " " + mUnit : "";
} else {
res += minutes > 0 || showFromMinutes ? minutes + " " + mUnit : "";
}
if (hours > 0 || minutes > 0 || showFromMinutes) {
res += " " + seconds + " " + sUnit;
} else {
res += seconds + " " + sUnit;
}
return res;
assertEquals("Total distance 14.21 kilometers. 16 minutes 39 seconds. Speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Current heart rate 133 bpm.", announcement);
}
}
@@ -21,6 +21,7 @@ import android.media.AudioManager;
import android.media.MediaPlayer;
import android.speech.tts.TextToSpeech;
import android.speech.tts.UtteranceProgressListener;
import android.text.Spannable;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -177,7 +178,7 @@ public class VoiceAnnouncement {
sensorStatistics = contentProviderUtils.getSensorStats(track.getId());
}
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval, sensorStatistics);
Spannable 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,9 +1,14 @@
package de.dennisguse.opentracks.services.announcement;
import static android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE;
import static de.dennisguse.opentracks.settings.PreferencesUtils.shouldVoiceAnnounceHeartRate;
import android.content.Context;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.TtsSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.time.Duration;
@@ -20,90 +25,149 @@ class VoiceAnnouncementUtils {
private VoiceAnnouncementUtils() {
}
static String getAnnouncement(
Context context,
TrackStatistics trackStatistics,
boolean isMetricUnits,
boolean isReportSpeed,
@Nullable IntervalStatistics.Interval currentInterval,
@Nullable SensorStatistics sensorStatistics
) {
static Spannable getAnnouncement(Context context, TrackStatistics trackStatistics, boolean isMetricUnits, boolean isReportSpeed, @Nullable IntervalStatistics.Interval currentInterval, @Nullable SensorStatistics sensorStatistics) {
SpannableStringBuilder builder = new SpannableStringBuilder();
Distance distance = trackStatistics.getTotalDistance();
Speed distancePerTime = trackStatistics.getAverageMovingSpeed();
Speed currentDistancePerTime = currentInterval != null ? currentInterval.getSpeed() : null;
int perUnitStringId = isMetricUnits ? R.string.voice_per_kilometer : R.string.voice_per_mile;
int totalDistanceId = isMetricUnits ? R.plurals.voiceTotalDistanceKilometers : R.plurals.voiceTotalDistanceMiles;
double distanceInUnit = distance.toKM_Miles(isMetricUnits);
String totalDistance = context.getResources().getQuantityString(totalDistanceId, getQuantityCount(distanceInUnit), distanceInUnit);
int distanceId = isMetricUnits ? R.plurals.voiceDistanceKilometers : R.plurals.voiceDistanceMiles;
builder.append(context.getString(R.string.total_distance));
long distanceIntegerPart = (long) distanceInUnit;
// Extract the decimal part
String distanceFractionalPart = String.format("%.2f", (distanceInUnit - distanceIntegerPart)).substring(2);
// Units should always be english singular for TTS.
// See https://developer.android.com/reference/android/text/style/TtsSpan?hl=en#TYPE_MEASURE
appendDecimalUnit(builder, context.getResources().getQuantityString(distanceId, getQuantityCount(distanceInUnit), distanceInUnit), distanceIntegerPart, distanceFractionalPart, isMetricUnits ? "kilometer" : "mile");
// Punctuation helps introduce natural pauses in TTS
builder.append(".");
if (distance.isZero()) {
return totalDistance;
return builder;
}
// Announce time
Duration movingTime = trackStatistics.getMovingTime();
if (!movingTime.isZero()) {
appendDuration(context, builder, movingTime);
builder.append(".");
}
String rate;
String currentRate;
String currentRateMsg;
String heartRateMsg = "";
if (isReportSpeed) {
int speedId = isMetricUnits ? R.plurals.voiceSpeedKilometersPerHour : R.plurals.voiceSpeedMilesPerHour;
double speedInUnit = distancePerTime.to(isMetricUnits);
rate = context.getResources().getQuantityString(speedId, getQuantityCount(speedInUnit), speedInUnit);
double currentDistancePerTimeInUnit = currentDistancePerTime != null ? currentDistancePerTime.to(isMetricUnits) : 0;
currentRate = context.getResources().getQuantityString(speedId, getQuantityCount(currentDistancePerTimeInUnit), currentDistancePerTimeInUnit);
currentRateMsg = context.getString(R.string.voice_speed_lap, currentRate);
builder.append(" ")
.append(context.getString(R.string.speed));
long speedIntegerPart = (long) speedInUnit;
// Extract the decimal part
String speedFractionalPart = String.format("%.1f", (speedInUnit - speedIntegerPart)).substring(2);
appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(speedInUnit), speedInUnit), speedIntegerPart, speedFractionalPart, isMetricUnits ? "kilometer per hour" : "mile per hour");
builder.append(".");
if (currentDistancePerTime != null) {
double currentDistancePerTimeInUnit = currentDistancePerTime.to(isMetricUnits);
if (currentDistancePerTimeInUnit > 0) {
builder.append(" ")
.append(context.getString(R.string.lap_speed));
long currentDistanceIntegerPart = (long) currentDistancePerTimeInUnit;
// Extract the decimal part
String currentDistanceFractionalPart = String.format("%.1f", (currentDistancePerTimeInUnit - currentDistanceIntegerPart)).substring(2);
appendDecimalUnit(builder, context.getResources().getQuantityString(speedId, getQuantityCount(currentDistancePerTimeInUnit), currentDistancePerTimeInUnit), currentDistanceIntegerPart, currentDistanceFractionalPart, isMetricUnits ? "kilometer per hour" : "mile per hour");
builder.append(".");
}
}
} else {
Duration time = distancePerTime.toPace(isMetricUnits);
int paceId = isMetricUnits ? R.string.voice_pace_per_kilometer : R.string.voice_pace_per_mile;
rate = context.getString(paceId, getAnnounceTime(context, time));
builder.append(" ")
.append(context.getString(R.string.pace));
appendDuration(context, builder, time);
builder.append(" ")
.append(context.getString(perUnitStringId))
.append(".");
Duration currentTime = currentDistancePerTime != null ? currentDistancePerTime.toPace(isMetricUnits) : Duration.ofMillis(0);
currentRate = context.getString(paceId, getAnnounceTime(context, currentTime));
currentRateMsg = context.getString(R.string.voice_pace_lap, currentRate);
if (!currentTime.isZero()) {
builder.append(" ")
.append(context.getString(R.string.lap_time));
appendDuration(context, builder, currentTime);
builder.append(" ")
.append(context.getString(perUnitStringId))
.append(".");
}
}
currentRateMsg = currentInterval == null ? "" : " " + currentRateMsg;
if (shouldVoiceAnnounceHeartRate()) {
if (sensorStatistics != null && sensorStatistics.hasHeartRate()) {
heartRateMsg = context.getString(R.string.average_heart_rate, Math.round(sensorStatistics.getAvgHeartRate().getBPM()));
int averageHeartRate = Math.round(sensorStatistics.getAvgHeartRate().getBPM());
builder.append(" ")
.append(context.getString(R.string.average_heart_rate));
appendCardinal(builder, context.getString(R.string.sensor_state_heart_rate_value, averageHeartRate), averageHeartRate);
builder.append(".");
}
if (currentInterval != null && currentInterval.hasAverageHeartRate()) {
if (!heartRateMsg.isEmpty()) {
heartRateMsg += " ";
}
heartRateMsg += context.getString(R.string.current_heart_rate, Math.round(currentInterval.getAverageHeartRate().getBPM()));
int currentHeartRate = Math.round(currentInterval.getAverageHeartRate().getBPM());
builder.append(" ")
.append(context.getString(R.string.current_heart_rate));
appendCardinal(builder, context.getString(R.string.sensor_state_heart_rate_value, currentHeartRate), currentHeartRate);
builder.append(".");
}
}
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.
private static String getAnnounceTime(Context context, Duration duration) {
String result = "";
int hours = (int) (duration.toHours());
int minutes = (int) (duration.toMinutes() % 60);
int seconds = (int) (duration.getSeconds() % 60);
if (hours != 0) {
String hoursText = context.getResources()
.getQuantityString(R.plurals.voiceHours, hours, hours);
result += hoursText + " ";
}
String minutesText = context.getResources()
.getQuantityString(R.plurals.voiceMinutes, minutes, minutes);
String secondsText = context.getResources()
.getQuantityString(R.plurals.voiceSeconds, seconds, seconds);
return result + minutesText + " " + secondsText;
return builder;
}
static int getQuantityCount(double d) {
return (int) d;
}
private static void appendDuration(@NonNull Context context, @NonNull SpannableStringBuilder builder, @NonNull Duration duration) {
int hours = (int) (duration.toHours());
int minutes = (int) (duration.toMinutes() % 60);
int seconds = (int) (duration.getSeconds() % 60);
if (hours > 0) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceHours, hours, hours), hours, null, "hour");
}
if (minutes > 0) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceMinutes, minutes, minutes), minutes, null, "minute");
}
if (seconds > 0 || duration.isZero()) {
appendDecimalUnit(builder, context.getResources().getQuantityString(R.plurals.voiceSeconds, seconds, seconds), seconds, null, "second");
}
}
/**
* Speaks as: 98.14 [UNIT] - ninety eight point one four [UNIT with correct plural form]
*/
private static void appendDecimalUnit(@NonNull SpannableStringBuilder builder, @NonNull String localizedText, long integerPart, @Nullable String fractionalPart, @NonNull String unit) {
TtsSpan.MeasureBuilder measureBuilder = new TtsSpan.MeasureBuilder()
.setUnit(unit);
if (fractionalPart == null) {
measureBuilder.setNumber(integerPart);
} else {
measureBuilder.setIntegerPart(integerPart)
.setFractionalPart(fractionalPart);
}
builder.append(" ")
.append(localizedText, measureBuilder.build(), SPAN_INCLUSIVE_EXCLUSIVE);
}
/**
* Speaks as: 98 - ninety eight
*/
private static void appendCardinal(@NonNull SpannableStringBuilder builder, @NonNull String localizedText, long number) {
builder.append(" ")
.append(localizedText, new TtsSpan.CardinalBuilder().setNumber(number).build(), SPAN_INCLUSIVE_EXCLUSIVE);
}
}
+17 -2
View File
@@ -575,6 +575,13 @@ limitations under the License.
<string name="voice_template">%1$s in %2$s at %3$s</string>
<string name="voice_speed_lap">Lap speed of %1$s</string>
<string name="voice_pace_lap">Lap time of %1$s</string>
<string name="voice_per_kilometer">per kilometer</string>
<string name="voice_per_mile">per mile</string>
<string name="lap_time">Lap time</string>
<string name="pace">Pace</string>
<string name="lap_speed">Lap speed</string>
<string name="speed">Speed</string>
<string name="total_distance">Total distance</string>
<plurals name="voiceTotalDistanceKilometers">
<item quantity="one">total distance 1 kilometer</item>
<item quantity="other">total distance %1$.2f kilometers</item>
@@ -583,8 +590,16 @@ limitations under the License.
<item quantity="one">total distance 1 mile</item>
<item quantity="other">total distance %1$.2f miles</item>
</plurals>
<string name="average_heart_rate">Average heart rate %1$d BPM</string>
<string name="current_heart_rate">Current heart rate %1$d BPM</string>
<plurals name="voiceDistanceKilometers">
<item quantity="one">1 kilometer</item>
<item quantity="other">%1$.2f kilometers</item>
</plurals>
<plurals name="voiceDistanceMiles">
<item quantity="one">1 mile</item>
<item quantity="other">%1$.2f miles</item>
</plurals>
<string name="average_heart_rate">Average heart rate</string>
<string name="current_heart_rate">Current heart rate</string>
<!-- Waypoint Type -->
<string name="waypoint_type_atm">ATM</string>
<string name="waypoint_type_bank">bank</string>