forked from upstream-mirrors/OpenTracks
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright 2009 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||
* use this file except in compliance with the License. You may obtain a copy of
|
||||
* the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations under
|
||||
* the License.
|
||||
*/
|
||||
|
||||
package de.dennisguse.opentracks.services.announcement;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.media.MediaPlayer;
|
||||
import android.speech.tts.TextToSpeech;
|
||||
import android.speech.tts.UtteranceProgressListener;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.content.data.Distance;
|
||||
import de.dennisguse.opentracks.content.data.Track;
|
||||
import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
|
||||
import de.dennisguse.opentracks.content.provider.TrackPointIterator;
|
||||
import de.dennisguse.opentracks.settings.PreferencesUtils;
|
||||
import de.dennisguse.opentracks.stats.TrackStatistics;
|
||||
import de.dennisguse.opentracks.viewmodels.IntervalStatistics;
|
||||
|
||||
/**
|
||||
* This class will announce the user's {@link TrackStatistics}.
|
||||
*
|
||||
* @author Sandor Dornbush
|
||||
*/
|
||||
public class VoiceAnnouncement {
|
||||
|
||||
public final static int AUDIO_STREAM = TextToSpeech.Engine.DEFAULT_STREAM;
|
||||
|
||||
private static final String TAG = VoiceAnnouncement.class.getSimpleName();
|
||||
|
||||
private final Context context;
|
||||
|
||||
private final AudioManager audioManager;
|
||||
|
||||
private final ContentProviderUtils contentProviderUtils;
|
||||
|
||||
private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
|
||||
@Override
|
||||
public void onAudioFocusChange(int focusChange) {
|
||||
Log.d(TAG, "Audio focus changed to " + focusChange);
|
||||
|
||||
boolean stop = false;
|
||||
switch (focusChange) {
|
||||
case AudioManager.AUDIOFOCUS_GAIN:
|
||||
stop = false;
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS:
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (stop && tts != null && tts.isSpeaking()) {
|
||||
tts.stop();
|
||||
Log.i(TAG, "Aborting current tts due to focus change " + focusChange);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final UtteranceProgressListener utteranceListener = new UtteranceProgressListener() {
|
||||
@Override
|
||||
public void onStart(String utteranceId) {
|
||||
int result = audioManager.requestAudioFocus(audioFocusChangeListener, AUDIO_STREAM, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK);
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_FAILED) {
|
||||
Log.w(TAG, "Failed to request audio focus.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDone(String utteranceId) {
|
||||
int result = audioManager.abandonAudioFocus(audioFocusChangeListener);
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_FAILED) {
|
||||
Log.w(TAG, "Failed to relinquish audio focus.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String utteranceId) {
|
||||
Log.e(TAG, "An error occurred for utteranceId " + utteranceId);
|
||||
}
|
||||
};
|
||||
|
||||
private TextToSpeech tts;
|
||||
// Response from TTS after its initialization
|
||||
private int ttsInitStatus = TextToSpeech.ERROR;
|
||||
|
||||
private boolean ttsReady = false;
|
||||
|
||||
private MediaPlayer ttsFallback;
|
||||
|
||||
VoiceAnnouncement(Context context) {
|
||||
this.context = context;
|
||||
audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
||||
contentProviderUtils = new ContentProviderUtils(context);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
Log.d(TAG, "Start");
|
||||
|
||||
if (tts == null) {
|
||||
tts = new TextToSpeech(context, status -> {
|
||||
Log.i(TAG, "TextToSpeech initialized with status " + status);
|
||||
ttsInitStatus = status;
|
||||
});
|
||||
}
|
||||
if (ttsFallback == null) {
|
||||
ttsFallback = MediaPlayer.create(context, R.raw.tts_fallback);
|
||||
ttsFallback.setAudioStreamType(AUDIO_STREAM);
|
||||
ttsFallback.setLooping(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void announce(@NonNull Track track) {
|
||||
synchronized (this) {
|
||||
if (!ttsReady) {
|
||||
ttsReady = ttsInitStatus == TextToSpeech.SUCCESS;
|
||||
if (ttsReady) {
|
||||
onTtsReady();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Arrays.asList(AudioManager.MODE_IN_CALL, AudioManager.MODE_IN_COMMUNICATION)
|
||||
.contains(audioManager.getMode())) {
|
||||
Log.i(TAG, "Announcement is not allowed at this time.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ttsReady) {
|
||||
Log.i(TAG, "TTS not ready/available, just generating a tone.");
|
||||
ttsFallback.seekTo(0);
|
||||
ttsFallback.start();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
boolean isMetricUnits = PreferencesUtils.isMetricUnits();
|
||||
boolean isReportSpeed = PreferencesUtils.isReportSpeed(track.getCategory());
|
||||
Distance minGPSDistance = PreferencesUtils.getRecordingDistanceInterval();
|
||||
|
||||
//TODO Do not load all trackpoints for every announcement
|
||||
TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(track.getId(), null);
|
||||
IntervalStatistics intervalStatistics = new IntervalStatistics(Distance.one(isMetricUnits), minGPSDistance);
|
||||
intervalStatistics.addTrackPoints(trackPointIterator);
|
||||
IntervalStatistics.Interval lastInterval = intervalStatistics.getLastInterval();
|
||||
|
||||
String announcement = VoiceAnnouncementUtils.getAnnouncement(context, track.getTrackStatistics(), isMetricUnits, isReportSpeed, lastInterval);
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
if (tts != null) {
|
||||
tts.shutdown();
|
||||
tts = null;
|
||||
}
|
||||
|
||||
if (ttsFallback != null) {
|
||||
ttsFallback.release();
|
||||
ttsFallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void onTtsReady() {
|
||||
Locale locale = Locale.getDefault();
|
||||
int languageAvailability = tts.isLanguageAvailable(locale);
|
||||
if (languageAvailability == TextToSpeech.LANG_MISSING_DATA || languageAvailability == TextToSpeech.LANG_NOT_SUPPORTED) {
|
||||
Log.w(TAG, "Default locale not available, use English.");
|
||||
locale = Locale.ENGLISH;
|
||||
/*
|
||||
* TODO: instead of using english, load the language if missing and show a toast if not supported.
|
||||
* Not able to change the resource strings to English.
|
||||
*/
|
||||
}
|
||||
tts.setLanguage(locale);
|
||||
tts.setSpeechRate(PreferencesUtils.getVoiceSpeedRate());
|
||||
tts.setOnUtteranceProgressListener(utteranceListener);
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2010 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||
* use this file except in compliance with the License. You may obtain a copy of
|
||||
* the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations under
|
||||
* the License.
|
||||
*/
|
||||
package de.dennisguse.opentracks.services.announcement;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import de.dennisguse.opentracks.content.data.Distance;
|
||||
import de.dennisguse.opentracks.content.data.Track;
|
||||
import de.dennisguse.opentracks.services.TrackRecordingService;
|
||||
import de.dennisguse.opentracks.stats.TrackStatistics;
|
||||
|
||||
/**
|
||||
* Execute a periodic task on a time or distance schedule.
|
||||
*
|
||||
* @author Sandor Dornbush
|
||||
*/
|
||||
public class VoiceAnnouncementManager {
|
||||
|
||||
private static final String TAG = VoiceAnnouncementManager.class.getSimpleName();
|
||||
|
||||
private final TrackRecordingService trackRecordingService;
|
||||
|
||||
private VoiceAnnouncement voiceAnnouncement;
|
||||
|
||||
private TrackStatistics trackStatistics;
|
||||
|
||||
private static final Distance DISTANCE_OFF = Distance.of(Double.MAX_VALUE);
|
||||
private Distance distanceFrequency = DISTANCE_OFF;
|
||||
@NonNull
|
||||
private Distance nextTotalDistance = DISTANCE_OFF;
|
||||
|
||||
private static final Duration TOTALTIME_OFF = Duration.ofMillis(Long.MAX_VALUE);
|
||||
private Duration totalTimeFrequency = TOTALTIME_OFF;
|
||||
@NonNull
|
||||
private Duration nextTotalTime = TOTALTIME_OFF;
|
||||
|
||||
public VoiceAnnouncementManager(@NonNull TrackRecordingService trackRecordingService) {
|
||||
this.trackRecordingService = trackRecordingService;
|
||||
}
|
||||
|
||||
public void restore(@Nullable TrackStatistics trackStatistics) {
|
||||
voiceAnnouncement = new VoiceAnnouncement(trackRecordingService);
|
||||
voiceAnnouncement.start();
|
||||
|
||||
this.trackStatistics = trackStatistics;
|
||||
updateNextDuration();
|
||||
updateNextTaskDistance();
|
||||
}
|
||||
|
||||
public void update(@NonNull Track track) {
|
||||
if (voiceAnnouncement == null) {
|
||||
Log.e(TAG, "Cannot update when in status shutdown.");
|
||||
return;
|
||||
}
|
||||
|
||||
boolean announce = false;
|
||||
this.trackStatistics = track.getTrackStatistics();
|
||||
if (trackStatistics.getTotalDistance().greaterThan(nextTotalDistance)) {
|
||||
updateNextTaskDistance();
|
||||
announce = true;
|
||||
}
|
||||
if (!trackStatistics.getTotalTime().minus(nextTotalTime).isNegative()) {
|
||||
updateNextDuration();
|
||||
announce = true;
|
||||
}
|
||||
|
||||
if (announce) {
|
||||
voiceAnnouncement.announce(track);
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
if (voiceAnnouncement != null) {
|
||||
voiceAnnouncement.shutdown();
|
||||
voiceAnnouncement = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setFrequency(Duration frequency) {
|
||||
this.totalTimeFrequency = frequency;
|
||||
restore(this.trackStatistics);
|
||||
}
|
||||
|
||||
public void setFrequency(Distance frequency) {
|
||||
this.distanceFrequency = frequency;
|
||||
restore(this.trackStatistics);
|
||||
}
|
||||
|
||||
public void updateNextTaskDistance() {
|
||||
if (trackStatistics == null || distanceFrequency.isZero()) {
|
||||
nextTotalDistance = DISTANCE_OFF;
|
||||
} else {
|
||||
|
||||
Distance distance = trackStatistics.getTotalDistance();
|
||||
|
||||
int index = (int) (distance.dividedBy(distanceFrequency));
|
||||
nextTotalDistance = distanceFrequency.multipliedBy(index + 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void updateNextDuration() {
|
||||
if (trackStatistics == null || totalTimeFrequency.isZero()) {
|
||||
nextTotalTime = TOTALTIME_OFF;
|
||||
} else {
|
||||
|
||||
Duration totalTime = trackStatistics.getTotalTime();
|
||||
Duration intervalMod = Duration.ofMillis(trackStatistics.getTotalTime().toMillis() % totalTimeFrequency.toMillis());
|
||||
|
||||
nextTotalTime = totalTime.plus(totalTimeFrequency.minus(intervalMod));
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@NonNull
|
||||
public Duration getNextTotalTime() {
|
||||
return nextTotalTime;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@NonNull
|
||||
public Distance getNextTotalDistance() {
|
||||
return nextTotalDistance;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package de.dennisguse.opentracks.services.announcement;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import de.dennisguse.opentracks.R;
|
||||
import de.dennisguse.opentracks.content.data.Distance;
|
||||
import de.dennisguse.opentracks.content.data.Speed;
|
||||
import de.dennisguse.opentracks.stats.TrackStatistics;
|
||||
import de.dennisguse.opentracks.viewmodels.IntervalStatistics;
|
||||
|
||||
class VoiceAnnouncementUtils {
|
||||
|
||||
private VoiceAnnouncementUtils() {
|
||||
}
|
||||
|
||||
static String getAnnouncement(Context context, TrackStatistics trackStatistics, boolean isMetricUnits, boolean isReportSpeed, @Nullable IntervalStatistics.Interval currentInterval) {
|
||||
Distance distance = trackStatistics.getTotalDistance();
|
||||
Speed distancePerTime = trackStatistics.getAverageMovingSpeed();
|
||||
Speed currentDistancePerTime = currentInterval != null ? currentInterval.getSpeed() : null;
|
||||
|
||||
int totalDistanceId = isMetricUnits ? R.plurals.voiceTotalDistanceKilometers : R.plurals.voiceTotalDistanceMiles;
|
||||
double distanceInUnit = distance.toKM_Miles(isMetricUnits);
|
||||
String totalDistance = context.getResources().getQuantityString(totalDistanceId, getQuantityCount(distanceInUnit), distanceInUnit);
|
||||
if (distance.isZero()) {
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
String rate;
|
||||
String currentRate;
|
||||
String currentRateMsg;
|
||||
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);
|
||||
} 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));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
currentRateMsg = currentInterval == null ? "" : " " + currentRateMsg;
|
||||
|
||||
return context.getString(R.string.voice_template, totalDistance, getAnnounceTime(context, trackStatistics.getMovingTime()), rate) + currentRateMsg;
|
||||
}
|
||||
|
||||
//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;
|
||||
}
|
||||
|
||||
static int getQuantityCount(double d) {
|
||||
return (int) d;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user