-
Notifications
You must be signed in to change notification settings - Fork 319
NAVAND-552: predownload voice instructions #6771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1009923
bf8c559
e95b687
99ce8ff
7908c34
26508a1
6fe5122
eda31a5
f269346
e76aa0a
12a2e6e
38d3589
594011f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| - 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`). | ||
| - Enabled voice instructions predownloading for those who use `MapboxAudioGuidance`. | ||
| - 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,28 @@ | ||
| package com.mapbox.navigation.utils.internal | ||
|
|
||
| import android.os.SystemClock | ||
| import java.util.concurrent.TimeUnit | ||
|
|
||
| interface Time { | ||
|
|
||
| fun nanoTime(): Long | ||
|
|
||
| fun millis(): Long | ||
|
|
||
| fun seconds(): Long | ||
| object SystemImpl : Time { | ||
| override fun nanoTime(): Long = System.nanoTime() | ||
|
|
||
| override fun millis(): Long = System.currentTimeMillis() | ||
|
|
||
| override fun seconds(): Long = TimeUnit.MILLISECONDS.toSeconds(millis()) | ||
| } | ||
|
|
||
| object SystemClockImpl : Time { | ||
| override fun nanoTime(): Long = SystemClock.elapsedRealtimeNanos() | ||
|
|
||
| override fun millis(): Long = SystemClock.elapsedRealtime() | ||
|
|
||
| override fun seconds(): Long = TimeUnit.MILLISECONDS.toSeconds(millis()) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -1,16 +1,21 @@ | ||||
| package com.mapbox.navigation.ui.voice.api | ||||
|
|
||||
| import android.content.Context | ||||
| import androidx.annotation.UiThread | ||||
| import com.mapbox.api.directions.v5.models.VoiceInstructions | ||||
| import com.mapbox.bindgen.Expected | ||||
| import com.mapbox.bindgen.ExpectedFactory | ||||
| import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI | ||||
| import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver | ||||
| import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer | ||||
| import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement | ||||
| import com.mapbox.navigation.ui.voice.model.SpeechError | ||||
| import com.mapbox.navigation.ui.voice.model.SpeechValue | ||||
| import com.mapbox.navigation.ui.voice.model.TypeAndAnnouncement | ||||
| import com.mapbox.navigation.ui.voice.model.VoiceState | ||||
| import com.mapbox.navigation.ui.voice.options.MapboxSpeechApiOptions | ||||
| import com.mapbox.navigation.utils.internal.InternalJobControlFactory | ||||
| import kotlinx.coroutines.cancel | ||||
| import kotlinx.coroutines.launch | ||||
| import java.util.Locale | ||||
|
|
||||
|
|
@@ -28,7 +33,11 @@ class MapboxSpeechApi @JvmOverloads constructor( | |||
| private val options: MapboxSpeechApiOptions = MapboxSpeechApiOptions.Builder().build() | ||||
| ) { | ||||
|
|
||||
| private val cachedFiles = mutableMapOf<TypeAndAnnouncement, SpeechValue>() | ||||
| private val mainJobController by lazy { InternalJobControlFactory.createMainScopeJobControl() } | ||||
| private val predownloadJobController by lazy { | ||||
| InternalJobControlFactory.createDefaultScopeJobControl() | ||||
| } | ||||
| private val voiceAPI = VoiceApiProvider.retrieveMapboxVoiceApi( | ||||
| context, | ||||
| accessToken, | ||||
|
|
@@ -40,6 +49,9 @@ class MapboxSpeechApi @JvmOverloads constructor( | |||
| * Given [VoiceInstructions] the method will try to generate the | ||||
| * voice instruction [SpeechAnnouncement] including the synthesized speech mp3 file | ||||
| * from Mapbox's API Voice. | ||||
| * NOTE: this method will try downloading an mp3 file from server. If you use voice instructions | ||||
| * predownloading (see [VoiceInstructionsPrefetcher]), invoke [generatePredownloaded] | ||||
| * instead of this method in your [VoiceInstructionsObserver]. | ||||
| * @param voiceInstruction VoiceInstructions object representing [VoiceInstructions] | ||||
| * @param consumer is a [SpeechValue] including the announcement to be played when the | ||||
| * announcement is ready or a [SpeechError] including the error information and a fallback | ||||
|
|
@@ -51,7 +63,42 @@ class MapboxSpeechApi @JvmOverloads constructor( | |||
| consumer: MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>> | ||||
| ) { | ||||
| mainJobController.scope.launch { | ||||
| retrieveVoiceFile(voiceInstruction, consumer) | ||||
| consumer.accept(retrieveVoiceFile(voiceInstruction)) | ||||
| } | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Given [VoiceInstructions] the method will try to generate the | ||||
| * voice instruction [SpeechAnnouncement] including the synthesized speech mp3 file | ||||
| * from Mapbox's API Voice. | ||||
| * NOTE: this method will NOT try downloading an mp3 file from server. It will either use | ||||
| * an already predownloaded file or an onboard speech synthesizer. Only invoke this method | ||||
| * if you use voice instructions predownloading (see [VoiceInstructionsPrefetcher]), | ||||
| * otherwise invoke [generatePredownloaded] in your [VoiceInstructionsObserver]. | ||||
| * @param voiceInstruction VoiceInstructions object representing [VoiceInstructions] | ||||
| * @param consumer is a [SpeechValue] including the announcement to be played when the | ||||
| * announcement is ready or a [SpeechError] including the error information and a fallback | ||||
| * with the raw announcement (without file) that can be played with a text-to-speech engine. | ||||
| * @see [cancel] | ||||
| */ | ||||
| @ExperimentalPreviewMapboxNavigationAPI | ||||
| fun generatePredownloaded( | ||||
| voiceInstruction: VoiceInstructions, | ||||
| consumer: MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>> | ||||
| ) { | ||||
| mainJobController.scope.launch { | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Do you have suspend function here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, but I'm trying to acquire a lock here (see
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see much difference between acquiring lock from from main thread directly and from a coroutine with main dispatcher. In both cases if lock is locked, main thread will be blocked, won't it?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. It's not mutex. |
||||
| val cachedValue = getFromCache(voiceInstruction) | ||||
| if (cachedValue != null) { | ||||
| consumer.accept(ExpectedFactory.createValue(cachedValue)) | ||||
| } else { | ||||
| val fallback = getFallbackAnnouncement(voiceInstruction) | ||||
| val speechError = SpeechError( | ||||
| "No predownloaded instruction for ${voiceInstruction.announcement()}", | ||||
| null, | ||||
| fallback | ||||
| ) | ||||
| consumer.accept(ExpectedFactory.createError(speechError)) | ||||
| } | ||||
| } | ||||
| } | ||||
|
|
||||
|
|
@@ -73,33 +120,62 @@ class MapboxSpeechApi @JvmOverloads constructor( | |||
| */ | ||||
| fun clean(announcement: SpeechAnnouncement) { | ||||
| voiceAPI.clean(announcement) | ||||
| VoiceInstructionsParser.parse(announcement).onValue { | ||||
| val value = cachedFiles[it] | ||||
| // when we clear fallback announcement, there is a chance we will remove the key | ||||
| // from map and not remove the file itself | ||||
| // since for fallback SpeechAnnouncement file is null | ||||
| if (value?.announcement == announcement) { | ||||
| cachedFiles.remove(it) | ||||
| } | ||||
| } | ||||
| } | ||||
|
|
||||
| @UiThread | ||||
| internal fun predownload(instructions: List<VoiceInstructions>) { | ||||
| instructions.forEach { instruction -> | ||||
| val typeAndAnnouncement = VoiceInstructionsParser.parse(instruction).value | ||||
| if (typeAndAnnouncement != null && !hasTypeAndAnnouncement(typeAndAnnouncement)) { | ||||
| predownloadJobController.scope.launch { | ||||
| val voiceFile = retrieveVoiceFile(instruction) | ||||
| mainJobController.scope.launch { | ||||
| voiceFile.onValue { speechValue -> | ||||
| cachedFiles[typeAndAnnouncement] = speechValue | ||||
| } | ||||
| } | ||||
| } | ||||
| } | ||||
| } | ||||
| } | ||||
|
|
||||
| internal fun cancelPredownload() { | ||||
| predownloadJobController.job.children.forEach { it.cancel() } | ||||
| val announcements = cachedFiles.map { it.value.announcement } | ||||
| announcements.forEach { clean(it) } | ||||
| } | ||||
|
|
||||
| @Throws(IllegalStateException::class) | ||||
| private suspend fun retrieveVoiceFile( | ||||
| voiceInstruction: VoiceInstructions, | ||||
| consumer: MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>> | ||||
| ) { | ||||
| ): Expected<SpeechError, SpeechValue> { | ||||
| when (val result = voiceAPI.retrieveVoiceFile(voiceInstruction)) { | ||||
| is VoiceState.VoiceFile -> { | ||||
| val announcement = voiceInstruction.announcement() | ||||
| val ssmlAnnouncement = voiceInstruction.ssmlAnnouncement() | ||||
| consumer.accept( | ||||
| ExpectedFactory.createValue( | ||||
| SpeechValue( | ||||
| // Can't be null as it's checked in retrieveVoiceFile | ||||
| SpeechAnnouncement.Builder(announcement!!) | ||||
| .ssmlAnnouncement(ssmlAnnouncement) | ||||
| .file(result.instructionFile) | ||||
| .build() | ||||
| ) | ||||
| return ExpectedFactory.createValue( | ||||
| SpeechValue( | ||||
| // Can't be null as it's checked in retrieveVoiceFile | ||||
| SpeechAnnouncement.Builder(announcement!!) | ||||
| .ssmlAnnouncement(ssmlAnnouncement) | ||||
| .file(result.instructionFile) | ||||
| .build() | ||||
| ) | ||||
| ) | ||||
| } | ||||
| is VoiceState.VoiceError -> { | ||||
| val fallback = getFallbackAnnouncement(voiceInstruction) | ||||
| val speechError = SpeechError(result.exception, null, fallback) | ||||
| consumer.accept(ExpectedFactory.createError(speechError)) | ||||
| return ExpectedFactory.createError(speechError) | ||||
| } | ||||
| } | ||||
| } | ||||
|
|
@@ -117,4 +193,13 @@ class MapboxSpeechApi @JvmOverloads constructor( | |||
| .ssmlAnnouncement(ssmlAnnouncement) | ||||
| .build() | ||||
| } | ||||
|
|
||||
| private fun hasTypeAndAnnouncement(typeAndAnnouncement: TypeAndAnnouncement): Boolean { | ||||
| return typeAndAnnouncement in cachedFiles | ||||
| } | ||||
|
|
||||
| private fun getFromCache(voiceInstruction: VoiceInstructions): SpeechValue? { | ||||
| val key = VoiceInstructionsParser.parse(voiceInstruction).value | ||||
| return key?.let { cachedFiles[it] } | ||||
| } | ||||
| } | ||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how is trigger detached when subscription is cancelled?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you mean that there will be n+1
onAttachedinvocations and nonDetachedwith the current approach? You're probably right, I added anonDetachedinvocation toMapboxAudioGuidance#onDetached.And it made me realize that we don't have to both destroy audioGuidanceVoice and trigger. One invocation is enough.