diff --git a/app/src/main/assets/sounds/error.flac b/app/src/main/assets/sounds/error.flac new file mode 100644 index 0000000..f6a808c Binary files /dev/null and b/app/src/main/assets/sounds/error.flac differ diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt index 25379e4..0a30b3b 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt @@ -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 @@ -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, @@ -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 }) @@ -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) } @@ -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() } } } } } @@ -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() } } } } } @@ -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}") } diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt index ef3abf5..7988550 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt @@ -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, @@ -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() diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt index e220e63..77e156f 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt @@ -39,6 +39,16 @@ interface VoiceSatellitePlayer : AutoCloseable { */ val repeatTimerFinishedSound: SettingState + /** + * Whether to enable the error sound. + */ + val enableErrorSound: SettingState + + /** + * The error sound to play when a voice assistant error occurs. + */ + val errorSound: SettingState + /** * The playback volume. */ @@ -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. */ @@ -97,6 +112,8 @@ class VoiceSatellitePlayerImpl( override val wakeSound: SettingState, override val timerFinishedSound: SettingState, override val repeatTimerFinishedSound: SettingState, + override val enableErrorSound: SettingState, + override val errorSound: SettingState, private val duckMultiplier: Float = 0.5f ) : VoiceSatellitePlayer { private var _isDucked = false @@ -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) { @@ -166,4 +189,3 @@ class VoiceSatellitePlayerImpl( mediaPlayer.close() } } - diff --git a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt index 9aa8fad..5158aaa 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -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) diff --git a/app/src/main/java/com/example/ava/settings/PlayerSettings.kt b/app/src/main/java/com/example/ava/settings/PlayerSettings.kt index cf012f3..0476140 100644 --- a/app/src/main/java/com/example/ava/settings/PlayerSettings.kt +++ b/app/src/main/java/com/example/ava/settings/PlayerSettings.kt @@ -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( @@ -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() @@ -68,6 +70,16 @@ interface PlayerSettingsStore : SettingsStore { * Whether the timer alarm repeats until the user stops it. */ val repeatTimerFinishedSound: SettingState + + /** + * Whether the error sound should be played when an error occurs. + */ + val enableErrorSound: SettingState + + /** + * The path to the error sound file. + */ + val errorSound: SettingState } @Singleton @@ -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) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt index f177ca2..6c16a4e 100644 --- a/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/example/ava/ui/screens/settings/SettingsViewModel.kt @@ -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 @@ -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) diff --git a/app/src/main/java/com/example/ava/ui/screens/settings/VoiceSatelliteSettings.kt b/app/src/main/java/com/example/ava/ui/screens/settings/VoiceSatelliteSettings.kt index 8c89bea..1191268 100644 --- a/app/src/main/java/com/example/ava/ui/screens/settings/VoiceSatelliteSettings.kt +++ b/app/src/main/java/com/example/ava/ui/screens/settings/VoiceSatelliteSettings.kt @@ -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() } + } + ) + } } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbc365c..549e6a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,10 @@ Specify a different file to play Repeat timer sound If enabled, repeat the sound until you say "stop" + Enable error sound + Play a sound when a voice assistant error occurs + Custom error sound + Specify a different file to play when a voice assistant error occurs Name cannot be empty Port must be between 1 and 65535 diff --git a/app/src/test/java/com/example/ava/VoicePipelineTest.kt b/app/src/test/java/com/example/ava/VoicePipelineTest.kt index 0df0f3b..d9d10d0 100644 --- a/app/src/test/java/com/example/ava/VoicePipelineTest.kt +++ b/app/src/test/java/com/example/ava/VoicePipelineTest.kt @@ -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 @@ -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 = {}, @@ -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 + } + } } ) @@ -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 + } + } } ) @@ -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 } ) @@ -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) + } } \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt b/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt index 2634ed9..63adcfc 100644 --- a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt @@ -15,6 +15,8 @@ class VoiceSatellitePlayerTest { wakeSound: SettingState = stubSettingState(""), timerFinishedSound: SettingState = stubSettingState(""), repeatTimerFinishedSound: SettingState = stubSettingState(true), + enableErrorSound: SettingState = stubSettingState(false), + errorSound: SettingState = stubSettingState(""), duckMultiplier: Float = 1f ) = VoiceSatellitePlayerImpl( ttsPlayer = ttsPlayer, @@ -23,6 +25,8 @@ class VoiceSatellitePlayerTest { wakeSound = wakeSound, timerFinishedSound = timerFinishedSound, repeatTimerFinishedSound = repeatTimerFinishedSound, + enableErrorSound = enableErrorSound, + errorSound = errorSound, duckMultiplier = duckMultiplier ) diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt index 2883279..0e0a6db 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt @@ -12,7 +12,9 @@ open class StubVoiceSatellitePlayer( override val enableWakeSound: SettingState = stubSettingState(true), override val wakeSound: SettingState = stubSettingState(""), override val timerFinishedSound: SettingState = stubSettingState(""), - override val repeatTimerFinishedSound: SettingState = stubSettingState(true) + override val repeatTimerFinishedSound: SettingState = stubSettingState(true), + override val enableErrorSound: SettingState = stubSettingState(false), + override val errorSound: SettingState = stubSettingState("") ) : VoiceSatellitePlayer { protected val _volume = MutableStateFlow(1.0f) override val volume: StateFlow = _volume @@ -42,6 +44,10 @@ open class StubVoiceSatellitePlayer( ttsPlayer.play(timerFinishedSound.get(), onCompletion) } + override suspend fun playErrorSound(onCompletion: () -> Unit) { + ttsPlayer.play(errorSound.get(), onCompletion) + } + override fun duck() {} override fun unDuck() {}