11package com.mapbox.navigation.ui.voice.api
22
33import android.content.Context
4+ import androidx.annotation.UiThread
45import com.mapbox.api.directions.v5.models.VoiceInstructions
56import com.mapbox.bindgen.Expected
67import com.mapbox.bindgen.ExpectedFactory
8+ import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
9+ import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver
710import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer
811import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
912import com.mapbox.navigation.ui.voice.model.SpeechError
1013import com.mapbox.navigation.ui.voice.model.SpeechValue
14+ import com.mapbox.navigation.ui.voice.model.TypeAndAnnouncement
1115import com.mapbox.navigation.ui.voice.model.VoiceState
1216import com.mapbox.navigation.ui.voice.options.MapboxSpeechApiOptions
1317import com.mapbox.navigation.utils.internal.InternalJobControlFactory
18+ import kotlinx.coroutines.cancel
1419import kotlinx.coroutines.launch
1520import 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}
0 commit comments