From e5b4100c21ce7d21bb5cb4c8994cdc3255c73dd4 Mon Sep 17 00:00:00 2001 From: Xavier Vello Date: Tue, 10 Mar 2026 14:47:02 +0100 Subject: [PATCH 1/2] add optional error sound --- .../esphome/voicesatellite/VoicePipeline.kt | 21 ++++++++--- .../esphome/voicesatellite/VoiceSatellite.kt | 4 +- .../voicesatellite/VoiceSatellitePlayer.kt | 18 ++++++++- .../ava/services/VoiceSatelliteService.kt | 3 +- .../example/ava/settings/PlayerSettings.kt | 10 +++++ .../ui/screens/settings/SettingsViewModel.kt | 14 +++++++ .../settings/VoiceSatelliteSettings.kt | 22 +++++++++++ app/src/main/res/values/strings.xml | 2 + .../java/com/example/ava/VoicePipelineTest.kt | 37 ++++++++++++------- .../example/ava/VoiceSatellitePlayerTest.kt | 2 + .../ava/stubs/StubVoiceSatellitePlayer.kt | 7 +++- 11 files changed, 117 insertions(+), 23 deletions(-) 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..a936e12 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,11 @@ interface VoiceSatellitePlayer : AutoCloseable { */ val repeatTimerFinishedSound: SettingState + /** + * The error sound to play when a voice assistant error occurs. + */ + val errorSound: SettingState + /** * The playback volume. */ @@ -78,6 +83,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 +107,7 @@ class VoiceSatellitePlayerImpl( override val wakeSound: SettingState, override val timerFinishedSound: SettingState, override val repeatTimerFinishedSound: SettingState, + override val errorSound: SettingState, private val duckMultiplier: Float = 0.5f ) : VoiceSatellitePlayer { private var _isDucked = false @@ -147,6 +158,12 @@ class VoiceSatellitePlayerImpl( ttsPlayer.play(timerFinishedSound.get(), onCompletion) } + override suspend fun playErrorSound(onCompletion: () -> Unit) { + errorSound.get()?.let { + ttsPlayer.play(it, onCompletion) + } ?: onCompletion() + } + override fun duck() { _isDucked = true if (!_muted.value) { @@ -166,4 +183,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..d8b1f30 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,8 @@ class VoiceSatelliteService() : LifecycleService() { enableWakeSound = playerSettingsStore.enableWakeSound, wakeSound = playerSettingsStore.wakeSound, timerFinishedSound = playerSettingsStore.timerFinishedSound, - repeatTimerFinishedSound = playerSettingsStore.repeatTimerFinishedSound + repeatTimerFinishedSound = playerSettingsStore.repeatTimerFinishedSound, + 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..3f7a907 100644 --- a/app/src/main/java/com/example/ava/settings/PlayerSettings.kt +++ b/app/src/main/java/com/example/ava/settings/PlayerSettings.kt @@ -23,6 +23,7 @@ data class PlayerSettings( val wakeSound: String = defaultWakeSound, val timerFinishedSound: String = defaultTimerFinishedSound, val repeatTimerFinishedSound: Boolean = true, + val errorSound: String? = null, ) private val DEFAULT = PlayerSettings() @@ -68,6 +69,11 @@ interface PlayerSettingsStore : SettingsStore { * Whether the timer alarm repeats until the user stops it. */ val repeatTimerFinishedSound: SettingState + + /** + * The path to the error sound file. + */ + val errorSound: SettingState } @Singleton @@ -102,4 +108,8 @@ class PlayerSettingsStoreImpl @Inject constructor(@ApplicationContext context: C SettingState(getFlow().map { it.repeatTimerFinishedSound }) { value -> update { it.copy(repeatTimerFinishedSound = 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..d7d93cf 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 @@ -148,6 +148,20 @@ class SettingsViewModel @Inject constructor( playerSettingsStore.repeatTimerFinishedSound.set(repeatTimerFinishedSound) } + 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(null) + } + 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..6f784bc 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,27 @@ fun VoiceSatelliteSettings( } ) } + item { + HorizontalDivider() + } + 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..4a39aab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,8 @@ Specify a different file to play Repeat timer sound If enabled, repeat the sound until you say "stop" + Custom error sound + Specify a 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..629effb 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 } ) diff --git a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt b/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt index 2634ed9..24319cf 100644 --- a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt @@ -15,6 +15,7 @@ class VoiceSatellitePlayerTest { wakeSound: SettingState = stubSettingState(""), timerFinishedSound: SettingState = stubSettingState(""), repeatTimerFinishedSound: SettingState = stubSettingState(true), + errorSound: SettingState = stubSettingState(null), duckMultiplier: Float = 1f ) = VoiceSatellitePlayerImpl( ttsPlayer = ttsPlayer, @@ -23,6 +24,7 @@ class VoiceSatellitePlayerTest { wakeSound = wakeSound, timerFinishedSound = timerFinishedSound, repeatTimerFinishedSound = repeatTimerFinishedSound, + 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..98bf56f 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,8 @@ 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 errorSound: SettingState = stubSettingState(null) ) : VoiceSatellitePlayer { protected val _volume = MutableStateFlow(1.0f) override val volume: StateFlow = _volume @@ -42,6 +43,10 @@ open class StubVoiceSatellitePlayer( ttsPlayer.play(timerFinishedSound.get(), onCompletion) } + override suspend fun playErrorSound(onCompletion: () -> Unit) { + errorSound.get()?.let { ttsPlayer.play(it, onCompletion) } ?: onCompletion() + } + override fun duck() {} override fun unDuck() {} From c43e2390df37f8e56ac9ca1cf7578900b7921afa Mon Sep 17 00:00:00 2001 From: brownard Date: Tue, 17 Mar 2026 22:45:05 +0000 Subject: [PATCH 2/2] Add a default error sound and a setting to enable/disable playback on error The error sound is taken from here - https://pixabay.com/sound-effects/film-special-effects-ui-sounds-pack-4-12-359738/ I believe sharing and distribution is allowed by the pixabay licence. --- app/src/main/assets/sounds/error.flac | Bin 0 -> 18020 bytes .../voicesatellite/VoiceSatellitePlayer.kt | 16 ++++++++----- .../ava/services/VoiceSatelliteService.kt | 1 + .../example/ava/settings/PlayerSettings.kt | 16 ++++++++++--- .../ui/screens/settings/SettingsViewModel.kt | 7 +++++- .../settings/VoiceSatelliteSettings.kt | 13 +++++++++++ app/src/main/res/values/strings.xml | 4 +++- .../java/com/example/ava/VoicePipelineTest.kt | 21 +++++++++++++++++- .../example/ava/VoiceSatellitePlayerTest.kt | 4 +++- .../ava/stubs/StubVoiceSatellitePlayer.kt | 5 +++-- 10 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 app/src/main/assets/sounds/error.flac diff --git a/app/src/main/assets/sounds/error.flac b/app/src/main/assets/sounds/error.flac new file mode 100644 index 0000000000000000000000000000000000000000..f6a808c23840e0df57a6495fd8df63ef1fe4aa46 GIT binary patch literal 18020 zcmeI$)ms!^)Gu(vp_`#Q1{}Iuas~tj5QgqXx=_e+T6MwXLhQn~klf^#3&sL1X%F z^i)p*PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@PXbQ@ zPXbQ@PXbQ@PXhnH3q0N$#-pui{J);D%Qi6*E7bqt`{RQy3voYP7187WQPUF~S-)`& z3>?_P@gpKBC>Zf@0SZz0$gI!N0F|?bq9_FgCKCP(3WYT6q)V`!rCL!$ggBH?1+1zJ zO|fYxf)}Tt##Iz;D=MNbz1fCPS!}ArmQEow9JW-jn2IV@U=h&3j1_koFp3JrhuUX+ z&dp?H4xzU1qzu7i+Rg+PPlr9jHw>ZHtfXUUVpSc(O-{w*!4?dTR0$OaV1fanY|&xZ z(>en$8@0*7tjR3G;Zz}*U{nJXQxkw09EQh2)d0XmC}?|~)2%oFD~r8|ySQxRA>5V< zhHYhZG0K`2w~C6`Wa6dWi;MrbWrX`E8mV;L=K|^`%sJZn|C)&(e>VR$6!h#gTtw-ujOW zH#);T1swN0`l1w;>h8WmsjkJM0U?+(48#=V``S?M@)9^x@^dvN-x%>`M6$w2sqoqO z43L&x20xIha3)tc@iZGkY9^`Nc2Uzf9`2?ck(IEWW-o4t6B)N!$JbU1`Zw2Mqi{H^ zNIwIec;h5?;`&ybIwM&DuOoSh>Xn{8Dbh*1Z?ZZR^MboZk6f#^jR#UnnL<%zOOl;; zH{G*q0NokMa8A}zx_19-&cK_g+;{1SL!OpQK}#06V`a}vte<#$?zxZ`pevBvNSYb2 zZjWy?Qm@Av@mnFqI<(3uzdK*GtF4)_@q-54orM-*r)^HW_Uk&JJDlOM;C{N*cexR+ ztu5HT)c#GU1AFFqXG#x{SpmQ3J83sj`K>}lNW9zf=nl4A>qwhv?rOFN$4yrkWL%NS z29OZxpVWp;ci;~@@$?|puL)#olQL0HK{#mZGyS8L8Wb0SkEak4=-{IV(t(ED99jON z#H3xkT=h1kpN%&83LVw$+>Zxxn8;<2(}#XE-qSNyn$*gd(^m8(G5$K-<-f~c-e#lR z2Rg1L@F}JaRnDwCFb61gN_`Ff-VLNC(Fl4xnc5fJqO{YsJ-W&ro8;hm{ofx zltTg%-ZJrT5>}Y6KpG;+1voT14k1k6$7)4YL$!{9;zDv<*_f}k{0p>h$64FR7Bth@ zkvgxY9Iy^(BaO+XrifW8M$29h5f2m?XC_D`I$yqEpSk~}EIYa32Wt6CEUH+vh-anO z83B?WKTh}BUH(|15C=0~p1;kc(j%I=R+?^cS?&cL2@@zUmM+hs>WK4!V%zeu5_3UI z`yYGZqAcQr$FmN$!P_xS6q!k{+ACG`=Dz~*Dhhw-Oa!P9R=H>xE+pnlm?Oa}9kG~q ztXEfBe2LI=l0%w|e?q9T94X&6VMD&ORg&MhEyn6Tq8JO~C|e1dzox{hcKUvu*gw9A z0|N6GzxVOOe@cM_3H+x-H{a_Yrgum&St^FKH!cg;Zurs+9|Jwp& zF&pq;HdS7^=I(X?SAUfH{;spBolm&NwVZ~0$q!})I4f;KoC{daPL64j*}5g=(tRKe zTV9J(foU9lpDT}V{NYac@4+pzqxS|55gjEny(jO3du__e1^QfKPNn}PfAQGTGkkm# zyH3PVRli~+{aNGD22#_s$bwo7Yndmxy_!_{$rprloU2BPU3c$qkL4%*D0CMv=CE57 zvsR15p2Iwv=#N?y?43U>3k08CPn&&r1Q%zsR_nI}ZSIe6->uJ5uOum*-CX1G>K#U! zdSOftB)D2`C~9JdIxWzu`?Y~tv$vXkZyv7PG;XN?N zvmL^!h+*O1LksmtvEGgoQ2lzDwG*R<(_UXP3~R1JiaAIzwb%sD9&wBQH7cqG?AH*l zc(b?zXo=r&XpN<5juBE2jI_vY0+?tjVwoHgb{75^#NJbajId>+v;S!X{3H2hNVisHKH=Am4K$>d>)9iv0?%lojRd#uiAGzipY ztc5KrIA9RCmzSO0*l9p@OIvz|PSLre-`guZ{tZ(!tXdlo(t6|E_PeA3R<))=LG-Km zg&B{fHm|N0i73Z=7&;mbaRl$U^)8;t5n*Zy$VR+<&Eey!rMLQ(Uv-=QI!5owjsS$c zgw@4StvZ@n-NyQrNiOkU%;r(&XE>JYTU4n$iX}*}eC7p#xQul^%Qnxjf{+67z4_c= z&CuvNU|o2gEJVJabX)C_?9#go5PimW8vOd^1NUEd2<(ze#XQ%yh*kp5aPV96P#zd+ z@>PnTBA*3?YGDbD9C? z2BpPrja13~tBk_!n?4fp2V<&QIw+;^qX=5@ha--)&gz9e9hmi-=Ie9n7vw)s!P~^~ z87W5QE5oDeb8{I8<1jf{y2PWC(h5VFn)$BS%v2|Yq~4!0s~+)a=QsrZcor29fHL$X?CJ2>Zr(PWn_?6{DH zxLd{ON=pM(*Cm|FAMj%QL)>i;s90t!p<#|vY&S=G7jaxr1r;49j@Iv8T`0ER}WN; zV2Ek=OGs!x=<$27-#UvTf?1%pF|Yh%N#qRTs5e(p7^=X30V zHjJhzXz8E1Ep$!Hyof0hmOxdW)^Jm*^^)GjR>&RFk&R*2$;TbRza(TcJY&jrtnpxq zcvi=2IUvLol1$TlB~7{cyCo0PaT2I-?(=Djw>WKl`%_eIDI_c66hpB(D-kNDn|Tk{BT$U2xuSW6MC}xq7=)bK5SOkwm$0a(Y|N1@&*? z3q03xS>Qr}E7s)=-@Gpt-6OS7vXS(U93}dXmTSOh`+UIpK3^C9a+>zxC>#?pwzj(s zt0j^x$-#j`NwUuR7loe{fT^Vnjig(uoj`fIB08PRq5H%*_-N6m#Ae`oRTgyNF5)do zuZfh`{SfFE zhGsMxLMg@k z=nydSyX*zW6yAZY^~kXrE{dZ3YB@|97dsFBWU91Xv-mI;C-R#Sxp>HW5;Wa9k=lSd zbF_D&_FgB#$pjtEhvEdGI4kiS1&2Ou(R*Zh8&~mc#G16m|1z8 zxY2$ z{%9A9t%brrD}>Tu@_t>oL6m(8Edb-cZTqN8VW@gurZnb3VwUiH8L|!f>|_Fp9*&LN z#alga@#r z@)3D-@NaAnE7st$#HB}K>Kjh5QVt-L@4ypyYY6BsGeB~c%ortT=!`n~@04bL1UzuhuW39;3ty-e-EYDwuPm6qcwQ(!YHeMM&HL%a|-HwZZ@t zmR*~7vkb8ljw*C#8duw@pFe5`IUYr%ttcj|;Bbiz_VBY(uqiDi8}{J?GfES}ZI)T$ z)v|mU%Eh$`uG1(ZYsrPDuv*5kIHzCYUOpI5GnJCoD3c2fPAYswKVdf>B{8ruu1A&yl+L)%Iw!BO-u61zUx-Y>`#_4 z)c_uFPua|o7Tw855M2;rq91SlTN&kHrEhulZZv&c}a z0Hvd5jJN6**zCH2MXj5{+<>^T+oC<~j>ewZqUSgQM^z8Jqe$}){G*pvQU4aWm#Qw% zVsW?iCr~)XTAhn&d+SY91?qoe?dlaDUuM1&d!-?2f)2iE#9$go;>VT}#~1uFEVgH) zx5pd7!>tzJ`zEjBvA4BEGT~(n<84sT$d}F>l7@S21_^{a#hI`hNlT@C%Clbr+*+Er zGx@tsl|7*3*k=ry=)9m)w6w8NlK8e*2NY-aVN90?_JgGr$SrGIG1_kgtgmfXhpSm4 zmbUugXR0={LrU(aQ)ppYy4)alDgC;NmcTw=p^UW&>jLNU)Nsqofmk55(`h*OC$P+4 z`v|F=Q=dgOLm_p72L)SoRM}c~rjMQ;toA1Q5mRSjg@# z5L5w)-)oWB5TSVXAc6X|Q;%&JAE_c&FG-OV)<*X+qn%z&~KuUb;kKj|hD|48rdri(It>>`Sb(qd#k zOQ9npz|=yPd-2L?!r_5yIT}k5F#Pi~32S2lU93;vxWp~H2w?UI524ngPF#8@7?hOG z2q{@ak6P_*w#Cl$ii*i)9Fu3wbf6JLdV>8=c4OfNd4ZN{bil8x%gl&Tr@rLYQ1TkTEKaCxRX>i`YRBj6byTe%EZ(dr z;9Z2-rX=65JoXwbrQ+Nd1TfZom%qC1`rvNc)$Kmc-K*Q1>iR|=YUKVaiSR*An6%17 zDwaCNE9-9p(ZrfZ0)N#ql*o9u>eMW1j1Ku!JHsX8@qOf_t(_kvZsaXjQW;i?Is%I1@GW)u-7FW8jTZAz zA6@l^12V?7jis)wa`@YuWe|mMdC>+a3HAo0v{6hy_m??*4q_^`)8dQ|W$*J33#@Yfv`Ewt z*-j_bd_7(yC17G{o(Rt|1G*F4L)}lR6zYch-ti*Vw~l-B30l8Y@opEF{(D{b_4iD0 zsedDhW$|H;sm(BSG~N*u|J_TmWxQ!_d%V(XN>xTZ11D3ElpXs7+fzw2K;+F{$BYAg zck;Y7e;?9-Wbb{0x1s0%W_ftu9&df)pAiI|X#h5-F(^wg)Hg7K(J-poF=lBo?*3si z|K4S`?oJD7XBo)u1#Kk;`qgyUmCHN}0co&S*2E%xwCR~;lO$&9IJnGpRDBIQ_b2uK z?G0>eD?Vn`?j-R9@zb@6vYt9gsp+%Q$G9iFbrvC4pmUU=pyblL+Hu^L7 zdJ-68LJxxGf+i$P=^X7_%gDefm~=n7nZ4te*md|@QAUe!0aG^?g)Y>>^J|UZH>gJp7aODipkI;*QSOi#N-0eL-_T<||lmeZgQM2djl${mlOHvqp0o*=HGKL>aeng`+y0p>tWD z+yLY3+eJJlSOQ5O49b4Z>fFf7`-QWc!o7DVJoDqa4Dan@o-7>+kGmW>P_$>2l{4Hy#ksWYDY14z+ ztc?eajd@m|7k|n*eJKPF__75iPoz;Lm7WTa;QS+|uNyLL>fUmE+82*HV35!{MUj ztp$vT;C4t)7u_rS;6~)T2`fh`17Tz2!19sv-5-K&vdj^Im@kL`Gmv0lp2UMUw5 z81|?p%flKKbphw={)ogP;xIJobB^8Q@)v4vnsh0BI06C8=Qjy*LL4u`T@{X9EZSJ{ z4b0dF1-Gs-6&wm7UZZECXsOA9=)ZA4zx0TB^^1hJnti*9C0AsO_RK9tIzon+uhr4G zYMv&=Yy_HIT9Si*1)bNa%;C`~|5W-y`P&IXm#CEnM%Sd@$ZWGKH6((nOj)-2HPWEQ zU`H%l@PMO*qik;K7#Q%DE~`}Siy%AZ##;85FtUl*qbUQ}(xaxh{QgB&>Bac{Z$(K- z$%+(&iL6W9I(w)eFEgRwLAUYeJyPFvEv{=AgY96Ki*1Z!vl0x)usm((dhZ@aw(75e zuzyF`>->y|!MUN|?mC_Qg`}J*s%qvyX?|+Y&xV}Lryn=*Tcxz(foDK;izpRJ) z5zG?j7j+@1hmn6t5lwYOTf-J-O0jN%jA*6~{#g5cpHq9H2-hN?OWO zsxxWO|7<$xT_~87Q<^~iz3(#?cEPwaBDs77=95Lng9)L3qkoYXddc)~hHDjepU1B5 zyryA?R!&QU~ z)9UA`16Bk_L)*#a16#dD%L=~VBN=8?#7Gwn)^d#M#lM%o#wEUS&XhVnHEkFAX8C6K zj$ZSFRbcNY%R$lj35l&d?9w}$SV?qcFJf(|%}09?zWEY*7DFSzuUnEbD`m;v+@||R zR^V12b^c7Sqikv=_ebuU>!iv%38Z%NS9{8rMsi07eN(y9kdKbVC=QUe%xl5VL~U8%s(BGK^FxRdWe9Mg`26G=W+aaN ztirtA)ZSvVmu3m>ZXM-KINEjV6VG1IXMPTr-&Jd-?|av1J}z0zwbOEL73a0!CPC(H z?#MV~y?`_g3mfwcRG#Or!#j?X5L=kD!FYA? zfKZ?v&)(R-``9h?h(ol!s1ciF($siC_qaI0@GD~-V4K;%L?a`9%6Bw z(ZVpyXGE^jh40=k!dq)3?6W+|&!gY))@t`O<-979wN1DJcwm@gsT$t5LR#}X3w)t`9$Bi_jk zq{5e;yyd%mZ5PQxssb}aR$?kdB9}#?*&Av+lP}NeMp(gUcm%?$&MCd`{4r;8^Jv>T zefOu<(9)~_B^}*XYAdU?iw{Af2###zE_0Vxh#p|yBimS=wB7#!gQ?q3xkFAjR|`z} zPR)eK5)dS=;OX}RCdm=#gU6#;OErLN=ETGnkCo9_cxM@)PlPAVTrrHm;roxpdD5(@moz9@$^cIOvOOTL$o2%#gnSUJ&-{^v|#pw^<`Y|1-X z?rg{qX4X=nQ8MGHp<>{ATTs0cQN$-fM=e#J5|5DW>*te$CiI&*z(G!oOQJhX$V>iBw9XA(^6{|*87kmjjLhWuiBIm zJ-GVg6b~F#qM-$P*VCT%d-89>x1|3djJ5}cxj@!hEqw)}MCuG_t%@HicdC}SG)u4& zN4~pNpBy(@qAw&@cC3^be_ww!vOduz-2Iye;w+)p%Wh4ZYdh)mXYG3w`FWotgq#Y0R7ekZ}S7};lQ!~ zxflGBlC;jOl4;rH(6i&sMf97ohz(B)HnZ5OvJJM!vPy|&$A4YCzk3&xcyM`o7vwU+ zZO4{umt{#$zRegI7+`H_Dc%r!Iu|xrps@usFjt3Ge-`4n5}v-*Bi43+Pj7EhBuKA} zS&h#%5bFHG;pQ2@7S+Gx`l>&tHkheE7NZjz=R|Xm)WnEtd^iQ*Hf!~C?&B$NqUV(I z!GDt3QR6>8lr1m2Ty>Cb{(J{NXi2wtan#xNTSswmc^dvRT)(R376^=Q0Eo%OM4h5}k&8^$luO=jWkLoG&0% zZ&LO)D06w3MQRA`Wk1|+&DLNp6V)z?oNGOtuSnax=P+J)@nQ!iGg9R>DXXjyLv%^}& zw$O+L9KKA&I;<=G#1JTm{~5`~r~XUlYDv;>fGi-}*V{Bw5cFaYjWCqOq2wNDh6$m0>liDlr z_6WP1E?IM$11TT?%E>Y;v8_|soaHMc(T7L^&y6s7+;_?xCc`wF^GApS8g*2e|Tn@u$}w3x*i`pM|Z;xPy(D?T)&KR z!jj*-y3OL|y#BZt=46!!@ifukl>&{`;2*E;Zlt{6HY`;&D5d+YwH`b~L`(gPM19hQ zeX=o>CQo~2nN!catEbMEBIesj&4qs~Z>z&ZFEZWn{8PA=SZ&4q#DIb548K1?S zNuR$fn(6Qztgv5q1@)B|sV4a2hs;;K1?sSDT3%+V!bTDREC|q#>`|qXFL}#2iMTPs;7jrn(a1Z=k|d#QL#wuEgQ-dg6Ygs{9||ioavPH@9V%i69SAeM9-3D!)@qnT<3K}KLb@wX zp&@`)I8+ zCkww4Ni1qJCdFz7k!Ca^LWz!v$8J(eVN_adH6D{ + /** + * Whether to enable the error sound. + */ + val enableErrorSound: SettingState + /** * The error sound to play when a voice assistant error occurs. */ - val errorSound: SettingState + val errorSound: SettingState /** * The playback volume. @@ -107,7 +112,8 @@ class VoiceSatellitePlayerImpl( override val wakeSound: SettingState, override val timerFinishedSound: SettingState, override val repeatTimerFinishedSound: SettingState, - override val errorSound: SettingState, + override val enableErrorSound: SettingState, + override val errorSound: SettingState, private val duckMultiplier: Float = 0.5f ) : VoiceSatellitePlayer { private var _isDucked = false @@ -159,9 +165,9 @@ class VoiceSatellitePlayerImpl( } override suspend fun playErrorSound(onCompletion: () -> Unit) { - errorSound.get()?.let { - ttsPlayer.play(it, onCompletion) - } ?: onCompletion() + if (enableErrorSound.get()) { + ttsPlayer.play(errorSound.get(), onCompletion) + } else onCompletion() } override fun duck() { 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 d8b1f30..5158aaa 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -179,6 +179,7 @@ class VoiceSatelliteService() : LifecycleService() { wakeSound = playerSettingsStore.wakeSound, timerFinishedSound = playerSettingsStore.timerFinishedSound, repeatTimerFinishedSound = playerSettingsStore.repeatTimerFinishedSound, + enableErrorSound = playerSettingsStore.enableErrorSound, errorSound = playerSettingsStore.errorSound ).apply { setVolume(playerSettings.volume) 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 3f7a907..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,7 +23,8 @@ data class PlayerSettings( val wakeSound: String = defaultWakeSound, val timerFinishedSound: String = defaultTimerFinishedSound, val repeatTimerFinishedSound: Boolean = true, - val errorSound: String? = null, + val enableErrorSound: Boolean = false, + val errorSound: String = defaultErrorSound, ) private val DEFAULT = PlayerSettings() @@ -70,10 +71,15 @@ interface PlayerSettingsStore : SettingsStore { */ 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 + val errorSound: SettingState } @Singleton @@ -109,6 +115,10 @@ class PlayerSettingsStoreImpl @Inject constructor(@ApplicationContext context: C 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) } } 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 d7d93cf..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,10 @@ 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( @@ -159,7 +164,7 @@ class SettingsViewModel @Inject constructor( } suspend fun resetErrorSound() { - playerSettingsStore.errorSound.set(null) + playerSettingsStore.errorSound.set(defaultErrorSound) } fun validateName(name: String): String? = 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 6f784bc..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 @@ -205,6 +205,19 @@ 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), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a39aab..549e6a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,8 +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 file to play when a voice assistant error occurs + 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 629effb..d9d10d0 100644 --- a/app/src/test/java/com/example/ava/VoicePipelineTest.kt +++ b/app/src/test/java/com/example/ava/VoicePipelineTest.kt @@ -227,7 +227,7 @@ class VoicePipelineTest { val pipeline = createPipeline( player = object : StubVoiceSatellitePlayer() { override val ttsPlayer: AudioPlayer - get() = object: StubAudioPlayer() { + get() = object : StubAudioPlayer() { override fun play(mediaUri: String, onCompletion: () -> Unit) { playerCompletion = onCompletion } @@ -323,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 24319cf..63adcfc 100644 --- a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt @@ -15,7 +15,8 @@ class VoiceSatellitePlayerTest { wakeSound: SettingState = stubSettingState(""), timerFinishedSound: SettingState = stubSettingState(""), repeatTimerFinishedSound: SettingState = stubSettingState(true), - errorSound: SettingState = stubSettingState(null), + enableErrorSound: SettingState = stubSettingState(false), + errorSound: SettingState = stubSettingState(""), duckMultiplier: Float = 1f ) = VoiceSatellitePlayerImpl( ttsPlayer = ttsPlayer, @@ -24,6 +25,7 @@ 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 98bf56f..0e0a6db 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt @@ -13,7 +13,8 @@ open class StubVoiceSatellitePlayer( override val wakeSound: SettingState = stubSettingState(""), override val timerFinishedSound: SettingState = stubSettingState(""), override val repeatTimerFinishedSound: SettingState = stubSettingState(true), - override val errorSound: SettingState = stubSettingState(null) + override val enableErrorSound: SettingState = stubSettingState(false), + override val errorSound: SettingState = stubSettingState("") ) : VoiceSatellitePlayer { protected val _volume = MutableStateFlow(1.0f) override val volume: StateFlow = _volume @@ -44,7 +45,7 @@ open class StubVoiceSatellitePlayer( } override suspend fun playErrorSound(onCompletion: () -> Unit) { - errorSound.get()?.let { ttsPlayer.play(it, onCompletion) } ?: onCompletion() + ttsPlayer.play(errorSound.get(), onCompletion) } override fun duck() {}