Skip to content

Commit 15d044e

Browse files
Updated MapboxAudioGuidanceVoice to use coroutines to synchronize calls to both MapboxSpeechApi and MapboxVoiceInstructionsPlayer.
As a result, MapboxAudioGuidanceVoice will wait until the player finishes playing the previous announcement before attempting to play the next one. Using coroutines also allows for API and Player cancellation alongside the parent Job.
1 parent 607e689 commit 15d044e

File tree

4 files changed

+90
-60
lines changed

4 files changed

+90
-60
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.filter
2727
import kotlinx.coroutines.flow.first
2828
import kotlinx.coroutines.flow.flatMapConcat
2929
import kotlinx.coroutines.flow.flatMapLatest
30+
import kotlinx.coroutines.flow.flowOf
3031
import kotlinx.coroutines.flow.map
3132
import kotlinx.coroutines.flow.updateAndGet
3233
import kotlinx.coroutines.launch
@@ -147,7 +148,8 @@ internal constructor(
147148
.filter { it.voiceInstructions != lastPlayedInstructions }
148149
.flatMapConcat {
149150
lastPlayedInstructions = it.voiceInstructions
150-
audioGuidance.speak(it.voiceInstructions)
151+
val announcement = audioGuidance.speak(it.voiceInstructions)
152+
flowOf(announcement)
151153
}
152154
.map { speechAnnouncement ->
153155
internalStateFlow.updateAndGet {
Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
package com.mapbox.navigation.ui.voice.internal
22

33
import com.mapbox.api.directions.v5.models.VoiceInstructions
4-
import com.mapbox.bindgen.Expected
5-
import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer
64
import com.mapbox.navigation.ui.voice.api.MapboxSpeechApi
75
import com.mapbox.navigation.ui.voice.api.MapboxVoiceInstructionsPlayer
86
import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
9-
import com.mapbox.navigation.ui.voice.model.SpeechError
10-
import com.mapbox.navigation.ui.voice.model.SpeechValue
11-
import kotlinx.coroutines.ExperimentalCoroutinesApi
12-
import kotlinx.coroutines.channels.awaitClose
13-
import kotlinx.coroutines.channels.onFailure
14-
import kotlinx.coroutines.channels.onSuccess
15-
import kotlinx.coroutines.flow.Flow
16-
import kotlinx.coroutines.flow.callbackFlow
17-
import kotlinx.coroutines.flow.flowOf
7+
import kotlinx.coroutines.NonCancellable
8+
import kotlinx.coroutines.suspendCancellableCoroutine
9+
import kotlinx.coroutines.withContext
10+
import kotlin.coroutines.resume
1811

1912
/**
2013
* Controls voice guidance for the car.
@@ -26,35 +19,44 @@ class MapboxAudioGuidanceVoice(
2619
private val mapboxSpeechApi: MapboxSpeechApi,
2720
private val mapboxVoiceInstructionsPlayer: MapboxVoiceInstructionsPlayer
2821
) {
29-
fun speak(voiceInstructions: VoiceInstructions?): Flow<SpeechAnnouncement?> {
22+
/**
23+
* Load and play [SpeechAnnouncement].
24+
* This method will suspend until announcement finishes playback.
25+
*/
26+
suspend fun speak(voiceInstructions: VoiceInstructions?): SpeechAnnouncement? {
3027
return if (voiceInstructions != null) {
31-
speechFlow(voiceInstructions)
28+
val announcement = mapboxSpeechApi.generate(voiceInstructions)
29+
try {
30+
mapboxVoiceInstructionsPlayer.play(announcement)
31+
announcement
32+
} finally {
33+
withContext(NonCancellable) {
34+
mapboxSpeechApi.clean(announcement)
35+
}
36+
}
3237
} else {
3338
mapboxSpeechApi.cancel()
3439
mapboxVoiceInstructionsPlayer.clear()
35-
flowOf(null)
40+
null
3641
}
3742
}
3843

39-
@OptIn(ExperimentalCoroutinesApi::class)
40-
private fun speechFlow(voiceInstructions: VoiceInstructions): Flow<SpeechAnnouncement> =
41-
callbackFlow {
42-
val speechCallback =
43-
MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>> { value ->
44-
val speechAnnouncement = value.value?.announcement ?: value.error!!.fallback
45-
mapboxVoiceInstructionsPlayer.play(speechAnnouncement) {
46-
mapboxSpeechApi.clean(it)
47-
trySend(speechAnnouncement).onSuccess {
48-
close()
49-
}.onFailure {
50-
close()
51-
}
52-
}
53-
}
54-
mapboxSpeechApi.generate(voiceInstructions, speechCallback)
55-
awaitClose {
56-
mapboxSpeechApi.cancel()
57-
mapboxVoiceInstructionsPlayer.clear()
58-
}
44+
private suspend fun MapboxSpeechApi.generate(
45+
instructions: VoiceInstructions
46+
): SpeechAnnouncement = suspendCancellableCoroutine { cont ->
47+
generate(instructions) { value ->
48+
val announcement = value.value?.announcement ?: value.error!!.fallback
49+
cont.resume(announcement)
5950
}
51+
cont.invokeOnCancellation { cancel() }
52+
}
53+
54+
private suspend fun MapboxVoiceInstructionsPlayer.play(
55+
announcement: SpeechAnnouncement
56+
): SpeechAnnouncement = suspendCancellableCoroutine { cont ->
57+
play(announcement) {
58+
cont.resume(announcement)
59+
}
60+
cont.invokeOnCancellation { clear() }
61+
}
6062
}

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import com.mapbox.navigation.ui.voice.internal.MapboxVoiceInstructionsState
88
import com.mapbox.navigation.ui.voice.internal.impl.MapboxAudioGuidanceServices
99
import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
1010
import io.mockk.Runs
11+
import io.mockk.coEvery
1112
import io.mockk.every
1213
import io.mockk.just
1314
import io.mockk.mockk
1415
import kotlinx.coroutines.delay
1516
import kotlinx.coroutines.flow.MutableStateFlow
1617
import kotlinx.coroutines.flow.flowOf
17-
import kotlinx.coroutines.flow.onEach
1818

1919
class TestMapboxAudioGuidanceServices(
2020
private val deviceLanguage: String = "en"
@@ -34,20 +34,19 @@ class TestMapboxAudioGuidanceServices(
3434
}
3535

3636
private val mapboxAudioGuidanceVoice = mockk<MapboxAudioGuidanceVoice> {
37-
every { speak(any()) } answers {
37+
coEvery { speak(any()) } coAnswers {
3838
val voiceInstructions = firstArg<VoiceInstructions?>()
3939
val speechAnnouncement: SpeechAnnouncement? = voiceInstructions?.let {
4040
mockk {
4141
every { announcement } returns it.announcement()!!
4242
every { ssmlAnnouncement } returns it.ssmlAnnouncement()
4343
}
4444
}
45-
flowOf(speechAnnouncement).onEach {
46-
if (it != null) {
47-
// Simulate a real speech announcement by delaying the TestCoroutineScope
48-
delay(SPEECH_ANNOUNCEMENT_DELAY_MS)
49-
}
45+
if (speechAnnouncement != null) {
46+
// Simulate a real speech announcement by delaying the TestCoroutineScope
47+
delay(SPEECH_ANNOUNCEMENT_DELAY_MS)
5048
}
49+
speechAnnouncement
5150
}
5251
}
5352

libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/internal/impl/MapboxAudioGuidanceVoiceTest.kt

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import io.mockk.every
1515
import io.mockk.mockk
1616
import io.mockk.verify
1717
import kotlinx.coroutines.ExperimentalCoroutinesApi
18-
import kotlinx.coroutines.flow.collect
18+
import kotlinx.coroutines.delay
19+
import kotlinx.coroutines.launch
1920
import org.junit.Assert.assertEquals
2021
import org.junit.Rule
2122
import org.junit.Test
@@ -27,28 +28,28 @@ class MapboxAudioGuidanceVoiceTest {
2728
val coroutineRule = MainCoroutineRule()
2829

2930
private val speechApi = mockk<MapboxSpeechApi>(relaxUnitFun = true)
30-
private val voiceInstructionsPlayer = mockk<MapboxVoiceInstructionsPlayer>(relaxUnitFun = true)
31-
private val carAppAudioGuidanceVoice = MapboxAudioGuidanceVoice(
31+
private val voiceInstructionsPlayer = mockk<MapboxVoiceInstructionsPlayer>(relaxed = true)
32+
private val sut = MapboxAudioGuidanceVoice(
3233
speechApi,
3334
voiceInstructionsPlayer
3435
)
3536

3637
@Test
37-
fun `voice instruction should be played as SpeechAnnouncement`() = coroutineRule.runBlockingTest {
38-
mockSuccessfulSpeechApi()
39-
mockSuccessfulVoiceInstructionsPlayer()
38+
fun `voice instruction should be played as SpeechAnnouncement`() =
39+
coroutineRule.runBlockingTest {
40+
mockSuccessfulSpeechApi()
41+
mockSuccessfulVoiceInstructionsPlayer()
4042

41-
val voiceInstructions = mockk<VoiceInstructions> {
42-
every { announcement() } returns "Turn right on Market"
43-
}
44-
carAppAudioGuidanceVoice.speak(voiceInstructions).collect { speechAnnouncement ->
43+
val voiceInstructions = mockk<VoiceInstructions> {
44+
every { announcement() } returns "Turn right on Market"
45+
}
46+
val speechAnnouncement = sut.speak(voiceInstructions)
4547
assertEquals("Turn right on Market", speechAnnouncement!!.announcement)
4648
}
47-
}
4849

4950
@Test
5051
fun `null should clean up the api and player`() = coroutineRule.runBlockingTest {
51-
carAppAudioGuidanceVoice.speak(null).collect()
52+
sut.speak(null)
5253

5354
verify { speechApi.cancel() }
5455
verify { voiceInstructionsPlayer.clear() }
@@ -70,18 +71,44 @@ class MapboxAudioGuidanceVoiceTest {
7071
val voiceInstructions = mockk<VoiceInstructions> {
7172
every { announcement() } returns "This message fails"
7273
}
73-
carAppAudioGuidanceVoice.speak(voiceInstructions).collect { speechAnnouncement ->
74-
assertEquals("Turn right on Market", speechAnnouncement!!.announcement)
75-
}
74+
val speechAnnouncement = sut.speak(voiceInstructions)
75+
assertEquals("Turn right on Market", speechAnnouncement!!.announcement)
7676
}
7777

78+
@Test
79+
fun `should wait until previous instruction finishes playback before playing next one`() =
80+
coroutineRule.runBlockingTest {
81+
mockSuccessfulSpeechApi()
82+
every { voiceInstructionsPlayer.play(any(), any()) } answers {
83+
launch {
84+
val speechAnnouncement = firstArg<SpeechAnnouncement>()
85+
delay(1000) // simulate 1 second announcement playback duration
86+
secondArg<MapboxNavigationConsumer<SpeechAnnouncement>>()
87+
.accept(speechAnnouncement)
88+
}
89+
Unit
90+
}
91+
92+
val played = mutableListOf<SpeechAnnouncement?>()
93+
launch {
94+
listOf(
95+
VoiceInstructions.builder().announcement("A").build(),
96+
VoiceInstructions.builder().announcement("B").build()
97+
).forEach {
98+
val announcement = sut.speak(it) // suspend until playback finishes
99+
played.add(announcement)
100+
}
101+
}
102+
advanceTimeBy(1500) // advance time to 50% of announcement B playback time
103+
104+
assertEquals(1, played.size)
105+
}
106+
78107
private fun mockSuccessfulSpeechApi() {
79108
every { speechApi.generate(any(), any()) } answers {
80109
val announcementArg = firstArg<VoiceInstructions>().announcement()
81110
val speechValue = mockk<SpeechValue> {
82-
every { announcement } returns mockk {
83-
every { announcement } returns announcementArg!!
84-
}
111+
every { announcement } returns SpeechAnnouncement.Builder(announcementArg!!).build()
85112
}
86113
val consumer = secondArg<MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>>>()
87114
consumer.accept(ExpectedFactory.createValue(speechValue))

0 commit comments

Comments
 (0)