Skip to content
Merged
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
Binary file added app/src/main/assets/sounds/error.flac
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.example.ava.esphome.Connected
import com.example.ava.esphome.EspHomeState
import com.example.ava.players.AudioPlayer
import com.example.esphomeproto.api.VoiceAssistantEvent
import com.example.esphomeproto.api.VoiceAssistantEventResponse
import com.example.esphomeproto.api.voiceAssistantAnnounceFinished
Expand All @@ -22,7 +21,7 @@ import timber.log.Timber
@OptIn(UnstableApi::class)
class VoicePipeline(
private val scope: CoroutineScope,
private val player: AudioPlayer,
private val player: VoiceSatellitePlayer,
private val sendMessage: suspend (MessageLite) -> Unit,
private val listeningChanged: (listening: Boolean) -> Unit,
private val stateChanged: (state: EspHomeState) -> Unit,
Expand Down Expand Up @@ -53,7 +52,7 @@ class VoicePipeline(
suspend fun stop() {
val state = _state
if (state is Responding) {
player.stop()
player.ttsPlayer.stop()
sendMessage(voiceAssistantAnnounceFinished { })
} else if (isRunning) {
sendMessage(voiceAssistantRequest { start = false })
Expand All @@ -79,7 +78,7 @@ class VoicePipeline(
ttsStreamUrl = voiceEvent.dataList.firstOrNull { data -> data.name == "url" }?.value
// Init the player early so it gains system audio focus, this ducks any
// background audio whilst the microphone is capturing voice
player.init()
player.ttsPlayer.init()
updateState(Listening)
}

Expand All @@ -93,7 +92,7 @@ class VoicePipeline(
if (voiceEvent.dataList.firstOrNull { data -> data.name == "tts_start_streaming" }?.value == "1") {
ttsStreamUrl?.let {
ttsPlayed = true
player.play(it) { scope.launch { fireEnded() } }
player.ttsPlayer.play(it) { scope.launch { fireEnded() } }
}
}
}
Expand All @@ -116,7 +115,7 @@ class VoicePipeline(
if (!ttsPlayed) {
voiceEvent.dataList.firstOrNull { data -> data.name == "url" }?.value?.let {
ttsPlayed = true
player.play(it) { scope.launch { fireEnded() } }
player.ttsPlayer.play(it) { scope.launch { fireEnded() } }
}
}
}
Expand All @@ -128,6 +127,16 @@ class VoicePipeline(
fireEnded()
}

VoiceAssistantEvent.VOICE_ASSISTANT_ERROR -> {
val code = voiceEvent.dataList.firstOrNull { it.name == "code" }?.value ?: "unknown"
val message = voiceEvent.dataList.firstOrNull { it.name == "message" }?.value ?: "Unknown error"
Timber.w("Voice assistant error ($code): $message")
updateState(VoiceError(message))
player.playErrorSound {
scope.launch { fireEnded() }
}
}

else -> {
Timber.d("Unhandled voice assistant event: ${voiceEvent.eventType}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ data object Listening : EspHomeState
data object Responding : EspHomeState
data object Processing : EspHomeState

data class VoiceError(val message: String) : EspHomeState

class VoiceSatellite(
coroutineContext: CoroutineContext,
name: String,
Expand Down Expand Up @@ -268,7 +270,7 @@ class VoiceSatellite(

private fun createPipeline() = VoicePipeline(
scope = scope,
player = player.ttsPlayer,
player = player,
sendMessage = { sendMessage(it) },
listeningChanged = {
if (it) player.duck()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ interface VoiceSatellitePlayer : AutoCloseable {
*/
val repeatTimerFinishedSound: SettingState<Boolean>

/**
* Whether to enable the error sound.
*/
val enableErrorSound: SettingState<Boolean>

/**
* The error sound to play when a voice assistant error occurs.
*/
val errorSound: SettingState<String>

/**
* The playback volume.
*/
Expand Down Expand Up @@ -78,6 +88,11 @@ interface VoiceSatellitePlayer : AutoCloseable {
*/
suspend fun playTimerFinishedSound(onCompletion: () -> Unit = {})

/**
* Plays the error sound if [errorSound] is not null.
*/
suspend fun playErrorSound(onCompletion: () -> Unit = {})

/**
* Ducks the media player volume.
*/
Expand All @@ -97,6 +112,8 @@ class VoiceSatellitePlayerImpl(
override val wakeSound: SettingState<String>,
override val timerFinishedSound: SettingState<String>,
override val repeatTimerFinishedSound: SettingState<Boolean>,
override val enableErrorSound: SettingState<Boolean>,
override val errorSound: SettingState<String>,
private val duckMultiplier: Float = 0.5f
) : VoiceSatellitePlayer {
private var _isDucked = false
Expand Down Expand Up @@ -147,6 +164,12 @@ class VoiceSatellitePlayerImpl(
ttsPlayer.play(timerFinishedSound.get(), onCompletion)
}

override suspend fun playErrorSound(onCompletion: () -> Unit) {
if (enableErrorSound.get()) {
ttsPlayer.play(errorSound.get(), onCompletion)
} else onCompletion()
}

override fun duck() {
_isDucked = true
if (!_muted.value) {
Expand All @@ -166,4 +189,3 @@ class VoiceSatellitePlayerImpl(
mediaPlayer.close()
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ class VoiceSatelliteService() : LifecycleService() {
enableWakeSound = playerSettingsStore.enableWakeSound,
wakeSound = playerSettingsStore.wakeSound,
timerFinishedSound = playerSettingsStore.timerFinishedSound,
repeatTimerFinishedSound = playerSettingsStore.repeatTimerFinishedSound
repeatTimerFinishedSound = playerSettingsStore.repeatTimerFinishedSound,
enableErrorSound = playerSettingsStore.enableErrorSound,
errorSound = playerSettingsStore.errorSound
).apply {
setVolume(playerSettings.volume)
setMuted(playerSettings.muted)
Expand Down
22 changes: 21 additions & 1 deletion app/src/main/java/com/example/ava/settings/PlayerSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import javax.inject.Singleton

const val defaultWakeSound = "asset:///sounds/wake_word_triggered.flac"
const val defaultTimerFinishedSound = "asset:///sounds/timer_finished.flac"

const val defaultErrorSound = "asset:///sounds/error.flac"

@Serializable
data class PlayerSettings(
Expand All @@ -23,6 +23,8 @@ data class PlayerSettings(
val wakeSound: String = defaultWakeSound,
val timerFinishedSound: String = defaultTimerFinishedSound,
val repeatTimerFinishedSound: Boolean = true,
val enableErrorSound: Boolean = false,
val errorSound: String = defaultErrorSound,
)

private val DEFAULT = PlayerSettings()
Expand Down Expand Up @@ -68,6 +70,16 @@ interface PlayerSettingsStore : SettingsStore<PlayerSettings> {
* Whether the timer alarm repeats until the user stops it.
*/
val repeatTimerFinishedSound: SettingState<Boolean>

/**
* Whether the error sound should be played when an error occurs.
*/
val enableErrorSound: SettingState<Boolean>

/**
* The path to the error sound file.
*/
val errorSound: SettingState<String>
}

@Singleton
Expand Down Expand Up @@ -102,4 +114,12 @@ class PlayerSettingsStoreImpl @Inject constructor(@ApplicationContext context: C
SettingState(getFlow().map { it.repeatTimerFinishedSound }) { value ->
update { it.copy(repeatTimerFinishedSound = value) }
}

override val enableErrorSound = SettingState(getFlow().map { it.enableErrorSound }) { value ->
update { it.copy(enableErrorSound = value) }
}

override val errorSound = SettingState(getFlow().map { it.errorSound }) { value ->
update { it.copy(errorSound = value) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.example.ava.R
import com.example.ava.settings.MicrophoneSettingsStore
import com.example.ava.settings.PlayerSettingsStore
import com.example.ava.settings.VoiceSatelliteSettingsStore
import com.example.ava.settings.defaultErrorSound
import com.example.ava.settings.defaultTimerFinishedSound
import com.example.ava.settings.defaultWakeSound
import com.example.ava.wakewords.models.WakeWordWithId
Expand Down Expand Up @@ -148,6 +149,24 @@ class SettingsViewModel @Inject constructor(
playerSettingsStore.repeatTimerFinishedSound.set(repeatTimerFinishedSound)
}

suspend fun saveEnableErrorSound(enableErrorSound: Boolean) {
playerSettingsStore.enableErrorSound.set(enableErrorSound)
}

suspend fun saveErrorSound(uri: Uri?) {
if (uri != null) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
playerSettingsStore.errorSound.set(uri.toString())
}
}

suspend fun resetErrorSound() {
playerSettingsStore.errorSound.set(defaultErrorSound)
}

fun validateName(name: String): String? =
if (name.isBlank())
context.getString(R.string.validation_voice_satellite_name_empty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,5 +202,40 @@ fun VoiceSatelliteSettings(
}
)
}
item {
HorizontalDivider()
}
item {
SwitchSetting(
name = stringResource(R.string.label_voice_satellite_enable_error_sound),
description = stringResource(R.string.description_voice_satellite_enable_error_sound),
value = playerState?.enableErrorSound ?: false,
enabled = enabled,
onCheckedChange = {
coroutineScope.launch {
viewModel.saveEnableErrorSound(it)
}
}
)
}
item {
DocumentSetting(
name = stringResource(R.string.label_custom_error_sound),
description = stringResource(R.string.description_custom_error_sound_location),
value = playerState?.errorSound?.toUri(),
enabled = enabled,
mimeTypes = arrayOf("audio/*"),
onResult = {
if (it != null) {
coroutineScope.launch {
viewModel.saveErrorSound(it)
}
}
},
onClearRequest = {
coroutineScope.launch { viewModel.resetErrorSound() }
}
)
}
}
}
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
<string name="description_custom_timer_sound_location">Specify a different file to play</string>
<string name="label_timer_sound_repeat">Repeat timer sound</string>
<string name="description_timer_sound_repeat">If enabled, repeat the sound until you say "stop"</string>
<string name="label_voice_satellite_enable_error_sound">Enable error sound</string>
<string name="description_voice_satellite_enable_error_sound">Play a sound when a voice assistant error occurs</string>
<string name="label_custom_error_sound">Custom error sound</string>
<string name="description_custom_error_sound_location">Specify a different file to play when a voice assistant error occurs</string>

<string name="validation_voice_satellite_name_empty">Name cannot be empty</string>
<string name="validation_voice_satellite_port_invalid">Port must be between 1 and 65535</string>
Expand Down
56 changes: 43 additions & 13 deletions app/src/test/java/com/example/ava/VoicePipelineTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import com.example.ava.esphome.EspHomeState
import com.example.ava.esphome.voicesatellite.Listening
import com.example.ava.esphome.voicesatellite.Responding
import com.example.ava.esphome.voicesatellite.VoicePipeline
import com.example.ava.players.AudioPlayer
import com.example.ava.stubs.StubAudioPlayer
import com.example.ava.stubs.StubVoiceSatellitePlayer
import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished
import com.example.esphomeproto.api.VoiceAssistantAudio
import com.example.esphomeproto.api.VoiceAssistantEvent
Expand All @@ -22,7 +24,7 @@ import kotlin.test.assertEquals

class VoicePipelineTest {
fun TestScope.createPipeline(
player: StubAudioPlayer = StubAudioPlayer(),
player: StubVoiceSatellitePlayer = StubVoiceSatellitePlayer(),
sendMessage: suspend (MessageLite) -> Unit = {},
listeningChanged: (Boolean) -> Unit = {},
stateChanged: (EspHomeState) -> Unit = {},
Expand Down Expand Up @@ -149,10 +151,13 @@ class VoicePipelineTest {
val notTtsStreamUrl = "not_tts_stream"
var playbackUrl: String? = null
val pipeline = createPipeline(
player = object : StubAudioPlayer() {
override fun play(mediaUri: String, onCompletion: () -> Unit) {
playbackUrl = mediaUri
}
player = object : StubVoiceSatellitePlayer() {
override val ttsPlayer: AudioPlayer
get() = object : StubAudioPlayer() {
override fun play(mediaUri: String, onCompletion: () -> Unit) {
playbackUrl = mediaUri
}
}
}
)

Expand All @@ -178,10 +183,13 @@ class VoicePipelineTest {
val notTtsStreamUrl = "not_tts_stream"
var playbackUrl: String? = null
val pipeline = createPipeline(
player = object : StubAudioPlayer() {
override fun play(mediaUri: String, onCompletion: () -> Unit) {
playbackUrl = mediaUri
}
player = object : StubVoiceSatellitePlayer() {
override val ttsPlayer: AudioPlayer
get() = object : StubAudioPlayer() {
override fun play(mediaUri: String, onCompletion: () -> Unit) {
playbackUrl = mediaUri
}
}
}
)

Expand Down Expand Up @@ -217,10 +225,13 @@ class VoicePipelineTest {
var ended = false
var playerCompletion: () -> Unit = {}
val pipeline = createPipeline(
player = object : StubAudioPlayer() {
override fun play(mediaUri: String, onCompletion: () -> Unit) {
playerCompletion = onCompletion
}
player = object : StubVoiceSatellitePlayer() {
override val ttsPlayer: AudioPlayer
get() = object : StubAudioPlayer() {
override fun play(mediaUri: String, onCompletion: () -> Unit) {
playerCompletion = onCompletion
}
}
},
ended = { ended = true }
)
Expand Down Expand Up @@ -312,4 +323,23 @@ class VoicePipelineTest {
pipeline.stop()
assertEquals(1, sentMessages.size)
}

@Test
fun should_play_error_sound_on_pipeline_error() = runTest {
var errorPlayed = false
val pipeline = createPipeline(
player = object : StubVoiceSatellitePlayer() {
override suspend fun playErrorSound(onCompletion: () -> Unit) {
errorPlayed = true
}
}
)

// Should play the error sound
pipeline.handleEvent(voiceAssistantEventResponse {
eventType = VoiceAssistantEvent.VOICE_ASSISTANT_ERROR
})

assert(errorPlayed)
}
}
Loading
Loading