Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -147,7 +148,8 @@ internal constructor(
.filter { it.voiceInstructions != lastPlayedInstructions }
.flatMapConcat {
lastPlayedInstructions = it.voiceInstructions
audioGuidance.speak(it.voiceInstructions)
val announcement = audioGuidance.speak(it.voiceInstructions)
flowOf(announcement)
Copy link
Contributor

@Zayankovsky Zayankovsky Dec 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you emit a flow with a single value, you can use a simple map instead of flatMapConcat.
After that you can replace two consecutive maps with one map.

}
.map { speechAnnouncement ->
internalStateFlow.updateAndGet {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
package com.mapbox.navigation.ui.voice.internal

import com.mapbox.api.directions.v5.models.VoiceInstructions
import com.mapbox.bindgen.Expected
import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer
import com.mapbox.navigation.ui.voice.api.MapboxSpeechApi
import com.mapbox.navigation.ui.voice.api.MapboxVoiceInstructionsPlayer
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.onSuccess
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume

/**
* Controls voice guidance for the car.
Expand All @@ -26,35 +19,44 @@ class MapboxAudioGuidanceVoice(
private val mapboxSpeechApi: MapboxSpeechApi,
private val mapboxVoiceInstructionsPlayer: MapboxVoiceInstructionsPlayer
) {
fun speak(voiceInstructions: VoiceInstructions?): Flow<SpeechAnnouncement?> {
/**
* Load and play [SpeechAnnouncement].
* This method will suspend until announcement finishes playback.
*/
suspend fun speak(voiceInstructions: VoiceInstructions?): SpeechAnnouncement? {
return if (voiceInstructions != null) {
speechFlow(voiceInstructions)
val announcement = mapboxSpeechApi.generate(voiceInstructions)
try {
mapboxVoiceInstructionsPlayer.play(announcement)
announcement
} finally {
withContext(NonCancellable) {
mapboxSpeechApi.clean(announcement)
}
}
} else {
mapboxSpeechApi.cancel()
mapboxVoiceInstructionsPlayer.clear()
flowOf(null)
null
}
}

@OptIn(ExperimentalCoroutinesApi::class)
private fun speechFlow(voiceInstructions: VoiceInstructions): Flow<SpeechAnnouncement> =
callbackFlow {
val speechCallback =
MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>> { value ->
val speechAnnouncement = value.value?.announcement ?: value.error!!.fallback
mapboxVoiceInstructionsPlayer.play(speechAnnouncement) {
mapboxSpeechApi.clean(it)
trySend(speechAnnouncement).onSuccess {
close()
}.onFailure {
close()
}
}
}
mapboxSpeechApi.generate(voiceInstructions, speechCallback)
awaitClose {
mapboxSpeechApi.cancel()
mapboxVoiceInstructionsPlayer.clear()
}
private suspend fun MapboxSpeechApi.generate(
instructions: VoiceInstructions
): SpeechAnnouncement = suspendCancellableCoroutine { cont ->
generate(instructions) { value ->
val announcement = value.value?.announcement ?: value.error!!.fallback
cont.resume(announcement)
}
cont.invokeOnCancellation { cancel() }
}

private suspend fun MapboxVoiceInstructionsPlayer.play(
announcement: SpeechAnnouncement
): SpeechAnnouncement = suspendCancellableCoroutine { cont ->
play(announcement) {
cont.resume(announcement)
}
cont.invokeOnCancellation { clear() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import com.mapbox.navigation.ui.voice.internal.MapboxVoiceInstructionsState
import com.mapbox.navigation.ui.voice.internal.impl.MapboxAudioGuidanceServices
import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach

class TestMapboxAudioGuidanceServices(
private val deviceLanguage: String = "en"
Expand All @@ -34,20 +34,19 @@ class TestMapboxAudioGuidanceServices(
}

private val mapboxAudioGuidanceVoice = mockk<MapboxAudioGuidanceVoice> {
every { speak(any()) } answers {
coEvery { speak(any()) } coAnswers {
val voiceInstructions = firstArg<VoiceInstructions?>()
val speechAnnouncement: SpeechAnnouncement? = voiceInstructions?.let {
mockk {
every { announcement } returns it.announcement()!!
every { ssmlAnnouncement } returns it.ssmlAnnouncement()
}
}
flowOf(speechAnnouncement).onEach {
if (it != null) {
// Simulate a real speech announcement by delaying the TestCoroutineScope
delay(SPEECH_ANNOUNCEMENT_DELAY_MS)
}
if (speechAnnouncement != null) {
// Simulate a real speech announcement by delaying the TestCoroutineScope
delay(SPEECH_ANNOUNCEMENT_DELAY_MS)
}
speechAnnouncement
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
Expand All @@ -27,28 +28,28 @@ class MapboxAudioGuidanceVoiceTest {
val coroutineRule = MainCoroutineRule()

private val speechApi = mockk<MapboxSpeechApi>(relaxUnitFun = true)
private val voiceInstructionsPlayer = mockk<MapboxVoiceInstructionsPlayer>(relaxUnitFun = true)
private val carAppAudioGuidanceVoice = MapboxAudioGuidanceVoice(
private val voiceInstructionsPlayer = mockk<MapboxVoiceInstructionsPlayer>(relaxed = true)
private val sut = MapboxAudioGuidanceVoice(
speechApi,
voiceInstructionsPlayer
)

@Test
fun `voice instruction should be played as SpeechAnnouncement`() = coroutineRule.runBlockingTest {
mockSuccessfulSpeechApi()
mockSuccessfulVoiceInstructionsPlayer()
fun `voice instruction should be played as SpeechAnnouncement`() =
coroutineRule.runBlockingTest {
mockSuccessfulSpeechApi()
mockSuccessfulVoiceInstructionsPlayer()

val voiceInstructions = mockk<VoiceInstructions> {
every { announcement() } returns "Turn right on Market"
}
carAppAudioGuidanceVoice.speak(voiceInstructions).collect { speechAnnouncement ->
val voiceInstructions = mockk<VoiceInstructions> {
every { announcement() } returns "Turn right on Market"
}
val speechAnnouncement = sut.speak(voiceInstructions)
assertEquals("Turn right on Market", speechAnnouncement!!.announcement)
}
}

@Test
fun `null should clean up the api and player`() = coroutineRule.runBlockingTest {
carAppAudioGuidanceVoice.speak(null).collect()
sut.speak(null)

verify { speechApi.cancel() }
verify { voiceInstructionsPlayer.clear() }
Expand All @@ -70,18 +71,44 @@ class MapboxAudioGuidanceVoiceTest {
val voiceInstructions = mockk<VoiceInstructions> {
every { announcement() } returns "This message fails"
}
carAppAudioGuidanceVoice.speak(voiceInstructions).collect { speechAnnouncement ->
assertEquals("Turn right on Market", speechAnnouncement!!.announcement)
}
val speechAnnouncement = sut.speak(voiceInstructions)
assertEquals("Turn right on Market", speechAnnouncement!!.announcement)
}

@Test
fun `should wait until previous instruction finishes playback before playing next one`() =
coroutineRule.runBlockingTest {
mockSuccessfulSpeechApi()
every { voiceInstructionsPlayer.play(any(), any()) } answers {
launch {
val speechAnnouncement = firstArg<SpeechAnnouncement>()
delay(1000) // simulate 1 second announcement playback duration
secondArg<MapboxNavigationConsumer<SpeechAnnouncement>>()
.accept(speechAnnouncement)
}
Unit
}

val played = mutableListOf<SpeechAnnouncement?>()
launch {
listOf(
VoiceInstructions.builder().announcement("A").build(),
VoiceInstructions.builder().announcement("B").build()
).forEach {
val announcement = sut.speak(it) // suspend until playback finishes
played.add(announcement)
}
}
advanceTimeBy(1500) // advance time to 50% of announcement B playback time

assertEquals(1, played.size)
}

private fun mockSuccessfulSpeechApi() {
every { speechApi.generate(any(), any()) } answers {
val announcementArg = firstArg<VoiceInstructions>().announcement()
val speechValue = mockk<SpeechValue> {
every { announcement } returns mockk {
every { announcement } returns announcementArg!!
}
every { announcement } returns SpeechAnnouncement.Builder(announcementArg!!).build()
}
val consumer = secondArg<MapboxNavigationConsumer<Expected<SpeechError, SpeechValue>>>()
consumer.accept(ExpectedFactory.createValue(speechValue))
Expand Down