Skip to content

Commit dfa79e9

Browse files
DzmitryFomchyntomaszrybakiewiczrunner
authored
NAVAND-2991 - Optimized TextToSpeech usage (#7833) (#7842)
* NAVAND-2991 - Optimized TextToSpeech usage Moved language setting and speak calls to a background thread. * Rename changelog files * NAVAND-2991 - Optimized TextToSpeech usage Unit-test fix --------- Co-authored-by: Tomasz Rybakiewicz <[email protected]> Co-authored-by: runner <runner@fv-az1428-633>
1 parent 39a5265 commit dfa79e9

File tree

6 files changed

+96
-50
lines changed

6 files changed

+96
-50
lines changed

LICENSE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2469,7 +2469,7 @@ License: [The Apache Software License, Version 2.0](http://www.apache.org/licens
24692469
===========================================================================
24702470

24712471
Mapbox Navigation uses portions of the Android Support Library compat (The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren't a part of the framework APIs. Compatible on devices running API 14 or later.).
2472-
URL: [https://developer.android.com/jetpack/androidx/releases/core#1.5.0](https://developer.android.com/jetpack/androidx/releases/core#1.5.0)
2472+
URL: [https://developer.android.com/jetpack/androidx/releases/core#1.6.0](https://developer.android.com/jetpack/androidx/releases/core#1.6.0)
24732473
License: [The Apache Software License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
24742474

24752475
===========================================================================
@@ -2544,8 +2544,14 @@ License: [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt)
25442544

25452545
===========================================================================
25462546

2547+
Mapbox Navigation uses portions of the Core Kotlin Extensions (Kotlin extensions for 'core' artifact).
2548+
URL: [https://developer.android.com/jetpack/androidx/releases/core#1.6.0](https://developer.android.com/jetpack/androidx/releases/core#1.6.0)
2549+
License: [The Apache Software License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
2550+
2551+
===========================================================================
2552+
25472553
Mapbox Navigation uses portions of the Experimental annotation (Java annotation for use on unstable Android API surfaces. When used in conjunction with the Experimental annotation lint checks, this annotation provides functional parity with Kotlin's Experimental annotation.).
2548-
URL: [https://developer.android.com/jetpack/androidx](https://developer.android.com/jetpack/androidx)
2554+
URL: [https://developer.android.com/jetpack/androidx/releases/annotation#1.1.0](https://developer.android.com/jetpack/androidx/releases/annotation#1.1.0)
25492555
License: [The Apache Software License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt)
25502556

25512557
===========================================================================
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fixed UI jank caused by on-device TextToSpeech player.

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

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.location.Location
66
import android.os.Bundle
77
import android.view.View
88
import androidx.appcompat.app.AppCompatActivity
9+
import androidx.lifecycle.coroutineScope
910
import androidx.lifecycle.lifecycleScope
1011
import com.mapbox.api.directions.v5.models.DirectionsRoute
1112
import com.mapbox.api.directions.v5.models.RouteOptions
@@ -69,6 +70,7 @@ import com.mapbox.navigation.ui.voice.model.SpeechVolume
6970
import com.mapbox.navigation.ui.voice.options.VoiceInstructionsPlayerOptions
7071
import com.mapbox.navigation.utils.internal.ifNonNull
7172
import com.mapbox.navigation.utils.internal.logD
73+
import kotlinx.coroutines.Dispatchers
7274
import kotlinx.coroutines.launch
7375
import java.util.Locale
7476

@@ -112,7 +114,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
112114
* has to be played. [MapboxVoiceInstructionsPlayer] should be instantiated in
113115
* `Activity#onCreate`.
114116
*/
115-
private lateinit var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer
117+
private var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer? = null
116118

117119
private val routeLineResources: RouteLineResources by lazy {
118120
RouteLineResources.Builder().build()
@@ -163,7 +165,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
163165
logD("play(fallback): '${error.fallback.announcement}'", TAG)
164166
// The data obtained in the form of an error is played using
165167
// voiceInstructionsPlayer.
166-
voiceInstructionsPlayer.play(
168+
voiceInstructionsPlayer?.play(
167169
error.fallback,
168170
voiceInstructionsPlayerCallback
169171
)
@@ -172,7 +174,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
172174
logD("play: '${value.announcement.announcement}'", TAG)
173175
// The data obtained in the form of speech announcement is played using
174176
// voiceInstructionsPlayer.
175-
voiceInstructionsPlayer.play(
177+
voiceInstructionsPlayer?.play(
176178
value.announcement,
177179
voiceInstructionsPlayerCallback
178180
)
@@ -220,7 +222,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
220222
RoutesObserver { result -> // Every time a new route is obtained make sure to cancel the [MapboxSpeechApi] and
221223
// clear the [MapboxVoiceInstructionsPlayer]
222224
speechApi.cancel()
223-
voiceInstructionsPlayer.clear()
225+
voiceInstructionsPlayer?.clear()
224226
if (result.navigationRoutes.isNotEmpty()) {
225227
lifecycleScope.launch {
226228
routeLineApi.setNavigationRoutes(
@@ -300,7 +302,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
300302
}
301303

302304
binding.addPlay.setOnClickListener {
303-
voiceInstructionsPlayer.play(
305+
voiceInstructionsPlayer?.play(
304306
SpeechAnnouncement.Builder("Test hybrid speech player.").build(),
305307
voiceInstructionsPlayerCallback
306308
)
@@ -323,10 +325,10 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
323325
private fun handleSoundState(value: Boolean) {
324326
if (value) {
325327
// This is used to set the speech volume to mute.
326-
voiceInstructionsPlayer.volume(SpeechVolume(0.0f))
328+
voiceInstructionsPlayer?.volume(SpeechVolume(0.0f))
327329
} else {
328330
// This is used to set the speech volume to max
329-
voiceInstructionsPlayer.volume(SpeechVolume(1.0f))
331+
voiceInstructionsPlayer?.volume(SpeechVolume(1.0f))
330332
}
331333
isMuted = value
332334
}
@@ -387,13 +389,15 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
387389
setLocationProvider(navigationLocationProvider)
388390
enabled = true
389391
}
390-
voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer(
391-
this,
392-
Locale.US.toLanguageTag(),
393-
VoiceInstructionsPlayerOptions.Builder()
394-
.abandonFocusDelay(PLAYER_ABANDON_FOCUS_DELAY)
395-
.build()
396-
)
392+
lifecycle.coroutineScope.launch(Dispatchers.Default) {
393+
voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer(
394+
applicationContext,
395+
Locale.US.toLanguageTag(),
396+
VoiceInstructionsPlayerOptions.Builder()
397+
.abandonFocusDelay(PLAYER_ABANDON_FOCUS_DELAY)
398+
.build()
399+
)
400+
}
397401
init()
398402
voiceInstructionsPrefetcher.onAttached(mapboxNavigation)
399403
}
@@ -428,7 +432,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener {
428432
mapboxNavigation.onDestroy()
429433
speechApi.cancel()
430434
voiceInstructionsPrefetcher.onDetached(mapboxNavigation)
431-
voiceInstructionsPlayer.shutdown()
435+
voiceInstructionsPlayer?.shutdown()
432436
}
433437

434438
override fun onMapLongClick(point: Point): Boolean {

libnavui-voice/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dependencies {
5050
// androidX
5151
implementation dependenciesList.androidXConstraintLayout
5252
implementation dependenciesList.androidXAppCompat
53+
implementation dependenciesList.androidXCoreKtx
5354

5455
apply from: "../gradle/unit-testing-dependencies.gradle"
5556
testImplementation(project(':libtesting-utils'))

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

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ import android.content.Context
44
import android.speech.tts.TextToSpeech
55
import android.speech.tts.UtteranceProgressListener
66
import androidx.annotation.VisibleForTesting
7+
import androidx.core.os.trace
78
import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
89
import com.mapbox.navigation.ui.voice.model.SpeechVolume
10+
import com.mapbox.navigation.utils.internal.InternalJobControlFactory.createDefaultScopeJobControl
11+
import com.mapbox.navigation.utils.internal.logD
912
import com.mapbox.navigation.utils.internal.logE
13+
import kotlinx.coroutines.cancelChildren
14+
import kotlinx.coroutines.launch
1015
import java.util.Locale
1116

1217
/**
@@ -25,18 +30,18 @@ internal class VoiceInstructionsTextPlayer(
2530
internal var isLanguageSupported: Boolean = false
2631

2732
private var textToSpeechInitStatus: Int? = null
33+
private val jobControl = createDefaultScopeJobControl()
2834

2935
@VisibleForTesting
30-
internal val textToSpeech =
36+
internal val textToSpeech = trace(TRACE_GET_TTS) {
3137
TextToSpeechProvider.getTextToSpeech(context.applicationContext) { status ->
3238
textToSpeechInitStatus = status
3339
if (status == TextToSpeech.SUCCESS) {
3440
initializeWithLanguage(Locale(language))
35-
if (isLanguageSupported) {
36-
setUpUtteranceProgressListener()
37-
}
41+
setUpUtteranceProgressListener()
3842
}
3943
}
44+
}
4045

4146
@VisibleForTesting
4247
internal var volumeLevel: Float = DEFAULT_VOLUME_LEVEL
@@ -69,14 +74,11 @@ internal class VoiceInstructionsTextPlayer(
6974
"Only one announcement can be played at a time."
7075
}
7176
currentPlay = announcement
72-
val announcement = announcement.announcement
73-
if (isLanguageSupported && announcement.isNotBlank()) {
74-
play(announcement)
77+
val text = announcement.announcement
78+
if (isLanguageSupported && text.isNotBlank()) {
79+
play(text)
7580
} else {
76-
logE(
77-
"$LANGUAGE_NOT_SUPPORTED or announcement from state is blank",
78-
LOG_CATEGORY
79-
)
81+
logE { "$LANGUAGE_NOT_SUPPORTED or announcement from state is blank" }
8082
donePlaying()
8183
}
8284
}
@@ -108,6 +110,7 @@ internal class VoiceInstructionsTextPlayer(
108110
* the announcement should end immediately and any announcements queued should be cleared.
109111
*/
110112
override fun shutdown() {
113+
jobControl.job.cancelChildren()
111114
textToSpeech.setOnUtteranceProgressListener(null)
112115
textToSpeech.shutdown()
113116
currentPlay = null
@@ -116,16 +119,20 @@ internal class VoiceInstructionsTextPlayer(
116119

117120
@VisibleForTesting
118121
internal fun initializeWithLanguage(language: Locale) {
119-
isLanguageSupported = if (playerAttributes.options.checkIsLanguageAvailable) {
120-
textToSpeech.isLanguageAvailable(language) == TextToSpeech.LANG_AVAILABLE
121-
} else {
122-
true
123-
}
124-
if (!isLanguageSupported) {
125-
logE(LANGUAGE_NOT_SUPPORTED, LOG_CATEGORY)
126-
return
122+
jobControl.scope.launch {
123+
trace(TRACE_INIT_LANG) {
124+
isLanguageSupported = if (playerAttributes.options.checkIsLanguageAvailable) {
125+
textToSpeech.isLanguageAvailable(language) == TextToSpeech.LANG_AVAILABLE
126+
} else {
127+
true
128+
}
129+
if (!isLanguageSupported) {
130+
logE { LANGUAGE_NOT_SUPPORTED }
131+
return@trace
132+
}
133+
textToSpeech.language = language
134+
}
127135
}
128-
textToSpeech.language = language
129136
}
130137

131138
private fun setUpUtteranceProgressListener() {
@@ -136,12 +143,12 @@ internal class VoiceInstructionsTextPlayer(
136143

137144
override fun onError(utteranceId: String?) {
138145
// Deprecated, may be called due to https://issuetracker.google.com/issues/138321382
139-
logE("Unexpected TextToSpeech error", LOG_CATEGORY)
146+
logE { "Unexpected TextToSpeech error" }
140147
donePlaying()
141148
}
142149

143150
override fun onError(utteranceId: String?, errorCode: Int) {
144-
logE("TextToSpeech error: $errorCode", LOG_CATEGORY)
151+
logE { "TextToSpeech error: $errorCode" }
145152
donePlaying()
146153
}
147154

@@ -163,18 +170,23 @@ internal class VoiceInstructionsTextPlayer(
163170
}
164171

165172
private fun play(announcement: String) {
166-
val currentBundle = BundleProvider.retrieveBundle()
167-
val bundle = currentBundle.apply {
168-
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volumeLevel)
173+
logD { "play: $announcement" }
174+
jobControl.scope.launch {
175+
trace(TRACE_PLAY) {
176+
val currentBundle = BundleProvider.retrieveBundle()
177+
val bundle = currentBundle.apply {
178+
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volumeLevel)
179+
}
180+
playerAttributes.applyOn(textToSpeech, bundle)
181+
182+
textToSpeech.speak(
183+
announcement,
184+
TextToSpeech.QUEUE_FLUSH,
185+
bundle,
186+
DEFAULT_UTTERANCE_ID
187+
)
188+
}
169189
}
170-
playerAttributes.applyOn(textToSpeech, bundle)
171-
172-
textToSpeech.speak(
173-
announcement,
174-
TextToSpeech.QUEUE_FLUSH,
175-
bundle,
176-
DEFAULT_UTTERANCE_ID
177-
)
178190
}
179191

180192
private companion object {
@@ -184,5 +196,12 @@ internal class VoiceInstructionsTextPlayer(
184196
private const val DEFAULT_UTTERANCE_ID = "default_id"
185197
private const val DEFAULT_VOLUME_LEVEL = 1.0f
186198
private const val MUTE_VOLUME_LEVEL = 0.0f
199+
200+
private const val TRACE_GET_TTS = "VoiceInstructionsTextPlayer.getTextToSpeech"
201+
private const val TRACE_INIT_LANG = "VoiceInstructionsTextPlayer.initializeWithLanguage"
202+
private const val TRACE_PLAY = "VoiceInstructionsTextPlayer.play"
203+
204+
private inline fun logD(msg: () -> String) = logD(LOG_CATEGORY, msg)
205+
private inline fun logE(msg: () -> String) = logE(LOG_CATEGORY, msg)
187206
}
188207
}

libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsTextPlayerTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ import android.speech.tts.TextToSpeech.LANG_AVAILABLE
77
import android.speech.tts.TextToSpeech.LANG_NOT_SUPPORTED
88
import android.speech.tts.TextToSpeech.OnInitListener
99
import com.mapbox.navigation.testing.LoggingFrontendTestRule
10+
import com.mapbox.navigation.testing.MainCoroutineRule
1011
import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
1112
import com.mapbox.navigation.ui.voice.model.SpeechVolume
1213
import com.mapbox.navigation.ui.voice.options.VoiceInstructionsPlayerOptions
14+
import com.mapbox.navigation.utils.internal.InternalJobControlFactory
15+
import com.mapbox.navigation.utils.internal.JobControl
1316
import io.mockk.clearMocks
1417
import io.mockk.every
1518
import io.mockk.mockk
1619
import io.mockk.mockkObject
1720
import io.mockk.unmockkObject
1821
import io.mockk.verify
22+
import kotlinx.coroutines.job
1923
import org.junit.After
2024
import org.junit.Assert.assertEquals
2125
import org.junit.Before
@@ -25,6 +29,9 @@ import java.util.Locale
2529

2630
class VoiceInstructionsTextPlayerTest {
2731

32+
@get:Rule
33+
var coroutineRule = MainCoroutineRule()
34+
2835
@get:Rule
2936
val loggerRule = LoggingFrontendTestRule()
3037

@@ -33,6 +40,13 @@ class VoiceInstructionsTextPlayerTest {
3340

3441
@Before
3542
fun setUp() {
43+
mockkObject(InternalJobControlFactory)
44+
every {
45+
InternalJobControlFactory.createDefaultScopeJobControl()
46+
} answers {
47+
val defaultScope = coroutineRule.createTestScope()
48+
JobControl(defaultScope.coroutineContext.job, defaultScope)
49+
}
3650
mockkObject(BundleProvider)
3751
mockkObject(TextToSpeechProvider)
3852
every { BundleProvider.retrieveBundle() } returns mockedBundle
@@ -41,6 +55,7 @@ class VoiceInstructionsTextPlayerTest {
4155

4256
@After
4357
fun tearDown() {
58+
unmockkObject(InternalJobControlFactory)
4459
unmockkObject(BundleProvider)
4560
unmockkObject(TextToSpeechProvider)
4661
}

0 commit comments

Comments
 (0)