Skip to content

Commit 687f6ba

Browse files
authored
NAVAND-552: predownload voice instructions (#6771)
1 parent 6604815 commit 687f6ba

File tree

22 files changed

+1345
-134
lines changed

22 files changed

+1345
-134
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Introduced `VoiceInstructionsPrefetcher` `MapboxSpeechAPI#generatePredownloaded` to use predownloaded voice instructions instead of downloading them on demand. Example usage can be found in the examples directory ( see `MapboxVoiceActivity`).
2+
- Enabled voice instructions predownloading for those who use `MapboxAudioGuidance`.
3+
- Fixed an issue where with low connectivity voice instruction might have been played too late for those who use `MapboxAudioGuidance`. If you use `MapboxSpeechAPI` directly, switch to voice instructions predownloading as described above if you encounter said issue.

examples/src/main/java/com/mapbox/navigation/examples/core/MapboxVoiceActivity.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.mapbox.maps.plugin.gestures.OnMapLongClickListener
2222
import com.mapbox.maps.plugin.gestures.gestures
2323
import com.mapbox.maps.plugin.locationcomponent.LocationComponentPlugin
2424
import com.mapbox.maps.plugin.locationcomponent.location
25+
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
2526
import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
2627
import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions
2728
import com.mapbox.navigation.base.options.NavigationOptions
@@ -60,6 +61,7 @@ import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadRequest
6061
import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoaderFactory
6162
import com.mapbox.navigation.ui.voice.api.MapboxSpeechApi
6263
import com.mapbox.navigation.ui.voice.api.MapboxVoiceInstructionsPlayer
64+
import com.mapbox.navigation.ui.voice.api.VoiceInstructionsPrefetcher
6365
import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
6466
import com.mapbox.navigation.ui.voice.model.SpeechError
6567
import com.mapbox.navigation.ui.voice.model.SpeechValue
@@ -79,6 +81,7 @@ import java.util.Locale
7981
* attention to its usage. Long press anywhere on the map to set a destination and trigger
8082
* navigation.
8183
*/
84+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
8285
class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
8386

8487
private var isMuted: Boolean = false
@@ -201,9 +204,13 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
201204
}
202205
}
203206

207+
private val voiceInstructionsPrefetcher by lazy {
208+
VoiceInstructionsPrefetcher(speechApi)
209+
}
210+
204211
private val voiceInstructionsObserver =
205212
VoiceInstructionsObserver { voiceInstructions -> // The data obtained must be used to generate the synthesized speech mp3 file.
206-
speechApi.generate(
213+
speechApi.generatePredownloaded(
207214
voiceInstructions,
208215
speechCallback
209216
)
@@ -382,6 +389,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
382389
.build()
383390
)
384391
init()
392+
voiceInstructionsPrefetcher.onAttached(mapboxNavigation)
385393
}
386394

387395
override fun onStart() {
@@ -413,6 +421,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
413421
mapboxReplayer.finish()
414422
mapboxNavigation.onDestroy()
415423
speechApi.cancel()
424+
voiceInstructionsPrefetcher.onDetached(mapboxNavigation)
416425
voiceInstructionsPlayer.shutdown()
417426
}
418427

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
package com.mapbox.navigation.utils.internal
22

3+
import android.os.SystemClock
4+
import java.util.concurrent.TimeUnit
5+
36
interface Time {
7+
48
fun nanoTime(): Long
9+
510
fun millis(): Long
611

12+
fun seconds(): Long
713
object SystemImpl : Time {
814
override fun nanoTime(): Long = System.nanoTime()
915

1016
override fun millis(): Long = System.currentTimeMillis()
17+
18+
override fun seconds(): Long = TimeUnit.MILLISECONDS.toSeconds(millis())
19+
}
20+
21+
object SystemClockImpl : Time {
22+
override fun nanoTime(): Long = SystemClock.elapsedRealtimeNanos()
23+
24+
override fun millis(): Long = SystemClock.elapsedRealtime()
25+
26+
override fun seconds(): Long = TimeUnit.MILLISECONDS.toSeconds(millis())
1127
}
1228
}

libnavui-voice/api/current.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ package com.mapbox.navigation.ui.voice.api {
6464
method public void cancel();
6565
method public void clean(com.mapbox.navigation.ui.voice.model.SpeechAnnouncement announcement);
6666
method public void generate(com.mapbox.api.directions.v5.models.VoiceInstructions voiceInstruction, com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer<com.mapbox.bindgen.Expected<com.mapbox.navigation.ui.voice.model.SpeechError,com.mapbox.navigation.ui.voice.model.SpeechValue>> consumer);
67+
method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void generatePredownloaded(com.mapbox.api.directions.v5.models.VoiceInstructions voiceInstruction, com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer<com.mapbox.bindgen.Expected<com.mapbox.navigation.ui.voice.model.SpeechError,com.mapbox.navigation.ui.voice.model.SpeechValue>> consumer);
6768
}
6869

6970
@UiThread public final class MapboxVoiceInstructionsPlayer {
@@ -92,6 +93,18 @@ package com.mapbox.navigation.ui.voice.api {
9293
property public abstract com.mapbox.navigation.ui.voice.options.VoiceInstructionsPlayerOptions options;
9394
}
9495

96+
@com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class VoiceInstructionsPrefetcher implements com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver {
97+
ctor public VoiceInstructionsPrefetcher(com.mapbox.navigation.ui.voice.api.MapboxSpeechApi speechApi);
98+
method public void onAttached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation);
99+
method public void onDetached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation);
100+
field public static final com.mapbox.navigation.ui.voice.api.VoiceInstructionsPrefetcher.Companion Companion;
101+
field public static final int DEFAULT_OBSERVABLE_TIME_SECONDS = 180; // 0xb4
102+
field public static final double DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER = 0.5;
103+
}
104+
105+
public static final class VoiceInstructionsPrefetcher.Companion {
106+
}
107+
95108
}
96109

97110
package com.mapbox.navigation.ui.voice.model {

libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidance.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.mapbox.navigation.ui.voice.api
22

33
import androidx.annotation.VisibleForTesting
44
import com.mapbox.api.directions.v5.models.VoiceInstructions
5+
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
56
import com.mapbox.navigation.core.MapboxNavigation
67
import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp
78
import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver
@@ -41,6 +42,9 @@ internal constructor(
4142

4243
private var dataStoreOwner: NavigationDataStoreOwner? = null
4344
private var configOwner: NavigationConfigOwner? = null
45+
46+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
47+
private var trigger: VoiceInstructionsPrefetcher? = null
4448
private var mutedStateFlow = MutableStateFlow(false)
4549
private val internalStateFlow = MutableStateFlow(MapboxAudioGuidanceState())
4650
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
@@ -70,8 +74,10 @@ internal constructor(
7074
/**
7175
* @see [MapboxNavigationApp]
7276
*/
77+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
7378
override fun onDetached(mapboxNavigation: MapboxNavigation) {
7479
mapboxVoiceInstructions.unregisterObservers(mapboxNavigation)
80+
trigger?.onDetached(mapboxNavigation)
7581
job?.cancel()
7682
job = null
7783
}
@@ -160,15 +166,22 @@ internal constructor(
160166
}
161167
}
162168

163-
private fun MapboxNavigation.audioGuidanceVoice(): Flow<MapboxAudioGuidanceVoice> =
164-
combine(
169+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
170+
private fun MapboxNavigation.audioGuidanceVoice(): Flow<MapboxAudioGuidanceVoice> {
171+
return combine(
165172
mapboxVoiceInstructions.voiceLanguage(),
166173
configOwner!!.language(),
167174
) { voiceLanguage, deviceLanguage -> voiceLanguage ?: deviceLanguage }
168175
.distinctUntilChanged()
169176
.map { language ->
170-
audioGuidanceServices.mapboxAudioGuidanceVoice(this, language)
177+
trigger?.onDetached(this)
178+
audioGuidanceServices.mapboxAudioGuidanceVoice(this, language).also {
179+
trigger = VoiceInstructionsPrefetcher(it.mapboxSpeechApi).also { trigger ->
180+
trigger.onAttached(this)
181+
}
182+
}
171183
}
184+
}
172185

173186
private suspend fun restoreMutedState() {
174187
dataStoreOwner?.apply {

libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
package com.mapbox.navigation.ui.voice.api
22

33
import android.content.Context
4+
import androidx.annotation.UiThread
45
import com.mapbox.api.directions.v5.models.VoiceInstructions
56
import com.mapbox.bindgen.Expected
67
import com.mapbox.bindgen.ExpectedFactory
8+
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
9+
import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver
710
import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer
811
import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
912
import com.mapbox.navigation.ui.voice.model.SpeechError
1013
import com.mapbox.navigation.ui.voice.model.SpeechValue
14+
import com.mapbox.navigation.ui.voice.model.TypeAndAnnouncement
1115
import com.mapbox.navigation.ui.voice.model.VoiceState
1216
import com.mapbox.navigation.ui.voice.options.MapboxSpeechApiOptions
1317
import com.mapbox.navigation.utils.internal.InternalJobControlFactory
18+
import kotlinx.coroutines.cancel
1419
import kotlinx.coroutines.launch
1520
import java.util.Locale
1621

@@ -28,7 +33,11 @@ class MapboxSpeechApi @JvmOverloads constructor(
2833
private val options: MapboxSpeechApiOptions = MapboxSpeechApiOptions.Builder().build()
2934
) {
3035

36+
private val cachedFiles = mutableMapOf<TypeAndAnnouncement, SpeechValue>()
3137
private val mainJobController by lazy { InternalJobControlFactory.createMainScopeJobControl() }
38+
private val predownloadJobController by lazy {
39+
InternalJobControlFactory.createDefaultScopeJobControl()
40+
}
3241
private val voiceAPI = VoiceApiProvider.retrieveMapboxVoiceApi(
3342
context,
3443
accessToken,
@@ -40,6 +49,9 @@ class MapboxSpeechApi @JvmOverloads constructor(
4049
* Given [VoiceInstructions] the method will try to generate the
4150
* voice instruction [SpeechAnnouncement] including the synthesized speech mp3 file
4251
* from Mapbox's API Voice.
52+
* NOTE: this method will try downloading an mp3 file from server. If you use voice instructions
53+
* predownloading (see [VoiceInstructionsPrefetcher]), invoke [generatePredownloaded]
54+
* instead of this method in your [VoiceInstructionsObserver].
4355
* @param voiceInstruction VoiceInstructions object representing [VoiceInstructions]
4456
* @param consumer is a [SpeechValue] including the announcement to be played when the
4557
* announcement is ready or a [SpeechError] including the error information and a fallback
@@ -51,7 +63,42 @@ class MapboxSpeechApi @JvmOverloads constructor(
5163
consumer: MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>>
5264
) {
5365
mainJobController.scope.launch {
54-
retrieveVoiceFile(voiceInstruction, consumer)
66+
consumer.accept(retrieveVoiceFile(voiceInstruction))
67+
}
68+
}
69+
70+
/**
71+
* Given [VoiceInstructions] the method will try to generate the
72+
* voice instruction [SpeechAnnouncement] including the synthesized speech mp3 file
73+
* from Mapbox's API Voice.
74+
* NOTE: this method will NOT try downloading an mp3 file from server. It will either use
75+
* an already predownloaded file or an onboard speech synthesizer. Only invoke this method
76+
* if you use voice instructions predownloading (see [VoiceInstructionsPrefetcher]),
77+
* otherwise invoke [generatePredownloaded] in your [VoiceInstructionsObserver].
78+
* @param voiceInstruction VoiceInstructions object representing [VoiceInstructions]
79+
* @param consumer is a [SpeechValue] including the announcement to be played when the
80+
* announcement is ready or a [SpeechError] including the error information and a fallback
81+
* with the raw announcement (without file) that can be played with a text-to-speech engine.
82+
* @see [cancel]
83+
*/
84+
@ExperimentalPreviewMapboxNavigationAPI
85+
fun generatePredownloaded(
86+
voiceInstruction: VoiceInstructions,
87+
consumer: MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>>
88+
) {
89+
mainJobController.scope.launch {
90+
val cachedValue = getFromCache(voiceInstruction)
91+
if (cachedValue != null) {
92+
consumer.accept(ExpectedFactory.createValue(cachedValue))
93+
} else {
94+
val fallback = getFallbackAnnouncement(voiceInstruction)
95+
val speechError = SpeechError(
96+
"No predownloaded instruction for ${voiceInstruction.announcement()}",
97+
null,
98+
fallback
99+
)
100+
consumer.accept(ExpectedFactory.createError(speechError))
101+
}
55102
}
56103
}
57104

@@ -73,33 +120,62 @@ class MapboxSpeechApi @JvmOverloads constructor(
73120
*/
74121
fun clean(announcement: SpeechAnnouncement) {
75122
voiceAPI.clean(announcement)
123+
VoiceInstructionsParser.parse(announcement).onValue {
124+
val value = cachedFiles[it]
125+
// when we clear fallback announcement, there is a chance we will remove the key
126+
// from map and not remove the file itself
127+
// since for fallback SpeechAnnouncement file is null
128+
if (value?.announcement == announcement) {
129+
cachedFiles.remove(it)
130+
}
131+
}
132+
}
133+
134+
@UiThread
135+
internal fun predownload(instructions: List<VoiceInstructions>) {
136+
instructions.forEach { instruction ->
137+
val typeAndAnnouncement = VoiceInstructionsParser.parse(instruction).value
138+
if (typeAndAnnouncement != null && !hasTypeAndAnnouncement(typeAndAnnouncement)) {
139+
predownloadJobController.scope.launch {
140+
val voiceFile = retrieveVoiceFile(instruction)
141+
mainJobController.scope.launch {
142+
voiceFile.onValue { speechValue ->
143+
cachedFiles[typeAndAnnouncement] = speechValue
144+
}
145+
}
146+
}
147+
}
148+
}
149+
}
150+
151+
internal fun cancelPredownload() {
152+
predownloadJobController.job.children.forEach { it.cancel() }
153+
val announcements = cachedFiles.map { it.value.announcement }
154+
announcements.forEach { clean(it) }
76155
}
77156

78157
@Throws(IllegalStateException::class)
79158
private suspend fun retrieveVoiceFile(
80159
voiceInstruction: VoiceInstructions,
81-
consumer: MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>>
82-
) {
160+
): Expected<SpeechError, SpeechValue> {
83161
when (val result = voiceAPI.retrieveVoiceFile(voiceInstruction)) {
84162
is VoiceState.VoiceFile -> {
85163
val announcement = voiceInstruction.announcement()
86164
val ssmlAnnouncement = voiceInstruction.ssmlAnnouncement()
87-
consumer.accept(
88-
ExpectedFactory.createValue(
89-
SpeechValue(
90-
// Can't be null as it's checked in retrieveVoiceFile
91-
SpeechAnnouncement.Builder(announcement!!)
92-
.ssmlAnnouncement(ssmlAnnouncement)
93-
.file(result.instructionFile)
94-
.build()
95-
)
165+
return ExpectedFactory.createValue(
166+
SpeechValue(
167+
// Can't be null as it's checked in retrieveVoiceFile
168+
SpeechAnnouncement.Builder(announcement!!)
169+
.ssmlAnnouncement(ssmlAnnouncement)
170+
.file(result.instructionFile)
171+
.build()
96172
)
97173
)
98174
}
99175
is VoiceState.VoiceError -> {
100176
val fallback = getFallbackAnnouncement(voiceInstruction)
101177
val speechError = SpeechError(result.exception, null, fallback)
102-
consumer.accept(ExpectedFactory.createError(speechError))
178+
return ExpectedFactory.createError(speechError)
103179
}
104180
}
105181
}
@@ -117,4 +193,13 @@ class MapboxSpeechApi @JvmOverloads constructor(
117193
.ssmlAnnouncement(ssmlAnnouncement)
118194
.build()
119195
}
196+
197+
private fun hasTypeAndAnnouncement(typeAndAnnouncement: TypeAndAnnouncement): Boolean {
198+
return typeAndAnnouncement in cachedFiles
199+
}
200+
201+
private fun getFromCache(voiceInstruction: VoiceInstructions): SpeechValue? {
202+
val key = VoiceInstructionsParser.parse(voiceInstruction).value
203+
return key?.let { cachedFiles[it] }
204+
}
120205
}

libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProvider.kt

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
package com.mapbox.navigation.ui.voice.api
66

77
import android.net.Uri
8+
import com.mapbox.api.directions.v5.models.VoiceInstructions
89
import com.mapbox.bindgen.Expected
910
import com.mapbox.bindgen.ExpectedFactory.createError
1011
import com.mapbox.bindgen.ExpectedFactory.createValue
1112
import com.mapbox.common.ResourceLoadError
13+
import com.mapbox.common.ResourceLoadFlags
1214
import com.mapbox.common.ResourceLoadResult
1315
import com.mapbox.common.ResourceLoadStatus
1416
import com.mapbox.navigation.base.internal.accounts.UrlSkuTokenProvider
@@ -25,20 +27,27 @@ internal class MapboxSpeechProvider(
2527
private val language: String,
2628
private val urlSkuTokenProvider: UrlSkuTokenProvider,
2729
private val options: MapboxSpeechApiOptions,
28-
private val resourceLoader: ResourceLoader
30+
private val resourceLoader: ResourceLoader,
2931
) {
3032

31-
suspend fun load(typeAndAnnouncement: TypeAndAnnouncement): Expected<Throwable, ByteArray> {
33+
suspend fun load(voiceInstruction: VoiceInstructions): Expected<Throwable, ByteArray> {
3234
return runCatching {
33-
val url = instructionUrl(typeAndAnnouncement.announcement, typeAndAnnouncement.type)
34-
val response = resourceLoader.load(url)
35+
val typeAndAnnouncement = VoiceInstructionsParser.parse(voiceInstruction)
36+
.getValueOrElse { throw it }
37+
val request = createRequest(typeAndAnnouncement)
38+
val response = resourceLoader.load(request)
3539
return processResponse(response)
3640
}.getOrElse {
3741
createError(it)
3842
}
3943
}
4044

41-
private suspend fun ResourceLoader.load(url: String) = load(ResourceLoadRequest(url))
45+
private fun createRequest(typeAndAnnouncement: TypeAndAnnouncement): ResourceLoadRequest {
46+
val url = instructionUrl(typeAndAnnouncement.announcement, typeAndAnnouncement.type)
47+
return ResourceLoadRequest(url).apply {
48+
flags = ResourceLoadFlags.ACCEPT_EXPIRED
49+
}
50+
}
4251

4352
private fun processResponse(
4453
response: Expected<ResourceLoadError, ResourceLoadResult>

0 commit comments

Comments
 (0)