diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6952bd..c43cfee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,7 @@ android { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi") + freeCompilerArgs.add("-Xannotation-default-target=param-property") } } buildFeatures { diff --git a/app/src/main/java/com/example/ava/audio/MicrophoneInput.kt b/app/src/main/java/com/example/ava/audio/MicrophoneInput.kt index 243f8d0..d1463c7 100644 --- a/app/src/main/java/com/example/ava/audio/MicrophoneInput.kt +++ b/app/src/main/java/com/example/ava/audio/MicrophoneInput.kt @@ -67,6 +67,7 @@ class MicrophoneInput( it.release() audioRecord = null } + Timber.d("Microphone closed") } companion object { diff --git a/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt b/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt index 773bc8b..533d7e1 100644 --- a/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt +++ b/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt @@ -1,6 +1,9 @@ package com.example.ava.esphome +import android.Manifest +import androidx.annotation.RequiresPermission import com.example.ava.esphome.entities.Entity +import com.example.ava.esphome.voiceassistant.VoiceAssistant import com.example.ava.server.DEFAULT_SERVER_PORT import com.example.ava.server.Server import com.example.ava.server.ServerException @@ -12,6 +15,12 @@ import com.example.esphomeproto.api.HelloRequest import com.example.esphomeproto.api.ListEntitiesRequest import com.example.esphomeproto.api.PingRequest import com.example.esphomeproto.api.SubscribeHomeAssistantStatesRequest +import com.example.esphomeproto.api.SubscribeVoiceAssistantRequest +import com.example.esphomeproto.api.VoiceAssistantAnnounceRequest +import com.example.esphomeproto.api.VoiceAssistantConfigurationRequest +import com.example.esphomeproto.api.VoiceAssistantEventResponse +import com.example.esphomeproto.api.VoiceAssistantSetConfiguration +import com.example.esphomeproto.api.VoiceAssistantTimerEventResponse import com.example.esphomeproto.api.disconnectResponse import com.example.esphomeproto.api.helloResponse import com.example.esphomeproto.api.listEntitiesDoneResponse @@ -41,30 +50,33 @@ data object Disconnected : EspHomeState data object Stopped : EspHomeState data class ServerError(val message: String) : EspHomeState -abstract class EspHomeDevice( +class EspHomeDevice( coroutineContext: CoroutineContext, - protected val name: String, - protected val port: Int = DEFAULT_SERVER_PORT, - protected val server: Server = ServerImpl(), + private val port: Int = DEFAULT_SERVER_PORT, + private val server: Server = ServerImpl(), + private val deviceInfo: DeviceInfoResponse, + val voiceAssistant: VoiceAssistant, entities: Iterable = emptyList() ) : AutoCloseable { - protected val entities = entities.toList() - protected val _state = MutableStateFlow(Disconnected) + private val entities = entities.toList() + private val _state = MutableStateFlow(Disconnected) val state = _state.asStateFlow() - protected val isSubscribedToEntityState = MutableStateFlow(false) + private val isSubscribedToVoiceAssistant = MutableStateFlow(false) + private val isSubscribedToEntityState = MutableStateFlow(false) - protected val scope = CoroutineScope( + private val scope = CoroutineScope( coroutineContext + Job(coroutineContext.job) + CoroutineName("${this.javaClass.simpleName} Scope") ) - open fun start() { + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun start() { startServer() + voiceAssistant.start() startConnectedChangedListener() listenForEntityStateChanges() + listenForVoiceAssistantResponses() } - protected abstract suspend fun getDeviceInfo(): DeviceInfoResponse - private fun startServer() { server.start(port) .onEach { handleMessageInternal(it) } @@ -84,26 +96,36 @@ abstract class EspHomeDevice( } .launchIn(scope) - fun listenForEntityStateChanges() = isSubscribedToEntityState + private fun listenForEntityStateChanges() = isSubscribedToEntityState .flatMapLatest { subscribed -> if (!subscribed) emptyFlow() else - entities - .map { it.subscribe() } - .merge() - .onEach { sendMessage(it) } - }.launchIn(scope) + entities.map { it.subscribe() }.merge() + } + .onEach { sendMessage(it) } + .launchIn(scope) + + private fun listenForVoiceAssistantResponses() = isSubscribedToVoiceAssistant + .flatMapLatest { subscribed -> + if (!subscribed) + emptyFlow() + else + voiceAssistant.subscribe() + } + .onEach { sendMessage(it) } + .launchIn(scope) + private suspend fun handleMessageInternal(message: MessageLite) { Timber.d("Received message: ${message.javaClass.simpleName} $message") handleMessage(message) } - protected open suspend fun handleMessage(message: MessageLite) { + private suspend fun handleMessage(message: MessageLite) { when (message) { is HelloRequest -> sendMessage(helloResponse { - name = this@EspHomeDevice.name + name = deviceInfo.name apiVersionMajor = 1 apiVersionMinor = 10 }) @@ -113,7 +135,7 @@ abstract class EspHomeDevice( server.disconnectCurrentClient() } - is DeviceInfoRequest -> sendMessage(getDeviceInfo()) + is DeviceInfoRequest -> sendMessage(deviceInfo) is PingRequest -> sendMessage(pingResponse { }) @@ -125,6 +147,15 @@ abstract class EspHomeDevice( sendMessage(listEntitiesDoneResponse { }) } + is SubscribeVoiceAssistantRequest -> isSubscribedToVoiceAssistant.value = + message.subscribe + + is VoiceAssistantConfigurationRequest, + is VoiceAssistantSetConfiguration, + is VoiceAssistantAnnounceRequest, + is VoiceAssistantEventResponse, + is VoiceAssistantTimerEventResponse -> voiceAssistant.handleMessage(message) + else -> { entities.map { it.handleMessage(message) }.asFlow().flattenConcat() .collect { response -> sendMessage(response) } @@ -132,22 +163,26 @@ abstract class EspHomeDevice( } } - protected suspend fun sendMessage(message: MessageLite) { + private suspend fun sendMessage(message: MessageLite) { Timber.d("Sending message: ${message.javaClass.simpleName} $message") server.sendMessage(message) } - protected open suspend fun onConnected() { + private suspend fun onConnected() { _state.value = Connected + voiceAssistant.onConnected() } - protected open suspend fun onDisconnected() { + private suspend fun onDisconnected() { isSubscribedToEntityState.value = false + isSubscribedToVoiceAssistant.value = false _state.value = Disconnected + voiceAssistant.onDisconnected() } override fun close() { scope.cancel() + voiceAssistant.close() server.close() } } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/esphome/entities/MediaPlayerEntity.kt b/app/src/main/java/com/example/ava/esphome/entities/MediaPlayerEntity.kt index 5f4241d..e468573 100644 --- a/app/src/main/java/com/example/ava/esphome/entities/MediaPlayerEntity.kt +++ b/app/src/main/java/com/example/ava/esphome/entities/MediaPlayerEntity.kt @@ -2,8 +2,6 @@ package com.example.ava.esphome.entities import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer -import com.example.ava.players.AudioPlayerState import com.example.esphomeproto.api.ListEntitiesRequest import com.example.esphomeproto.api.MediaPlayerCommand import com.example.esphomeproto.api.MediaPlayerCommandRequest @@ -11,15 +9,59 @@ import com.example.esphomeproto.api.MediaPlayerState import com.example.esphomeproto.api.listEntitiesMediaPlayerResponse import com.example.esphomeproto.api.mediaPlayerStateResponse import com.google.protobuf.MessageLite +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow +interface MediaPlayer { + /** + * The current state of media playback. + */ + val mediaState: Flow + + /** + * Starts playback of the specified media. + */ + fun playMedia(mediaUrl: String) + + /** + * Sets the paused state of media playback. + */ + fun setMediaPaused(paused: Boolean) + + /** + * Stops media playback. + */ + fun stopMedia() + + /** + * Gets the playback volume. + */ + val volume: StateFlow + + /** + * Sets the playback volume. + */ + suspend fun setVolume(value: Float) + + /** + * Gets whether playback is muted. + */ + val muted: StateFlow + + /** + * Sets whether playback is muted. + */ + suspend fun setMuted(value: Boolean) +} + @OptIn(UnstableApi::class) class MediaPlayerEntity( val key: Int, val name: String, val objectId: String, - val player: VoiceSatellitePlayer + val mediaPlayer: MediaPlayer ) : Entity { override fun handleMessage(message: MessageLite) = flow { @@ -34,18 +76,28 @@ class MediaPlayerEntity( is MediaPlayerCommandRequest -> { if (message.key == key) { if (message.hasMediaUrl) { - player.mediaPlayer.play(message.mediaUrl) + mediaPlayer.playMedia(message.mediaUrl) } else if (message.hasCommand) { when (message.command) { - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PAUSE -> player.mediaPlayer.pause() - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PLAY -> player.mediaPlayer.unpause() - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_STOP -> player.mediaPlayer.stop() - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_MUTE -> player.setMuted(true) - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_UNMUTE -> player.setMuted(false) + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PAUSE -> + mediaPlayer.setMediaPaused(true) + + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PLAY -> + mediaPlayer.setMediaPaused(false) + + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_STOP -> + mediaPlayer.stopMedia() + + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_MUTE -> + mediaPlayer.setMuted(true) + + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_UNMUTE -> + mediaPlayer.setMuted(false) + else -> {} } } else if (message.hasVolume) { - player.setVolume(message.volume) + mediaPlayer.setVolume(message.volume) } } } @@ -53,21 +105,15 @@ class MediaPlayerEntity( } override fun subscribe() = combine( - player.mediaPlayer.state, - player.volume, - player.muted, + mediaPlayer.mediaState, + mediaPlayer.volume, + mediaPlayer.muted, ) { state, volume, muted -> mediaPlayerStateResponse { key = this@MediaPlayerEntity.key - this.state = getState(state) + this.state = state this.volume = volume this.muted = muted } } - - private fun getState(state: AudioPlayerState) = when (state) { - AudioPlayerState.PLAYING -> MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING - AudioPlayerState.PAUSED -> MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED - AudioPlayerState.IDLE -> MediaPlayerState.MEDIA_PLAYER_STATE_IDLE - } } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/Announcement.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/Announcement.kt similarity index 77% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/Announcement.kt rename to app/src/main/java/com/example/ava/esphome/voiceassistant/Announcement.kt index 9ffecbe..387f931 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/Announcement.kt +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/Announcement.kt @@ -1,8 +1,7 @@ -package com.example.ava.esphome.voicesatellite +package com.example.ava.esphome.voiceassistant import com.example.ava.esphome.Connected import com.example.ava.esphome.EspHomeState -import com.example.ava.players.AudioPlayer import com.example.esphomeproto.api.voiceAssistantAnnounceFinished import com.google.protobuf.MessageLite import kotlinx.coroutines.CoroutineScope @@ -10,7 +9,7 @@ import kotlinx.coroutines.launch class Announcement( private val scope: CoroutineScope, - private val player: AudioPlayer, + private val voiceOutput: VoiceOutput, private val sendMessage: suspend (MessageLite) -> Unit, private val stateChanged: (state: EspHomeState) -> Unit, private val ended: suspend (continueConversation: Boolean) -> Unit @@ -20,12 +19,7 @@ class Announcement( fun announce(mediaUrl: String, preannounceUrl: String, continueConversation: Boolean) { updateState(Responding) - val urls = if (preannounceUrl.isNotEmpty()) { - listOf(preannounceUrl, mediaUrl) - } else { - listOf(mediaUrl) - } - player.play(urls) { + voiceOutput.playAnnouncement(preannounceUrl, mediaUrl) { scope.launch { stop() ended(continueConversation) @@ -35,7 +29,7 @@ class Announcement( suspend fun stop() { if (_state == Responding) { - player.stop() + voiceOutput.stopTTS() updateState(Connected) sendMessage(voiceAssistantAnnounceFinished { }) } diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceAssistant.kt similarity index 63% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt rename to app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceAssistant.kt index c77a253..6457a39 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceAssistant.kt @@ -1,39 +1,38 @@ -package com.example.ava.esphome.voicesatellite +package com.example.ava.esphome.voiceassistant import android.Manifest import androidx.annotation.RequiresPermission import com.example.ava.esphome.Connected -import com.example.ava.esphome.EspHomeDevice +import com.example.ava.esphome.Disconnected import com.example.ava.esphome.EspHomeState -import com.example.ava.esphome.entities.MediaPlayerEntity -import com.example.ava.esphome.entities.SwitchEntity -import com.example.ava.esphome.voicesatellite.VoiceTimer.Companion.timerFromEvent -import com.example.ava.server.DEFAULT_SERVER_PORT -import com.example.ava.server.Server -import com.example.ava.server.ServerImpl -import com.example.ava.settings.VoiceSatelliteSettingsStore +import com.example.ava.esphome.voiceassistant.VoiceTimer.Companion.timerFromEvent import com.example.ava.tasker.StopRingingRunner import com.example.ava.tasker.WakeSatelliteRunner -import com.example.esphomeproto.api.DeviceInfoResponse import com.example.esphomeproto.api.VoiceAssistantAnnounceRequest import com.example.esphomeproto.api.VoiceAssistantConfigurationRequest import com.example.esphomeproto.api.VoiceAssistantEventResponse -import com.example.esphomeproto.api.VoiceAssistantFeature import com.example.esphomeproto.api.VoiceAssistantSetConfiguration import com.example.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.VoiceAssistantTimerEventResponse -import com.example.esphomeproto.api.deviceInfoResponse import com.example.esphomeproto.api.voiceAssistantConfigurationResponse import com.example.esphomeproto.api.voiceAssistantWakeWord import com.google.protobuf.MessageLite +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job import kotlinx.coroutines.launch import timber.log.Timber import kotlin.coroutines.CoroutineContext @@ -45,41 +44,19 @@ data object Processing : EspHomeState data class VoiceError(val message: String) : EspHomeState -class VoiceSatellite( +class VoiceAssistant( coroutineContext: CoroutineContext, - name: String, - port: Int = DEFAULT_SERVER_PORT, - server: Server = ServerImpl(), - val audioInput: VoiceSatelliteAudioInput, - val player: VoiceSatellitePlayer, - val settingsStore: VoiceSatelliteSettingsStore -) : EspHomeDevice( - coroutineContext = coroutineContext, - name = name, - port = port, - server = server, - entities = listOf( - MediaPlayerEntity(0, "Media Player", "media_player", player), - SwitchEntity( - key = 1, - name = "Mute Microphone", - objectId = "mute_microphone", - getState = audioInput.muted - ) { audioInput.setMuted(it) }, - SwitchEntity( - key = 2, - name = "Enable Wake Sound", - objectId = "enable_wake_sound", - getState = player.enableWakeSound - ) { player.enableWakeSound.set(it) }, - SwitchEntity( - key = 3, - name = "Repeat Timer Sound", - objectId = "repeat_timer_sound", - getState = player.repeatTimerFinishedSound - ) { player.repeatTimerFinishedSound.set(it) } + val voiceInput: VoiceInput, + val voiceOutput: VoiceOutput, +) : AutoCloseable { + private val scope = CoroutineScope( + coroutineContext + Job(coroutineContext.job) + CoroutineName("${this.javaClass.simpleName} Scope") ) -) { + private val isConnected = MutableStateFlow(false) + private val subscription = MutableSharedFlow() + protected val _state = MutableStateFlow(Disconnected) + val state = _state.asStateFlow() + private var pipeline: VoicePipeline? = null private var announcement: Announcement? = null private val _pendingTimers = MutableStateFlow>(emptyMap()) @@ -93,61 +70,58 @@ class VoiceSatellite( get() = _ringingTimer.value != null @RequiresPermission(Manifest.permission.RECORD_AUDIO) - override fun start() { - super.start() - startAudioInput() + fun start() { + startVoiceInput() // Wire up tasker actions - WakeSatelliteRunner.register { scope.launch { wakeSatellite() } } + WakeSatelliteRunner.register { scope.launch { wakeAssistant() } } StopRingingRunner.register { scope.launch { stopTimer() } } } + + fun subscribe() = subscription.asSharedFlow() + @RequiresPermission(Manifest.permission.RECORD_AUDIO) - private fun startAudioInput() = server.isConnected + private fun startVoiceInput() = isConnected .flatMapLatest { isConnected -> - if (isConnected) audioInput.start() else emptyFlow() + if (isConnected) voiceInput.start() else emptyFlow() } .onEach { handleAudioResult(audioResult = it) } .launchIn(scope) - override suspend fun onDisconnected() { + suspend fun onConnected() { + isConnected.value = true resetState() - super.onDisconnected() } - override suspend fun getDeviceInfo(): DeviceInfoResponse = deviceInfoResponse { - val settings = settingsStore.get() - name = settings.name - macAddress = settings.macAddress - voiceAssistantFeatureFlags = VoiceAssistantFeature.VOICE_ASSISTANT.flag or - VoiceAssistantFeature.API_AUDIO.flag or - VoiceAssistantFeature.TIMERS.flag or - VoiceAssistantFeature.ANNOUNCE.flag or - VoiceAssistantFeature.START_CONVERSATION.flag + suspend fun onDisconnected() { + isConnected.value = false + resetState(Disconnected) } - override suspend fun handleMessage(message: MessageLite) { + suspend fun handleMessage(message: MessageLite) { when (message) { - is VoiceAssistantConfigurationRequest -> sendMessage( + is VoiceAssistantConfigurationRequest -> subscription.emit( voiceAssistantConfigurationResponse { - availableWakeWords += audioInput.availableWakeWords.map { + availableWakeWords += voiceInput.getAvailableWakeWords().map { voiceAssistantWakeWord { id = it.id wakeWord = it.wakeWord.wake_word trainedLanguages += it.wakeWord.trained_languages.toList() } } - activeWakeWords += audioInput.activeWakeWords.value + activeWakeWords += voiceInput.activeWakeWords.get() maxActiveWakeWords = 2 }) is VoiceAssistantSetConfiguration -> { + val availableWakeWords = voiceInput.getAvailableWakeWords() val activeWakeWords = - message.activeWakeWordsList.filter { audioInput.availableWakeWords.any { wakeWord -> wakeWord.id == it } } + message.activeWakeWordsList.filter { availableWakeWords.any { wakeWord -> wakeWord.id == it } } Timber.d("Setting active wake words: $activeWakeWords") if (activeWakeWords.isNotEmpty()) { - audioInput.setActiveWakeWords(activeWakeWords) + voiceInput.activeWakeWords.set(activeWakeWords) } val ignoredWakeWords = message.activeWakeWordsList.filter { !activeWakeWords.contains(it) } @@ -165,8 +139,6 @@ class VoiceSatellite( pipeline?.handleEvent(message) ?: Timber.w("No pipeline to handle event: $message") is VoiceAssistantTimerEventResponse -> handleTimerMessage(message) - - else -> super.handleMessage(message) } } @@ -190,9 +162,9 @@ class VoiceSatellite( _ringingTimer.update { timer } if (wasNotRinging) { - player.duck() - player.playTimerFinishedSound { - scope.launch { onTimerSoundFinished() } + voiceOutput.duck() + voiceOutput.playTimerFinishedSound { repeat -> + scope.launch { onTimerSoundFinished(repeat) } } } } @@ -209,12 +181,12 @@ class VoiceSatellite( resetState() announcement = Announcement( scope = scope, - player = player.ttsPlayer, - sendMessage = { sendMessage(it) }, + voiceOutput = voiceOutput, + sendMessage = { subscription.emit(it) }, stateChanged = { _state.value = it }, ended = { onTtsFinished(it) } ).apply { - player.duck() + voiceOutput.duck() announce(mediaId, preannounceId, startConversation) } } @@ -232,12 +204,12 @@ class VoiceSatellite( } private suspend fun onWakeDetected(wakeWordPhrase: String) { - // Allow using the wake word to stop the timer - // TODO: Should the satellite also wake? + // Allow using the wake word to stop the timer. + // TODO: Should the assistant also wake? if (isRinging) { stopTimer() } else { - wakeSatellite(wakeWordPhrase) + wakeAssistant(wakeWordPhrase) } } @@ -245,27 +217,27 @@ class VoiceSatellite( if (isRinging) { stopTimer() } else { - stopSatellite() + stopAssistant() } } - private suspend fun wakeSatellite( + private suspend fun wakeAssistant( wakeWordPhrase: String = "", isContinueConversation: Boolean = false ) { // Multiple wake detections from the same wake word can be triggered - // so ensure the satellite is only woken once. Currently this is + // so ensure the assistant is only woken once. Currently this is // achieved by creating a pipeline in the Listening state // on the first wake detection and checking for that here. if (pipeline?.state == Listening) return - Timber.d("Wake satellite") + Timber.d("Wake assistant") resetState() pipeline = createPipeline() if (!isContinueConversation) { - player.duck() + voiceOutput.duck() // Start streaming audio only after the wake sound has finished - player.playWakeSound { + voiceOutput.playWakeSound { scope.launch { pipeline?.start(wakeWordPhrase) } } } else { @@ -275,33 +247,33 @@ class VoiceSatellite( private fun createPipeline() = VoicePipeline( scope = scope, - player = player, - sendMessage = { sendMessage(it) }, + voiceOutput = voiceOutput, + sendMessage = { subscription.emit(it) }, listeningChanged = { - if (it) player.duck() - audioInput.isStreaming = it + if (it) voiceOutput.duck() + voiceInput.isStreaming = it }, stateChanged = { _state.value = it }, ended = { onTtsFinished(it) } ) - private suspend fun stopSatellite() { - // Ignore the stop request if the satellite is idle or currently streaming + private suspend fun stopAssistant() { + // Ignore the stop request if the assistant is idle or currently streaming // microphone audio as there's either nothing to stop or the stop word was // used incidentally as part of the voice command. val state = _state.value if (state is Connected || state is Listening) return - Timber.d("Stop satellite") + Timber.d("Stop assistant") resetState() - player.unDuck() + voiceOutput.unDuck() } private fun stopTimer() { Timber.d("Stop timer") if (isRinging) { _ringingTimer.update { null } - player.ttsPlayer.stop() - player.unDuck() + voiceOutput.stopTTS() + voiceOutput.unDuck() } } @@ -309,41 +281,41 @@ class VoiceSatellite( Timber.d("TTS finished") if (continueConversation) { Timber.d("Continuing conversation") - wakeSatellite(isContinueConversation = true) + wakeAssistant(isContinueConversation = true) } else { - player.unDuck() + voiceOutput.unDuck() } } - private suspend fun onTimerSoundFinished() { + private suspend fun onTimerSoundFinished(repeat: Boolean) { delay(1000) if (isRinging) { - if (player.repeatTimerFinishedSound.get()) { - player.playTimerFinishedSound { - scope.launch { onTimerSoundFinished() } + if (repeat) { + voiceOutput.playTimerFinishedSound { + scope.launch { onTimerSoundFinished(it) } } } else { stopTimer() } } else { - player.unDuck() + voiceOutput.unDuck() } } - private suspend fun resetState() { + private suspend fun resetState(newState: EspHomeState = Connected) { pipeline?.stop() pipeline = null announcement?.stop() announcement = null _ringingTimer.update { null } - audioInput.isStreaming = false - player.ttsPlayer.stop() - _state.value = Connected + voiceInput.isStreaming = false + voiceOutput.stopTTS() + _state.value = newState } override fun close() { - super.close() - player.close() + scope.cancel() + voiceOutput.close() WakeSatelliteRunner.unregister() StopRingingRunner.unregister() } diff --git a/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceInput.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceInput.kt new file mode 100644 index 0000000..105c185 --- /dev/null +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceInput.kt @@ -0,0 +1,160 @@ +package com.example.ava.esphome.voiceassistant + +import android.Manifest +import androidx.annotation.RequiresPermission +import com.example.ava.audio.MicrophoneInput +import com.example.ava.settings.SettingState +import com.example.ava.wakewords.microwakeword.MicroWakeWord +import com.example.ava.wakewords.microwakeword.MicroWakeWordDetector +import com.example.ava.wakewords.models.WakeWordWithId +import com.google.protobuf.ByteString +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.yield +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +sealed class AudioResult { + data class Audio(val audio: ByteString) : AudioResult() + data class WakeDetected(val wakeWord: String) : AudioResult() + class StopDetected() : AudioResult() +} + +interface VoiceInput { + /** + * The list of wake words available for selection. + */ + suspend fun getAvailableWakeWords(): List + + /** + * The list of stop words available for selection. + */ + suspend fun getAvailableStopWords(): List + + /** + * The list of currently active wake words. + */ + val activeWakeWords: SettingState> + + /** + * The list of currently active stop words. + */ + val activeStopWords: SettingState> + + /** + * Whether the microphone is muted. + */ + val muted: SettingState + + /** + * Whether the microphone is currently streaming audio. + */ + var isStreaming: Boolean + + /** + * Starts listening to audio from the microphone for wake word detection and streaming. + * If an active wake word or stop word is detected, emits [AudioResult.WakeDetected] or + * [AudioResult.StopDetected] respectively. If [isStreaming] is true, [AudioResult.Audio] is + * also emitted with the raw audio data. + */ + fun start(): Flow +} + +class VoiceInputImpl( + private val availableWakeWords: Flow>, + private val availableStopWords: Flow>, + override val activeWakeWords: SettingState>, + override val activeStopWords: SettingState>, + override val muted: SettingState, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : VoiceInput { + override suspend fun getAvailableWakeWords() = availableWakeWords.first() + override suspend fun getAvailableStopWords() = availableStopWords.first() + + private val _isStreaming = AtomicBoolean(false) + override var isStreaming: Boolean + get() = _isStreaming.get() + set(value) = _isStreaming.set(value) + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun start() = muted.flatMapLatest { + // Stop microphone when muted + if (it) emptyFlow() + else flow { + MicrophoneInput().use { microphoneInput -> + microphoneInput.start() + emitAll( + combine(activeWakeWords, activeStopWords) { activeWakeWords, activeStopWords -> + readMicrophone(microphoneInput, activeWakeWords, activeStopWords) + }.flatMapLatest { it } + ) + } + } + }.flowOn(dispatcher) + + private fun readMicrophone( + microphoneInput: MicrophoneInput, + activeWakeWords: List, + activeStopWords: List + ) = flow { + createDetector(activeWakeWords, activeStopWords).use { detector -> + Timber.d("Created wake word detector") + while (true) { + val audio = microphoneInput.read() + if (isStreaming) { + emit(AudioResult.Audio(ByteString.copyFrom(audio))) + audio.rewind() + } + + // Always run audio through the models, even if not currently streaming, to keep + // their internal state up to date + val detections = detector.detect(audio) + for (detection in detections) { + if (detection.wakeWordId in activeWakeWords) { + emit(AudioResult.WakeDetected(detection.wakeWordPhrase)) + } else if (detection.wakeWordId in activeStopWords) { + emit(AudioResult.StopDetected()) + } + } + + // This flow needs to be cancellable and not block upstream emissions, therefore + // it needs to regularly suspend. Currently the only suspension points are during + // emissions, but because this flow only emits values when a wake word is detected + // or microphone audio is streaming. most of the time no emissions and no + // suspensions occur. Yield to ensure there's always a suspension point. + yield() + } + } + } + + private suspend fun createDetector( + wakeWords: List, + stopWords: List + ) = MicroWakeWordDetector( + loadWakeWords(wakeWords, availableWakeWords.first()) + + loadWakeWords(stopWords, availableStopWords.first()) + ) + + private suspend fun loadWakeWords( + ids: List, + wakeWords: List + ): List = buildList { + for (id in ids) { + wakeWords.firstOrNull { it.id == id }?.let { wakeWord -> + runCatching { + add(MicroWakeWord.fromWakeWord(wakeWord)) + }.onFailure { + Timber.e(it, "Error loading wake word: $id") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceOutput.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceOutput.kt new file mode 100644 index 0000000..eeaab8e --- /dev/null +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceOutput.kt @@ -0,0 +1,217 @@ +package com.example.ava.esphome.voiceassistant + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.example.ava.esphome.entities.MediaPlayer +import com.example.ava.players.AudioPlayer +import com.example.ava.players.AudioPlayerState +import com.example.esphomeproto.api.MediaPlayerState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +interface VoiceOutput : AutoCloseable { + /** + * The playback volume. + */ + val volume: StateFlow + + /** + * Sets the playback volume. + */ + suspend fun setVolume(value: Float) + + /** + * Whether playback is muted. + */ + val muted: StateFlow + + /** + * Sets whether playback is muted. + */ + suspend fun setMuted(value: Boolean) + + /** + * Plays a TTS response. + */ + fun playTTS(ttsUrl: String, onCompletion: () -> Unit = {}) + + /** + * Plays an announcement, optionally with a preannounce sound. + */ + fun playAnnouncement( + preannounceUrl: String = "", + mediaUrl: String, + onCompletion: () -> Unit = {} + ) + + /** + * Plays the wake sound. + */ + suspend fun playWakeSound(onCompletion: () -> Unit = {}) + + /** + * Plays the timer finished sound. The [onCompletion] callback indicates whether the + * the timer finished sound should be repeated. + */ + suspend fun playTimerFinishedSound(onCompletion: (repeat: Boolean) -> Unit = {}) + + /** + * Plays the error sound. + */ + suspend fun playErrorSound(onCompletion: () -> Unit = {}) + + /** + * Ducks the media player volume. + */ + fun duck() + + /** + * Un-ducks the media player volume. + */ + fun unDuck() + + /** + * Stops any currently playing response or sound. + */ + fun stopTTS() +} + +@OptIn(UnstableApi::class) +class VoiceOutputImpl( + private val ttsPlayer: AudioPlayer, + private val mediaPlayer: AudioPlayer, + private val enableWakeSound: suspend () -> Boolean, + private val wakeSound: suspend () -> String, + private val timerFinishedSound: suspend () -> String, + private val repeatTimerFinishedSound: suspend () -> Boolean, + private val enableErrorSound: suspend () -> Boolean, + private val errorSound: suspend () -> String, + volume: Float = 1.0f, + private val volumeChanged: suspend (Float) -> Unit = {}, + muted: Boolean = false, + private val mutedChanged: suspend (Boolean) -> Unit = {}, + private val duckMultiplier: Float = 0.5f +) : VoiceOutput, MediaPlayer { + private var _isDucked = false + private val _volume = MutableStateFlow(volume) + private val _muted = MutableStateFlow(muted) + + init { + if (!muted) { + ttsPlayer.volume = volume + mediaPlayer.volume = if (_isDucked) volume * duckMultiplier else volume + } else { + mediaPlayer.volume = 0.0f + ttsPlayer.volume = 0.0f + } + } + + override val volume get() = _volume.asStateFlow() + override suspend fun setVolume(value: Float) { + _volume.value = value + if (!_muted.value) { + ttsPlayer.volume = value + mediaPlayer.volume = if (_isDucked) value * duckMultiplier else value + } + volumeChanged(value) + } + + override val muted get() = _muted.asStateFlow() + override suspend fun setMuted(value: Boolean) { + _muted.value = value + if (value) { + mediaPlayer.volume = 0.0f + ttsPlayer.volume = 0.0f + } else { + ttsPlayer.volume = _volume.value + mediaPlayer.volume = if (_isDucked) _volume.value * duckMultiplier else _volume.value + } + mutedChanged(value) + } + + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) { + ttsPlayer.play(ttsUrl, onCompletion) + } + + override fun playAnnouncement( + preannounceUrl: String, + mediaUrl: String, + onCompletion: () -> Unit + ) { + val urls = if (preannounceUrl.isNotEmpty()) { + listOf(preannounceUrl, mediaUrl) + } else { + listOf(mediaUrl) + } + ttsPlayer.play(urls, onCompletion) + } + + override suspend fun playWakeSound(onCompletion: () -> Unit) { + if (enableWakeSound()) { + ttsPlayer.play(wakeSound(), onCompletion) + } else onCompletion() + } + + override suspend fun playTimerFinishedSound(onCompletion: (repeat: Boolean) -> Unit) { + val repeat = repeatTimerFinishedSound() + ttsPlayer.play(timerFinishedSound()) { + onCompletion(repeat) + } + } + + override suspend fun playErrorSound(onCompletion: () -> Unit) { + if (enableErrorSound()) { + ttsPlayer.play(errorSound(), onCompletion) + } else onCompletion() + } + + override fun duck() { + _isDucked = true + if (!_muted.value) { + mediaPlayer.volume = _volume.value * duckMultiplier + } + // The player should gain audio focus when initialized, + // ducking any external audio. + ttsPlayer.init() + } + + override fun unDuck() { + _isDucked = false + if (!_muted.value) { + mediaPlayer.volume = _volume.value + } + } + + override fun stopTTS() { + ttsPlayer.stop() + } + + // MediaPlayer implementation + + override val mediaState = mediaPlayer.state.map { state -> + when (state) { + AudioPlayerState.PLAYING -> MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING + AudioPlayerState.PAUSED -> MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED + AudioPlayerState.IDLE -> MediaPlayerState.MEDIA_PLAYER_STATE_IDLE + } + } + + override fun playMedia(mediaUrl: String) { + mediaPlayer.play(mediaUrl) + } + + override fun setMediaPaused(paused: Boolean) { + if (paused) mediaPlayer.pause() else mediaPlayer.unpause() + } + + override fun stopMedia() { + mediaPlayer.stop() + } + + override fun close() { + ttsPlayer.close() + mediaPlayer.close() + } +} diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoicePipeline.kt similarity index 94% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt rename to app/src/main/java/com/example/ava/esphome/voiceassistant/VoicePipeline.kt index 0a30b3b..c89bd2e 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoicePipeline.kt @@ -1,4 +1,4 @@ -package com.example.ava.esphome.voicesatellite +package com.example.ava.esphome.voiceassistant import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi @@ -21,7 +21,7 @@ import timber.log.Timber @OptIn(UnstableApi::class) class VoicePipeline( private val scope: CoroutineScope, - private val player: VoiceSatellitePlayer, + private val voiceOutput: VoiceOutput, private val sendMessage: suspend (MessageLite) -> Unit, private val listeningChanged: (listening: Boolean) -> Unit, private val stateChanged: (state: EspHomeState) -> Unit, @@ -52,7 +52,7 @@ class VoicePipeline( suspend fun stop() { val state = _state if (state is Responding) { - player.ttsPlayer.stop() + voiceOutput.stopTTS() sendMessage(voiceAssistantAnnounceFinished { }) } else if (isRunning) { sendMessage(voiceAssistantRequest { start = false }) @@ -78,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.ttsPlayer.init() + voiceOutput.duck() updateState(Listening) } @@ -92,7 +92,7 @@ class VoicePipeline( if (voiceEvent.dataList.firstOrNull { data -> data.name == "tts_start_streaming" }?.value == "1") { ttsStreamUrl?.let { ttsPlayed = true - player.ttsPlayer.play(it) { scope.launch { fireEnded() } } + voiceOutput.playTTS(it) { scope.launch { fireEnded() } } } } } @@ -115,7 +115,7 @@ class VoicePipeline( if (!ttsPlayed) { voiceEvent.dataList.firstOrNull { data -> data.name == "url" }?.value?.let { ttsPlayed = true - player.ttsPlayer.play(it) { scope.launch { fireEnded() } } + voiceOutput.playTTS(it) { scope.launch { fireEnded() } } } } } @@ -129,10 +129,11 @@ class VoicePipeline( 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" + val message = voiceEvent.dataList.firstOrNull { it.name == "message" }?.value + ?: "Unknown error" Timber.w("Voice assistant error ($code): $message") updateState(VoiceError(message)) - player.playErrorSound { + voiceOutput.playErrorSound { scope.launch { fireEnded() } } } diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceTimer.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceTimer.kt similarity index 98% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceTimer.kt rename to app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceTimer.kt index 66d6132..2e338c3 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceTimer.kt +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceTimer.kt @@ -1,4 +1,4 @@ -package com.example.ava.esphome.voicesatellite +package com.example.ava.esphome.voiceassistant import com.example.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.VoiceAssistantTimerEventResponse diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatelliteAudioInput.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatelliteAudioInput.kt deleted file mode 100644 index e9bf48b..0000000 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatelliteAudioInput.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.example.ava.esphome.voicesatellite - -import android.Manifest -import androidx.annotation.RequiresPermission -import com.example.ava.audio.MicrophoneInput -import com.example.ava.wakewords.microwakeword.MicroWakeWord -import com.example.ava.wakewords.microwakeword.MicroWakeWordDetector -import com.example.ava.wakewords.models.WakeWordWithId -import com.google.protobuf.ByteString -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.yield -import timber.log.Timber -import java.util.concurrent.atomic.AtomicBoolean - -sealed class AudioResult { - data class Audio(val audio: ByteString) : AudioResult() - data class WakeDetected(val wakeWord: String) : AudioResult() - class StopDetected() : AudioResult() -} - -interface VoiceSatelliteAudioInput { - /** - * The list of wake words available for selection. - */ - val availableWakeWords: List - - /** - * The list of stop words available for selection. - */ - val availableStopWords: List - - /** - * The list of currently active wake words. - */ - val activeWakeWords: StateFlow> - - /** - * Sets the currently active wake words. - */ - fun setActiveWakeWords(value: List) - - /** - * The list of currently active stop words. - */ - val activeStopWords: StateFlow> - - /** - * Sets the currently active stop words. - */ - fun setActiveStopWords(value: List) - - /** - * Whether the microphone is muted. - */ - val muted: StateFlow - - /** - * Sets whether the microphone is muted. - */ - fun setMuted(value: Boolean) - - /** - * Whether the microphone is currently streaming audio. - */ - var isStreaming: Boolean - - /** - * Starts listening to audio from the microphone for wake word detection and streaming. - * If an active wake word or stop word is detected, emits [AudioResult.WakeDetected] or - * [AudioResult.StopDetected] respectively. If [isStreaming] is true, [AudioResult.Audio] is - * also emitted with the raw audio data. - */ - fun start(): Flow -} - -class VoiceSatelliteAudioInputImpl( - activeWakeWords: List, - activeStopWords: List, - override val availableWakeWords: List, - override val availableStopWords: List, - muted: Boolean = false, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) : VoiceSatelliteAudioInput { - private val _availableWakeWords = availableWakeWords.associateBy { it.id } - private val _availableStopWords = availableStopWords.associateBy { it.id } - - private val _activeWakeWords = MutableStateFlow(activeWakeWords) - override val activeWakeWords = _activeWakeWords.asStateFlow() - override fun setActiveWakeWords(value: List) { - _activeWakeWords.value = value - } - - private val _activeStopWords = MutableStateFlow(activeStopWords) - override val activeStopWords = _activeStopWords.asStateFlow() - override fun setActiveStopWords(value: List) { - _activeStopWords.value = value - } - - private val _muted = MutableStateFlow(muted) - override val muted = _muted.asStateFlow() - override fun setMuted(value: Boolean) { - _muted.value = value - } - - private val _isStreaming = AtomicBoolean(false) - override var isStreaming: Boolean - get() = _isStreaming.get() - set(value) = _isStreaming.set(value) - - @RequiresPermission(Manifest.permission.RECORD_AUDIO) - override fun start() = muted.flatMapLatest { - // Stop microphone when muted - if (it) emptyFlow() - else flow { - val microphoneInput = MicrophoneInput() - var wakeWords = activeWakeWords.value - var stopWords = activeStopWords.value - var detector = createDetector(wakeWords, stopWords) - try { - microphoneInput.start() - while (true) { - if (wakeWords != activeWakeWords.value || stopWords != activeStopWords.value) { - wakeWords = activeWakeWords.value - stopWords = activeStopWords.value - detector.close() - detector = createDetector(wakeWords, stopWords) - } - - val audio = microphoneInput.read() - if (isStreaming) { - emit(AudioResult.Audio(ByteString.copyFrom(audio))) - audio.rewind() - } - - // Always run audio through the models, even if not currently streaming, to keep - // their internal state up to date - val detections = detector.detect(audio) - for (detection in detections) { - if (detection.wakeWordId in wakeWords) { - emit(AudioResult.WakeDetected(detection.wakeWordPhrase)) - } else if (detection.wakeWordId in stopWords) { - emit(AudioResult.StopDetected()) - } - } - - // yield to ensure upstream emissions and - // cancellation have a chance to occur - yield() - } - } finally { - microphoneInput.close() - detector.close() - } - } - }.flowOn(dispatcher) - - private suspend fun createDetector( - wakeWords: List, - stopWords: List - ) = MicroWakeWordDetector( - loadWakeWords(wakeWords, _availableWakeWords) + - loadWakeWords(stopWords, _availableStopWords) - ) - - private suspend fun loadWakeWords( - ids: List, - wakeWords: Map - ): List = buildList { - for (id in ids) { - wakeWords[id]?.let { wakeWord -> - runCatching { - add(MicroWakeWord.fromWakeWord(wakeWord)) - }.onFailure { - Timber.e(it, "Error loading wake word: $id") - } - } - } - } -} \ No newline at end of file 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 deleted file mode 100644 index 77e156f..0000000 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.example.ava.esphome.voicesatellite - -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import com.example.ava.players.AudioPlayer -import com.example.ava.settings.SettingState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -interface VoiceSatellitePlayer : AutoCloseable { - /** - * The player to use for TTS playback, will also be used for wake and timer finished sounds. - */ - val ttsPlayer: AudioPlayer - - /** - * The player to use for media playback. - */ - val mediaPlayer: AudioPlayer - - /** - * Whether to enable the wake sound. - */ - val enableWakeSound: SettingState - - /** - * The wake sound to play when the satellite is woken. - */ - val wakeSound: SettingState - - /** - * The timer finished sound to play when a timer is finished. - */ - val timerFinishedSound: SettingState - - /** - * Whether to repeat the timer finished sound when the timer is finished. - */ - 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. - */ - val volume: StateFlow - - /** - * Sets the playback volume. - */ - fun setVolume(value: Float) - - /** - * Whether playback is muted. - */ - val muted: StateFlow - - /** - * Sets whether playback is muted. - */ - fun setMuted(value: Boolean) - - /** - * Plays an announcement, optionally with a preannounce sound. - */ - fun playAnnouncement( - preannounceUrl: String = "", - mediaUrl: String, - onCompletion: () -> Unit = {} - ) - - /** - * Plays the wake sound if [enableWakeSound] is true. - */ - suspend fun playWakeSound(onCompletion: () -> Unit = {}) - - /** - * Plays the timer finished sound. - */ - suspend fun playTimerFinishedSound(onCompletion: () -> Unit = {}) - - /** - * Plays the error sound if [errorSound] is not null. - */ - suspend fun playErrorSound(onCompletion: () -> Unit = {}) - - /** - * Ducks the media player volume. - */ - fun duck() - - /** - * Un-ducks the media player volume. - */ - fun unDuck() -} - -@OptIn(UnstableApi::class) -class VoiceSatellitePlayerImpl( - override val ttsPlayer: AudioPlayer, - override val mediaPlayer: AudioPlayer, - override val enableWakeSound: SettingState, - 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 - private val _volume = MutableStateFlow(1.0f) - private val _muted = MutableStateFlow(false) - - override val volume get() = _volume.asStateFlow() - override fun setVolume(value: Float) { - _volume.value = value - if (!_muted.value) { - ttsPlayer.volume = value - mediaPlayer.volume = if (_isDucked) value * duckMultiplier else value - } - } - - override val muted get() = _muted.asStateFlow() - override fun setMuted(value: Boolean) { - _muted.value = value - if (value) { - mediaPlayer.volume = 0.0f - ttsPlayer.volume = 0.0f - } else { - ttsPlayer.volume = _volume.value - mediaPlayer.volume = if (_isDucked) _volume.value * duckMultiplier else _volume.value - } - } - - override fun playAnnouncement( - preannounceUrl: String, - mediaUrl: String, - onCompletion: () -> Unit - ) { - val urls = if (preannounceUrl.isNotEmpty()) { - listOf(preannounceUrl, mediaUrl) - } else { - listOf(mediaUrl) - } - ttsPlayer.play(urls, onCompletion) - } - - override suspend fun playWakeSound(onCompletion: () -> Unit) { - if (enableWakeSound.get()) { - ttsPlayer.play(wakeSound.get(), onCompletion) - } else onCompletion() - } - - override suspend fun playTimerFinishedSound(onCompletion: () -> Unit) { - 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) { - mediaPlayer.volume = _volume.value * duckMultiplier - } - } - - override fun unDuck() { - _isDucked = false - if (!_muted.value) { - mediaPlayer.volume = _volume.value - } - } - - override fun close() { - ttsPlayer.close() - mediaPlayer.close() - } -} diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt new file mode 100644 index 0000000..49328df --- /dev/null +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -0,0 +1,140 @@ +package com.example.ava.services + +import android.content.Context +import android.content.Context.AUDIO_SERVICE +import android.media.AudioManager +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C.AUDIO_CONTENT_TYPE_MUSIC +import androidx.media3.common.C.AUDIO_CONTENT_TYPE_SPEECH +import androidx.media3.common.C.USAGE_ASSISTANT +import androidx.media3.common.C.USAGE_MEDIA +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.example.ava.esphome.EspHomeDevice +import com.example.ava.esphome.entities.MediaPlayerEntity +import com.example.ava.esphome.entities.SwitchEntity +import com.example.ava.esphome.voiceassistant.VoiceAssistant +import com.example.ava.esphome.voiceassistant.VoiceInputImpl +import com.example.ava.esphome.voiceassistant.VoiceOutputImpl +import com.example.ava.players.AudioPlayer +import com.example.ava.players.AudioPlayerImpl +import com.example.ava.server.ServerImpl +import com.example.ava.settings.MicrophoneSettingsStore +import com.example.ava.settings.PlayerSettingsStore +import com.example.ava.settings.VoiceSatelliteSettingsStore +import com.example.ava.settings.activeStopWords +import com.example.ava.settings.activeWakeWords +import com.example.esphomeproto.api.VoiceAssistantFeature +import com.example.esphomeproto.api.deviceInfoResponse +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +class DeviceBuilder @Inject constructor( + @ApplicationContext private val context: Context, + private val satelliteSettingsStore: VoiceSatelliteSettingsStore, + private val microphoneSettingsStore: MicrophoneSettingsStore, + private val playerSettingsStore: PlayerSettingsStore +) { + suspend fun buildVoiceSatellite(coroutineContext: CoroutineContext): EspHomeDevice { + val satelliteSettings = satelliteSettingsStore.get() + // Need a reference to voiceOutput as it needs to be passed to + // both the VoiceAssistant and MediaPlayerEntity + val voiceOutput = playerSettingsStore.toVoiceOutput() + return EspHomeDevice( + coroutineContext = coroutineContext, + port = satelliteSettings.serverPort, + server = ServerImpl(), + deviceInfo = deviceInfoResponse { + name = satelliteSettings.name + macAddress = satelliteSettings.macAddress + voiceAssistantFeatureFlags = VoiceAssistantFeature.VOICE_ASSISTANT.flag or + VoiceAssistantFeature.API_AUDIO.flag or + VoiceAssistantFeature.TIMERS.flag or + VoiceAssistantFeature.ANNOUNCE.flag or + VoiceAssistantFeature.START_CONVERSATION.flag + }, + voiceAssistant = VoiceAssistant( + coroutineContext = coroutineContext, + voiceInput = microphoneSettingsStore.toVoiceInput(), + voiceOutput = voiceOutput + ), + entities = listOf( + MediaPlayerEntity( + key = 0, + name = "Media Player", + objectId = "media_player", + mediaPlayer = voiceOutput + ), + SwitchEntity( + key = 1, + name = "Mute Microphone", + objectId = "mute_microphone", + getState = microphoneSettingsStore.muted + ) { microphoneSettingsStore.muted.set(it) }, + SwitchEntity( + key = 2, + name = "Enable Wake Sound", + objectId = "enable_wake_sound", + getState = playerSettingsStore.enableWakeSound + ) { playerSettingsStore.enableWakeSound.set(it) }, + SwitchEntity( + key = 3, + name = "Repeat Timer Sound", + objectId = "repeat_timer_sound", + getState = playerSettingsStore.repeatTimerFinishedSound + ) { playerSettingsStore.repeatTimerFinishedSound.set(it) } + ) + ) + } + + private fun MicrophoneSettingsStore.toVoiceInput() = VoiceInputImpl( + availableWakeWords = availableWakeWords, + availableStopWords = availableStopWords, + activeWakeWords = activeWakeWords, + activeStopWords = activeStopWords, + muted = muted + ) + + private suspend fun PlayerSettingsStore.toVoiceOutput(): VoiceOutputImpl { + val playerSettings = get() + return VoiceOutputImpl( + ttsPlayer = createAudioPlayer( + USAGE_ASSISTANT, + AUDIO_CONTENT_TYPE_SPEECH, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + + ), + mediaPlayer = createAudioPlayer( + USAGE_MEDIA, + AUDIO_CONTENT_TYPE_MUSIC, + AudioManager.AUDIOFOCUS_GAIN + ), + enableWakeSound = { enableWakeSound.get() }, + wakeSound = { wakeSound.get() }, + timerFinishedSound = { timerFinishedSound.get() }, + repeatTimerFinishedSound = { repeatTimerFinishedSound.get() }, + enableErrorSound = { enableErrorSound.get() }, + errorSound = { errorSound.get() }, + volume = playerSettings.volume, + volumeChanged = { volume.set(it) }, + muted = playerSettings.muted, + mutedChanged = { muted.set(it) } + ) + } + + @OptIn(UnstableApi::class) + fun createAudioPlayer(usage: Int, contentType: Int, focusGain: Int): AudioPlayer { + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + return AudioPlayerImpl(audioManager, focusGain) { + ExoPlayer.Builder(context).setAudioAttributes( + AudioAttributes.Builder() + .setUsage(usage) + .setContentType(contentType) + .build(), + false + ).build() + } + } +} \ No newline at end of file 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 83c367a..9dc4de6 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -2,30 +2,17 @@ package com.example.ava.services import android.app.NotificationManager import android.content.Intent -import android.media.AudioManager import android.os.Binder import android.os.IBinder import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C.AUDIO_CONTENT_TYPE_MUSIC -import androidx.media3.common.C.AUDIO_CONTENT_TYPE_SPEECH -import androidx.media3.common.C.USAGE_ASSISTANT -import androidx.media3.common.C.USAGE_MEDIA import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer +import com.example.ava.esphome.EspHomeDevice import com.example.ava.esphome.Stopped -import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceSatelliteAudioInputImpl -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayerImpl import com.example.ava.notifications.createVoiceSatelliteServiceNotification import com.example.ava.notifications.createVoiceSatelliteServiceNotificationChannel import com.example.ava.nsd.NsdRegistration import com.example.ava.nsd.registerVoiceSatelliteNsd -import com.example.ava.players.AudioPlayer -import com.example.ava.players.AudioPlayerImpl -import com.example.ava.settings.MicrophoneSettingsStore -import com.example.ava.settings.PlayerSettingsStore import com.example.ava.settings.VoiceSatelliteSettings import com.example.ava.settings.VoiceSatelliteSettingsStore import com.example.ava.tasker.ActivityConfigAvaActivity @@ -36,14 +23,11 @@ import com.joaomgcd.taskerpluginlibrary.extensions.requestQuery import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber @@ -57,21 +41,18 @@ class VoiceSatelliteService() : LifecycleService() { lateinit var satelliteSettingsStore: VoiceSatelliteSettingsStore @Inject - lateinit var microphoneSettingsStore: MicrophoneSettingsStore - - @Inject - lateinit var playerSettingsStore: PlayerSettingsStore + lateinit var deviceBuilder: DeviceBuilder private val wifiWakeLock = WifiWakeLock() private var voiceSatelliteNsd = AtomicReference(null) - private val _voiceSatellite = MutableStateFlow(null) + private val _voiceSatellite = MutableStateFlow(null) val voiceSatelliteState = _voiceSatellite.flatMapLatest { - it?.state ?: flowOf(Stopped) + it?.voiceAssistant?.state ?: flowOf(Stopped) } val voiceTimers = _voiceSatellite.flatMapLatest { - it?.allTimers ?: flowOf(listOf()) + it?.voiceAssistant?.allTimers ?: flowOf(listOf()) } fun startVoiceSatellite() { @@ -96,7 +77,6 @@ class VoiceSatelliteService() : LifecycleService() { wifiWakeLock.create(applicationContext, TAG) createVoiceSatelliteServiceNotificationChannel(this) updateNotificationOnStateChanges() - startSettingsWatcher() startTaskerStateObserver() } @@ -122,7 +102,9 @@ class VoiceSatelliteService() : LifecycleService() { ) satelliteSettingsStore.ensureMacAddressIsSet() val settings = satelliteSettingsStore.get() - _voiceSatellite.value = createVoiceSatellite(settings).apply { start() } + _voiceSatellite.value = + deviceBuilder.buildVoiceSatellite(lifecycleScope.coroutineContext) + .apply { start() } voiceSatelliteNsd.set(registerVoiceSatelliteNsd(settings)) wifiWakeLock.acquire() } @@ -130,30 +112,6 @@ class VoiceSatelliteService() : LifecycleService() { return super.onStartCommand(intent, flags, startId) } - private fun startSettingsWatcher() { - _voiceSatellite.flatMapLatest { satellite -> - if (satellite == null) emptyFlow() - else merge( - // Update settings when satellite changes, - // dropping the initial value to avoid overwriting - // settings with the initial/default values - satellite.audioInput.activeWakeWords.drop(1).onEach { - microphoneSettingsStore.wakeWord.set(it.firstOrNull().orEmpty()) - microphoneSettingsStore.secondWakeWord.set(it.elementAtOrNull(1)) - }, - satellite.audioInput.muted.drop(1).onEach { - microphoneSettingsStore.muted.set(it) - }, - satellite.player.volume.drop(1).onEach { - playerSettingsStore.volume.set(it) - }, - satellite.player.muted.drop(1).onEach { - playerSettingsStore.muted.set(it) - } - ) - }.launchIn(lifecycleScope) - } - private fun startTaskerStateObserver() { combine(voiceSatelliteState, voiceTimers) { state, timers -> AvaActivityRunner.updateState(state, timers) @@ -161,56 +119,9 @@ class VoiceSatelliteService() : LifecycleService() { }.launchIn(lifecycleScope) } - private suspend fun createVoiceSatellite(satelliteSettings: VoiceSatelliteSettings): VoiceSatellite { - val microphoneSettings = microphoneSettingsStore.get() - val audioInput = VoiceSatelliteAudioInputImpl( - activeWakeWords = listOfNotNull( - microphoneSettings.wakeWord, - microphoneSettings.secondWakeWord - ), - activeStopWords = listOf(microphoneSettings.stopWord), - availableWakeWords = microphoneSettingsStore.availableWakeWords.first(), - availableStopWords = microphoneSettingsStore.availableStopWords.first(), - muted = microphoneSettings.muted - ) - - val playerSettings = playerSettingsStore.get() - val player = VoiceSatellitePlayerImpl( - ttsPlayer = createAudioPlayer( - USAGE_ASSISTANT, - AUDIO_CONTENT_TYPE_SPEECH, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK - - ), - mediaPlayer = createAudioPlayer( - USAGE_MEDIA, - AUDIO_CONTENT_TYPE_MUSIC, - AudioManager.AUDIOFOCUS_GAIN - ), - enableWakeSound = playerSettingsStore.enableWakeSound, - wakeSound = playerSettingsStore.wakeSound, - timerFinishedSound = playerSettingsStore.timerFinishedSound, - repeatTimerFinishedSound = playerSettingsStore.repeatTimerFinishedSound, - enableErrorSound = playerSettingsStore.enableErrorSound, - errorSound = playerSettingsStore.errorSound - ).apply { - setVolume(playerSettings.volume) - setMuted(playerSettings.muted) - } - - return VoiceSatellite( - coroutineContext = lifecycleScope.coroutineContext, - name = satelliteSettings.name, - port = satelliteSettings.serverPort, - audioInput = audioInput, - player = player, - settingsStore = satelliteSettingsStore - ) - } - private fun updateNotificationOnStateChanges() = _voiceSatellite .flatMapLatest { - it?.state ?: emptyFlow() + it?.voiceAssistant?.state ?: emptyFlow() } .onEach { (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify( @@ -223,19 +134,6 @@ class VoiceSatelliteService() : LifecycleService() { } .launchIn(lifecycleScope) - fun createAudioPlayer(usage: Int, contentType: Int, focusGain: Int): AudioPlayer { - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager - return AudioPlayerImpl(audioManager, focusGain) { - ExoPlayer.Builder(this@VoiceSatelliteService).setAudioAttributes( - AudioAttributes.Builder() - .setUsage(usage) - .setContentType(contentType) - .build(), - false - ).build() - } - } - private fun registerVoiceSatelliteNsd(settings: VoiceSatelliteSettings) = registerVoiceSatelliteNsd( context = this@VoiceSatelliteService, diff --git a/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt b/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt index 0f6af27..f716eb9 100644 --- a/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt +++ b/app/src/main/java/com/example/ava/settings/MicrophoneSettings.kt @@ -11,10 +11,12 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.serialization.Serializable +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -77,6 +79,27 @@ interface MicrophoneSettingsStore : SettingsStore { val availableStopWords: Flow> } +val MicrophoneSettingsStore.activeWakeWords + get() = SettingState( + flow = combine(wakeWord, secondWakeWord) { wakeWord, secondWakeWord -> + listOfNotNull(wakeWord, secondWakeWord) + } + ) { + if (it.size > 0) { + wakeWord.set(it[0]) + secondWakeWord.set(it.getOrNull(1)) + } else Timber.w("Attempted to set empty active wake word list") + } + +val MicrophoneSettingsStore.activeStopWords + get() = SettingState( + flow = stopWord.map { listOf(it) } + ) { + if (it.size > 0) { + stopWord.set(it[0]) + } else Timber.w("Attempted to set empty stop word list") + } + @Singleton class MicrophoneSettingsStoreImpl @Inject constructor(@param:ApplicationContext private val context: Context) : MicrophoneSettingsStore, SettingsStoreImpl( diff --git a/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt b/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt index ca2a1e4..e785fc8 100644 --- a/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt +++ b/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt @@ -2,10 +2,10 @@ package com.example.ava.tasker import android.content.Context import com.example.ava.esphome.EspHomeState -import com.example.ava.esphome.voicesatellite.Listening -import com.example.ava.esphome.voicesatellite.Processing -import com.example.ava.esphome.voicesatellite.Responding -import com.example.ava.esphome.voicesatellite.VoiceTimer +import com.example.ava.esphome.voiceassistant.Listening +import com.example.ava.esphome.voiceassistant.Processing +import com.example.ava.esphome.voiceassistant.Responding +import com.example.ava.esphome.voiceassistant.VoiceTimer import com.joaomgcd.taskerpluginlibrary.condition.TaskerPluginRunnerConditionState import com.joaomgcd.taskerpluginlibrary.input.TaskerInput import com.joaomgcd.taskerpluginlibrary.input.TaskerInputField diff --git a/app/src/main/java/com/example/ava/ui/services/components/VoiceTimers.kt b/app/src/main/java/com/example/ava/ui/services/components/VoiceTimers.kt index 2e75921..023465b 100644 --- a/app/src/main/java/com/example/ava/ui/services/components/VoiceTimers.kt +++ b/app/src/main/java/com/example/ava/ui/services/components/VoiceTimers.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.ava.R -import com.example.ava.esphome.voicesatellite.VoiceTimer +import com.example.ava.esphome.voiceassistant.VoiceTimer import com.example.ava.ui.services.ServiceViewModel import kotlinx.coroutines.delay import kotlin.time.Clock diff --git a/app/src/main/java/com/example/ava/utils/EspHomeStateTranslations.kt b/app/src/main/java/com/example/ava/utils/EspHomeStateTranslations.kt index b133b48..c95b32b 100644 --- a/app/src/main/java/com/example/ava/utils/EspHomeStateTranslations.kt +++ b/app/src/main/java/com/example/ava/utils/EspHomeStateTranslations.kt @@ -7,9 +7,9 @@ import com.example.ava.esphome.Disconnected import com.example.ava.esphome.EspHomeState import com.example.ava.esphome.ServerError import com.example.ava.esphome.Stopped -import com.example.ava.esphome.voicesatellite.Listening -import com.example.ava.esphome.voicesatellite.Processing -import com.example.ava.esphome.voicesatellite.Responding +import com.example.ava.esphome.voiceassistant.Listening +import com.example.ava.esphome.voiceassistant.Processing +import com.example.ava.esphome.voiceassistant.Responding fun EspHomeState.translate(resources: Resources): String = when (this) { is Stopped -> resources.getString(R.string.satellite_state_stopped) diff --git a/app/src/main/java/com/example/ava/wakewords/microwakeword/MicroWakeWordDetector.kt b/app/src/main/java/com/example/ava/wakewords/microwakeword/MicroWakeWordDetector.kt index ac981b3..f6d427f 100644 --- a/app/src/main/java/com/example/ava/wakewords/microwakeword/MicroWakeWordDetector.kt +++ b/app/src/main/java/com/example/ava/wakewords/microwakeword/MicroWakeWordDetector.kt @@ -2,6 +2,7 @@ package com.example.ava.wakewords.microwakeword import com.example.ava.utils.fillFrom import com.example.microfeatures.MicroFrontend +import timber.log.Timber import java.nio.ByteBuffer private const val SAMPLES_PER_SECOND = 16000 @@ -42,5 +43,6 @@ class MicroWakeWordDetector(private val wakeWords: List) : AutoCl frontend.close() for (model in wakeWords) model.close() + Timber.d("MicroWakeWordDetector closed") } } \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/AnnouncementTest.kt b/app/src/test/java/com/example/ava/AnnouncementTest.kt index cbac828..1185efb 100644 --- a/app/src/test/java/com/example/ava/AnnouncementTest.kt +++ b/app/src/test/java/com/example/ava/AnnouncementTest.kt @@ -2,9 +2,10 @@ package com.example.ava import com.example.ava.esphome.Connected import com.example.ava.esphome.EspHomeState -import com.example.ava.esphome.voicesatellite.Announcement -import com.example.ava.esphome.voicesatellite.Responding -import com.example.ava.stubs.StubAudioPlayer +import com.example.ava.esphome.voiceassistant.Announcement +import com.example.ava.esphome.voiceassistant.Responding +import com.example.ava.esphome.voiceassistant.VoiceOutput +import com.example.ava.stubs.StubVoiceOutput import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished import com.google.protobuf.MessageLite import kotlinx.coroutines.test.TestScope @@ -15,13 +16,13 @@ import kotlin.test.assertEquals class AnnouncementTest { fun TestScope.createAnnouncement( - player: StubAudioPlayer = StubAudioPlayer(), + voiceOutput: VoiceOutput = StubVoiceOutput(), sendMessage: suspend (MessageLite) -> Unit = {}, stateChanged: (EspHomeState) -> Unit = {}, ended: suspend (continueConversation: Boolean) -> Unit = {} ) = Announcement( scope = this, - player = player, + voiceOutput = voiceOutput, sendMessage = sendMessage, stateChanged = stateChanged, ended = ended @@ -34,7 +35,7 @@ class AnnouncementTest { var state: EspHomeState = Connected var ended = false val announcement = createAnnouncement( - player = object : StubAudioPlayer() { + voiceOutput = object : StubVoiceOutput() { override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { playedUrls.addAll(mediaUris) playerCompletion = onCompletion @@ -60,26 +61,12 @@ class AnnouncementTest { assertEquals(true, ended) } - @Test - fun should_not_play_empty_preannounce_sound() = runTest { - val playedUrls = mutableListOf() - val announcement = createAnnouncement( - player = object : StubAudioPlayer() { - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - playedUrls.addAll(mediaUris) - } - } - ) - announcement.announce("media", "", true) - assertEquals(listOf("media"), playedUrls) - } - @Test fun when_responding_should_send_announce_finished_when_stopped() = runTest { val sentMessages = mutableListOf() var state: EspHomeState = Connected val announcement = createAnnouncement( - player = object : StubAudioPlayer() { + voiceOutput = object : StubVoiceOutput() { override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { // Never complete playback } diff --git a/app/src/test/java/com/example/ava/SatelliteTest.kt b/app/src/test/java/com/example/ava/SatelliteTest.kt deleted file mode 100644 index 2dda12c..0000000 --- a/app/src/test/java/com/example/ava/SatelliteTest.kt +++ /dev/null @@ -1,655 +0,0 @@ -package com.example.ava - -import com.example.ava.esphome.Connected -import com.example.ava.esphome.voicesatellite.AudioResult -import com.example.ava.esphome.voicesatellite.Listening -import com.example.ava.esphome.voicesatellite.Processing -import com.example.ava.esphome.voicesatellite.Responding -import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceSatelliteAudioInput -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer -import com.example.ava.server.Server -import com.example.ava.stubs.StubAudioPlayer -import com.example.ava.stubs.StubServer -import com.example.ava.stubs.StubVoiceSatelliteAudioInput -import com.example.ava.stubs.StubVoiceSatellitePlayer -import com.example.ava.stubs.StubVoiceSatelliteSettingsStore -import com.example.ava.stubs.stubSettingState -import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished -import com.example.esphomeproto.api.VoiceAssistantEvent -import com.example.esphomeproto.api.VoiceAssistantRequest -import com.example.esphomeproto.api.voiceAssistantAnnounceRequest -import com.example.esphomeproto.api.voiceAssistantEventData -import com.example.esphomeproto.api.voiceAssistantEventResponse -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals - -class SatelliteTest { - fun TestScope.createSatellite( - server: Server = StubServer(), - audioInput: VoiceSatelliteAudioInput = StubVoiceSatelliteAudioInput(), - player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() - ) = VoiceSatellite( - coroutineContext = this.coroutineContext, - "Test Satellite", - server = server, - audioInput = audioInput, - player = player, - settingsStore = StubVoiceSatelliteSettingsStore() - ).apply { - start() - advanceUntilIdle() - } - - @Test - fun should_handle_wake_word_intercept_during_setup() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(server = server, audioInput = audioInput) - - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - advanceUntilIdle() - - assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) - assertEquals(1, server.sentMessages.size) - assertEquals("wake word", (server.sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) - - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END - }) - advanceUntilIdle() - - assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - assertEquals(1, server.sentMessages.size) - - satellite.close() - } - - @Test - fun should_ignore_duplicate_wake_detections() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(server = server, audioInput = audioInput) - - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - advanceUntilIdle() - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - - assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) - assertEquals(1, server.sentMessages.size) - assertEquals("wake word", (server.sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) - - satellite.close() - } - - @Test - fun should_stop_existing_pipeline_and_restart_on_wake_word() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - var stopped = false - override fun stop() { - stopped = true - } - } - val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer) - ) - - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - advanceUntilIdle() - - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START - }) - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END - }) - advanceUntilIdle() - - assertEquals(Processing, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - server.sentMessages.clear() - - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - advanceUntilIdle() - - assertEquals(true, ttsPlayer.stopped) - - // Should send a pipeline stop request, followed by a start request - assertEquals(2, server.sentMessages.size) - assertEquals(false, (server.sentMessages[0] as VoiceAssistantRequest).start) - assertEquals(true, (server.sentMessages[1] as VoiceAssistantRequest).start) - - // Should correctly handle receiving confirmation of the - // previous pipeline stop before the new pipeline is started - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END - }) - advanceUntilIdle() - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START - }) - advanceUntilIdle() - - assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) - - satellite.close() - } - - @Test - fun should_stop_existing_announcement_and_start_pipeline_on_wake_word() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - val mediaUrls = mutableListOf() - lateinit var onCompletion: () -> Unit - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - this.mediaUrls.addAll(mediaUris) - this.onCompletion = onCompletion - } - - var stopped = false - override fun stop() { - stopped = true - } - } - val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) - ) - - server.receivedMessages.emit(voiceAssistantAnnounceRequest { - preannounceMediaId = "preannounce" - mediaId = "media" - }) - advanceUntilIdle() - - assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) - ttsPlayer.mediaUrls.clear() - - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - advanceUntilIdle() - - // Should stop playback and send an announce finished response - assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assert(server.sentMessages[0] is VoiceAssistantAnnounceFinished) - - // Wake sound playback and completion - assertEquals(listOf("wake"), ttsPlayer.mediaUrls) - ttsPlayer.onCompletion() - advanceUntilIdle() - - // Should send pipeline start request - assertEquals(2, server.sentMessages.size) - assertEquals(true, (server.sentMessages[1] as VoiceAssistantRequest).start) - - assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) - - satellite.close() - } - - @Test - fun should_stop_existing_announcement_and_restart_on_new_announcement() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - val mediaUrls = mutableListOf() - lateinit var onCompletion: () -> Unit - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - this.mediaUrls.addAll(mediaUris) - this.onCompletion = onCompletion - } - - var stopped = false - override fun stop() { - stopped = true - } - } - val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) - ) - - server.receivedMessages.emit(voiceAssistantAnnounceRequest { - preannounceMediaId = "preannounce" - mediaId = "media" - }) - advanceUntilIdle() - - assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) - ttsPlayer.mediaUrls.clear() - - server.receivedMessages.emit(voiceAssistantAnnounceRequest { - preannounceMediaId = "preannounce2" - mediaId = "media2" - }) - advanceUntilIdle() - - // Should stop playback and send an announce finished response - assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assert(server.sentMessages[0] is VoiceAssistantAnnounceFinished) - - // New announcement played - assertEquals(listOf("preannounce2", "media2"), ttsPlayer.mediaUrls) - assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - ttsPlayer.onCompletion() - advanceUntilIdle() - - // Should send an announce finished response - assertEquals(2, server.sentMessages.size) - assert(server.sentMessages[1] is VoiceAssistantAnnounceFinished) - - // And revert to idle - assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - satellite.close() - } - - @Test - fun should_stop_processing_pipeline_on_stop_word() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - var stopped = false - override fun stop() { - stopped = true - } - } - val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) - ) - - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - advanceUntilIdle() - - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START - }) - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END - }) - advanceUntilIdle() - - assertEquals(Processing, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - server.sentMessages.clear() - audioInput.audioResults.emit(AudioResult.StopDetected()) - advanceUntilIdle() - - // Should stop playback and send a pipeline stop request - assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assertEquals(false, (server.sentMessages[0] as VoiceAssistantRequest).start) - - // And revert to idle - assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - satellite.close() - } - - @Test - fun should_stop_tts_playback_on_stop_word() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - val mediaUrls = mutableListOf() - lateinit var onCompletion: () -> Unit - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - this.mediaUrls.addAll(mediaUris) - this.onCompletion = onCompletion - } - - var stopped = false - override fun stop() { - stopped = true - } - } - val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) - ) - - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - advanceUntilIdle() - - assertEquals("wake", ttsPlayer.mediaUrls[0]) - // Wake sound finished - ttsPlayer.onCompletion() - advanceUntilIdle() - - ttsPlayer.mediaUrls.clear() - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START - }) - // Start TTS playback - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_START - }) - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_END - data += voiceAssistantEventData { name = "url"; value = "tts" } - }) - advanceUntilIdle() - - assertEquals("tts", ttsPlayer.mediaUrls[0]) - assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - server.sentMessages.clear() - audioInput.audioResults.emit(AudioResult.StopDetected()) - advanceUntilIdle() - - // Should stop playback and send an announce finished response - assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assert(server.sentMessages[0] is VoiceAssistantAnnounceFinished) - - // And revert to idle - assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - satellite.close() - } - - @Test - fun should_stop_announcement_on_stop_word() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - val mediaUrls = mutableListOf() - lateinit var onCompletion: () -> Unit - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - this.mediaUrls.addAll(mediaUris) - this.onCompletion = onCompletion - } - - var stopped = false - override fun stop() { - stopped = true - } - } - val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) - ) - - server.receivedMessages.emit(voiceAssistantAnnounceRequest { - preannounceMediaId = "preannounce" - mediaId = "media" - }) - advanceUntilIdle() - - assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) - - audioInput.audioResults.emit(AudioResult.StopDetected()) - advanceUntilIdle() - - // Should stop playback and send an announce finished response - assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assert(server.sentMessages[0] is VoiceAssistantAnnounceFinished) - - // And revert to idle - assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) - - satellite.close() - } - - @Test - fun should_duck_media_volume_during_pipeline_run() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - var isDucked = false - val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } - ) - audioInput.audioResults.emit(AudioResult.WakeDetected("")) - advanceUntilIdle() - - // Should duck immediately after the wake word - assertEquals(true, isDucked) - - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END - }) - advanceUntilIdle() - - // Should un-duck and revert to idle when the pipeline ends - assertEquals(false, isDucked) - assertEquals(Connected, satellite.state.value) - - satellite.close() - } - - @Test - fun should_un_duck_media_volume_when_pipeline_stopped() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - var isDucked = false - val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } - ) - - audioInput.audioResults.emit(AudioResult.WakeDetected("")) - advanceUntilIdle() - - // Should duck immediately after the wake word - assertEquals(true, isDucked) - - // Stop detections are ignored when the satellite is in - // the listening state, so change state to processing - server.receivedMessages.emit(voiceAssistantEventResponse { - eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END - }) - - assertEquals(Processing, satellite.state.value) - assertEquals(true, isDucked) - - // Stop the pipeline - audioInput.audioResults.emit(AudioResult.StopDetected()) - advanceUntilIdle() - - // Should un-duck and revert to idle - assertEquals(false, isDucked) - assertEquals(Connected, satellite.state.value) - - satellite.close() - } - - @Test - fun should_duck_media_volume_during_announcement() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - lateinit var onCompletion: () -> Unit - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - this.onCompletion = onCompletion - } - } - var isDucked = false - val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( - ttsPlayer = ttsPlayer, - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } - ) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { - preannounceMediaId = "preannounce" - mediaId = "media" - }) - advanceUntilIdle() - - // Should duck whilst the announcement is playing - assertEquals(true, isDucked) - - ttsPlayer.onCompletion() - advanceUntilIdle() - - // Should un-duck and revert to idle when the announcement finishes - assertEquals(false, isDucked) - assertEquals(Connected, satellite.state.value) - - satellite.close() - } - - @Test - fun should_un_duck_media_volume_when_announcement_stopped() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - lateinit var onCompletion: () -> Unit - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - this.onCompletion = onCompletion - } - } - var isDucked = false - val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( - ttsPlayer = ttsPlayer, - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } - ) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { - preannounceMediaId = "preannounce" - mediaId = "media" - }) - advanceUntilIdle() - - // Should duck whilst the announcement is playing - assertEquals(true, isDucked) - - // Stop the announcement - audioInput.audioResults.emit(AudioResult.StopDetected()) - advanceUntilIdle() - - // Should un-duck and revert to idle - assertEquals(false, isDucked) - assertEquals(Connected, satellite.state.value) - - satellite.close() - } - - @Test - fun should_not_un_duck_media_volume_when_starting_conversation() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val ttsPlayer = object : StubAudioPlayer() { - lateinit var onCompletion: () -> Unit - override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { - this.onCompletion = onCompletion - } - } - var isDucked = false - val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( - ttsPlayer = ttsPlayer, - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } - ) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { - preannounceMediaId = "preannounce" - mediaId = "media" - startConversation = true - }) - advanceUntilIdle() - - // Should duck whilst the announcement is playing - assertEquals(true, isDucked) - - // End the announcement and start conversation - ttsPlayer.onCompletion() - advanceUntilIdle() - - // Should be ducked and in the listening state - assertEquals(true, isDucked) - assertEquals(Listening, satellite.state.value) - satellite.close() - } -} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt index c3ab25b..cf69ba0 100644 --- a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt +++ b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt @@ -1,30 +1,22 @@ package com.example.ava import android.content.ContextWrapper -import androidx.compose.material3.TimeInput -import com.example.ava.esphome.voicesatellite.AudioResult -import com.example.ava.esphome.voicesatellite.Listening -import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceSatelliteAudioInput -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer -import com.example.ava.server.Server -import com.example.ava.stubs.StubAudioPlayer -import com.example.ava.stubs.StubServer -import com.example.ava.stubs.StubVoiceSatelliteAudioInput -import com.example.ava.stubs.StubVoiceSatellitePlayer -import com.example.ava.stubs.StubVoiceSatelliteSettingsStore -import com.example.ava.stubs.stubSettingState -import com.example.ava.tasker.AvaActivityInput -import com.example.ava.tasker.AvaActivityRunner +import com.example.ava.esphome.voiceassistant.Listening +import com.example.ava.esphome.voiceassistant.VoiceAssistant +import com.example.ava.esphome.voiceassistant.VoiceInput +import com.example.ava.esphome.voiceassistant.VoiceOutput +import com.example.ava.stubs.StubVoiceInput +import com.example.ava.stubs.StubVoiceOutput import com.example.ava.tasker.StopRingingRunner import com.example.ava.tasker.WakeSatelliteRunner import com.example.esphomeproto.api.VoiceAssistantRequest import com.example.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.voiceAssistantTimerEventResponse +import com.google.protobuf.MessageLite import com.joaomgcd.taskerpluginlibrary.input.TaskerInput -import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionSatisfied -import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionUnsatisfied import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -34,17 +26,13 @@ import kotlin.test.assertEquals class TaskerPluginsTest { private val dummyContext = ContextWrapper(null) - private fun TestScope.createSatellite( - server: Server = StubServer(), - audioInput: VoiceSatelliteAudioInput = StubVoiceSatelliteAudioInput(), - player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() - ) = VoiceSatellite( + private fun TestScope.createVoiceAssistant( + voiceInput: VoiceInput = StubVoiceInput(), + voiceOutput: VoiceOutput = StubVoiceOutput() + ) = VoiceAssistant( coroutineContext = this.coroutineContext, - "Test Satellite", - server = server, - audioInput = audioInput, - player = player, - settingsStore = StubVoiceSatelliteSettingsStore() + voiceInput = voiceInput, + voiceOutput = voiceOutput ).apply { start() advanceUntilIdle() @@ -52,27 +40,28 @@ class TaskerPluginsTest { @Test fun should_handle_wake_satellite_action() = runTest { - val server = StubServer() - val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(server = server, audioInput = audioInput) + val voiceInput = StubVoiceInput() + val voiceAssistant = createVoiceAssistant(voiceInput = voiceInput) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) advanceUntilIdle() val result = WakeSatelliteRunner().run(dummyContext, TaskerInput(Unit)) assert(result is TaskerPluginResultSucess) advanceUntilIdle() - assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) - assertEquals(1, server.sentMessages.size) - assertEquals(true, (server.sentMessages[0] as VoiceAssistantRequest).start) + assertEquals(Listening, voiceAssistant.state.value) + assertEquals(true, voiceInput.isStreaming) + assertEquals(1, sentMessages.size) + assertEquals(true, (sentMessages[0] as VoiceAssistantRequest).start) - satellite.close() + messageJob.cancel() + voiceAssistant.close() } @Test fun should_handle_stop_ringing_action() = runTest { - val server = StubServer() - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput(timerFinishedSound = "ring") { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -81,21 +70,14 @@ class TaskerPluginsTest { } var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } - val satellite = createSatellite( - server = server, - player = StubVoiceSatellitePlayer( - ttsPlayer = ttsPlayer, - repeatTimerFinishedSound = stubSettingState(true), - timerFinishedSound = stubSettingState("ring") - ) - ) + val voiceAssistant = createVoiceAssistant(voiceOutput = voiceOutput) // Make it ring by sending a timer finished event - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "id" totalSeconds = 60 @@ -104,9 +86,9 @@ class TaskerPluginsTest { }) advanceUntilIdle() - assertEquals(false, ttsPlayer.stopped) - assertEquals(listOf("ring"), ttsPlayer.mediaUrls) - ttsPlayer.mediaUrls.clear() + assertEquals(false, voiceOutput.stopped) + assertEquals(listOf("ring"), voiceOutput.mediaUrls) + voiceOutput.mediaUrls.clear() // Trigger StopRingingAction via its runner val result = StopRingingRunner().run(dummyContext, TaskerInput(Unit)) @@ -114,8 +96,8 @@ class TaskerPluginsTest { advanceUntilIdle() // Should no longer be ringing - assertEquals(true, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) - satellite.close() + voiceAssistant.close() } } \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/VoiceAssistantTest.kt b/app/src/test/java/com/example/ava/VoiceAssistantTest.kt new file mode 100644 index 0000000..81142f3 --- /dev/null +++ b/app/src/test/java/com/example/ava/VoiceAssistantTest.kt @@ -0,0 +1,642 @@ +package com.example.ava + +import com.example.ava.esphome.Connected +import com.example.ava.esphome.voiceassistant.AudioResult +import com.example.ava.esphome.voiceassistant.Listening +import com.example.ava.esphome.voiceassistant.Processing +import com.example.ava.esphome.voiceassistant.Responding +import com.example.ava.esphome.voiceassistant.VoiceAssistant +import com.example.ava.esphome.voiceassistant.VoiceInput +import com.example.ava.esphome.voiceassistant.VoiceOutput +import com.example.ava.stubs.StubVoiceInput +import com.example.ava.stubs.StubVoiceOutput +import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished +import com.example.esphomeproto.api.VoiceAssistantEvent +import com.example.esphomeproto.api.VoiceAssistantRequest +import com.example.esphomeproto.api.voiceAssistantAnnounceRequest +import com.example.esphomeproto.api.voiceAssistantEventData +import com.example.esphomeproto.api.voiceAssistantEventResponse +import com.google.protobuf.MessageLite +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class VoiceAssistantTest { + suspend fun TestScope.createVoiceAssistant( + voiceInput: VoiceInput = StubVoiceInput(), + voiceOutput: VoiceOutput = StubVoiceOutput() + ) = VoiceAssistant( + coroutineContext = this.coroutineContext, + voiceInput = voiceInput, + voiceOutput = voiceOutput, + ).apply { + start() + onConnected() + advanceUntilIdle() + } + + @Test + fun should_handle_wake_word_intercept_during_setup() = runTest { + val voiceInput = StubVoiceInput() + val voiceAssistant = createVoiceAssistant(voiceInput = voiceInput) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + advanceUntilIdle() + + assertEquals(Listening, voiceAssistant.state.value) + assertEquals(true, voiceInput.isStreaming) + assertEquals(1, sentMessages.size) + assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) + + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END + }) + advanceUntilIdle() + + assertEquals(Connected, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + assertEquals(1, sentMessages.size) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_ignore_duplicate_wake_detections() = runTest { + val voiceInput = StubVoiceInput() + val voiceAssistant = createVoiceAssistant(voiceInput = voiceInput) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + advanceUntilIdle() + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + + assertEquals(Listening, voiceAssistant.state.value) + assertEquals(true, voiceInput.isStreaming) + assertEquals(1, sentMessages.size) + assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_stop_existing_pipeline_and_restart_on_wake_word() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput() { + var stopped = false + override fun stopTTS() { + stopped = true + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + advanceUntilIdle() + + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START + }) + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END + }) + advanceUntilIdle() + + assertEquals(Processing, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + sentMessages.clear() + + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + advanceUntilIdle() + + assertEquals(true, voiceOutput.stopped) + + // Should send a pipeline stop request, followed by a start request + assertEquals(2, sentMessages.size) + assertEquals(false, (sentMessages[0] as VoiceAssistantRequest).start) + assertEquals(true, (sentMessages[1] as VoiceAssistantRequest).start) + + // Should correctly handle receiving confirmation of the + // previous pipeline stop before the new pipeline is started + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END + }) + advanceUntilIdle() + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START + }) + advanceUntilIdle() + + assertEquals(Listening, voiceAssistant.state.value) + assertEquals(true, voiceInput.isStreaming) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_stop_existing_announcement_and_start_pipeline_on_wake_word() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput( + wakeSound = "wake" + ) { + val mediaUrls = mutableListOf() + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.mediaUrls.addAll(mediaUris) + this.onCompletion = onCompletion + } + + var stopped = false + override fun stopTTS() { + stopped = true + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { + preannounceMediaId = "preannounce" + mediaId = "media" + }) + advanceUntilIdle() + + assertEquals(Responding, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) + voiceOutput.mediaUrls.clear() + + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + advanceUntilIdle() + + // Should stop playback and send an announce finished response + assertEquals(true, voiceOutput.stopped) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) + + // Wake sound playback and completion + assertEquals(listOf("wake"), voiceOutput.mediaUrls) + voiceOutput.onCompletion() + advanceUntilIdle() + + // Should send pipeline start request + assertEquals(2, sentMessages.size) + assertEquals(true, (sentMessages[1] as VoiceAssistantRequest).start) + + assertEquals(Listening, voiceAssistant.state.value) + assertEquals(true, voiceInput.isStreaming) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_stop_existing_announcement_and_restart_on_new_announcement() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput() { + val mediaUrls = mutableListOf() + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.mediaUrls.addAll(mediaUris) + this.onCompletion = onCompletion + } + + var stopped = false + override fun stopTTS() { + stopped = true + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { + preannounceMediaId = "preannounce" + mediaId = "media" + }) + advanceUntilIdle() + + assertEquals(Responding, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) + voiceOutput.mediaUrls.clear() + + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { + preannounceMediaId = "preannounce2" + mediaId = "media2" + }) + advanceUntilIdle() + + // Should stop playback and send an announce finished response + assertEquals(true, voiceOutput.stopped) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) + + // New announcement played + assertEquals(listOf("preannounce2", "media2"), voiceOutput.mediaUrls) + assertEquals(Responding, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + voiceOutput.onCompletion() + advanceUntilIdle() + + // Should send an announce finished response + assertEquals(2, sentMessages.size) + assert(sentMessages[1] is VoiceAssistantAnnounceFinished) + + // And revert to idle + assertEquals(Connected, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_stop_processing_pipeline_on_stop_word() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput() { + var stopped = false + override fun stopTTS() { + stopped = true + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + advanceUntilIdle() + + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START + }) + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END + }) + advanceUntilIdle() + + assertEquals(Processing, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + sentMessages.clear() + voiceInput.audioResults.emit(AudioResult.StopDetected()) + advanceUntilIdle() + + // Should stop playback and send a pipeline stop request + assertEquals(true, voiceOutput.stopped) + assertEquals(1, sentMessages.size) + assertEquals(false, (sentMessages[0] as VoiceAssistantRequest).start) + + // And revert to idle + assertEquals(Connected, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_stop_tts_playback_on_stop_word() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput( + wakeSound = "wake" + ) { + val mediaUrls = mutableListOf() + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.mediaUrls.addAll(mediaUris) + this.onCompletion = onCompletion + } + + var stopped = false + override fun stopTTS() { + stopped = true + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + advanceUntilIdle() + + assertEquals("wake", voiceOutput.mediaUrls[0]) + // Wake sound finished + voiceOutput.onCompletion() + advanceUntilIdle() + + voiceOutput.mediaUrls.clear() + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START + }) + // Start TTS playback + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_START + }) + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_END + data += voiceAssistantEventData { name = "url"; value = "tts" } + }) + advanceUntilIdle() + + assertEquals("tts", voiceOutput.mediaUrls[0]) + assertEquals(Responding, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + sentMessages.clear() + voiceInput.audioResults.emit(AudioResult.StopDetected()) + advanceUntilIdle() + + // Should stop playback and send an announce finished response + assertEquals(true, voiceOutput.stopped) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) + + // And revert to idle + assertEquals(Connected, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_stop_announcement_on_stop_word() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput() { + val mediaUrls = mutableListOf() + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.mediaUrls.addAll(mediaUris) + this.onCompletion = onCompletion + } + + var stopped = false + override fun stopTTS() { + stopped = true + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + val sentMessages = mutableListOf() + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { + preannounceMediaId = "preannounce" + mediaId = "media" + }) + advanceUntilIdle() + + assertEquals(Responding, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) + + voiceInput.audioResults.emit(AudioResult.StopDetected()) + advanceUntilIdle() + + // Should stop playback and send an announce finished response + assertEquals(true, voiceOutput.stopped) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) + + // And revert to idle + assertEquals(Connected, voiceAssistant.state.value) + assertEquals(false, voiceInput.isStreaming) + + messageJob.cancel() + voiceAssistant.close() + } + + @Test + fun should_duck_media_volume_during_pipeline_run() = runTest { + val voiceInput = StubVoiceInput() + var isDucked = false + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = object : StubVoiceOutput() { + override fun duck() { + isDucked = true + } + + override fun unDuck() { + isDucked = false + } + } + ) + + voiceInput.audioResults.emit(AudioResult.WakeDetected("")) + advanceUntilIdle() + + // Should duck immediately after the wake word + assertEquals(true, isDucked) + + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END + }) + advanceUntilIdle() + + // Should un-duck and revert to idle when the pipeline ends + assertEquals(false, isDucked) + assertEquals(Connected, voiceAssistant.state.value) + + voiceAssistant.close() + } + + @Test + fun should_un_duck_media_volume_when_pipeline_stopped() = runTest { + val voiceInput = StubVoiceInput() + var isDucked = false + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = object : StubVoiceOutput() { + override fun duck() { + isDucked = true + } + + override fun unDuck() { + isDucked = false + } + } + ) + + voiceInput.audioResults.emit(AudioResult.WakeDetected("")) + advanceUntilIdle() + + // Should duck immediately after the wake word + assertEquals(true, isDucked) + + // Stop detections are ignored when the satellite is in + // the listening state, so change state to processing + voiceAssistant.handleMessage(voiceAssistantEventResponse { + eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END + }) + + assertEquals(Processing, voiceAssistant.state.value) + assertEquals(true, isDucked) + + // Stop the pipeline + voiceInput.audioResults.emit(AudioResult.StopDetected()) + advanceUntilIdle() + + // Should un-duck and revert to idle + assertEquals(false, isDucked) + assertEquals(Connected, voiceAssistant.state.value) + + voiceAssistant.close() + } + + @Test + fun should_duck_media_volume_during_announcement() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput() { + var isDucked = false + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.onCompletion = onCompletion + } + + override fun duck() { + isDucked = true + } + + override fun unDuck() { + isDucked = false + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { + preannounceMediaId = "preannounce" + mediaId = "media" + }) + advanceUntilIdle() + + // Should duck whilst the announcement is playing + assertEquals(true, voiceOutput.isDucked) + + voiceOutput.onCompletion() + advanceUntilIdle() + + // Should un-duck and revert to idle when the announcement finishes + assertEquals(false, voiceOutput.isDucked) + assertEquals(Connected, voiceAssistant.state.value) + + voiceAssistant.close() + } + + @Test + fun should_un_duck_media_volume_when_announcement_stopped() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput() { + var isDucked = false + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.onCompletion = onCompletion + } + + override fun duck() { + isDucked = true + } + + override fun unDuck() { + isDucked = false + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { + preannounceMediaId = "preannounce" + mediaId = "media" + }) + advanceUntilIdle() + + // Should duck whilst the announcement is playing + assertEquals(true, voiceOutput.isDucked) + + // Stop the announcement + voiceInput.audioResults.emit(AudioResult.StopDetected()) + advanceUntilIdle() + + // Should un-duck and revert to idle + assertEquals(false, voiceOutput.isDucked) + assertEquals(Connected, voiceAssistant.state.value) + + voiceAssistant.close() + } + + @Test + fun should_not_un_duck_media_volume_when_starting_conversation() = runTest { + val voiceInput = StubVoiceInput() + val voiceOutput = object : StubVoiceOutput() { + var isDucked = false + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.onCompletion = onCompletion + } + + override fun duck() { + isDucked = true + } + + override fun unDuck() { + isDucked = false + } + } + val voiceAssistant = createVoiceAssistant( + voiceInput = voiceInput, + voiceOutput = voiceOutput + ) + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { + preannounceMediaId = "preannounce" + mediaId = "media" + startConversation = true + }) + advanceUntilIdle() + + // Should duck whilst the announcement is playing + assertEquals(true, voiceOutput.isDucked) + + // End the announcement and start conversation + voiceOutput.onCompletion() + advanceUntilIdle() + + // Should be ducked and in the listening state + assertEquals(true, voiceOutput.isDucked) + assertEquals(Listening, voiceAssistant.state.value) + voiceAssistant.close() + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt b/app/src/test/java/com/example/ava/VoiceAssistantTimerTest.kt similarity index 57% rename from app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt rename to app/src/test/java/com/example/ava/VoiceAssistantTimerTest.kt index cd3f398..a97b512 100644 --- a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceAssistantTimerTest.kt @@ -1,16 +1,12 @@ package com.example.ava -import com.example.ava.esphome.voicesatellite.AudioResult -import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceTimer -import com.example.ava.players.AudioPlayer -import com.example.ava.server.Server -import com.example.ava.stubs.StubAudioPlayer -import com.example.ava.stubs.StubServer -import com.example.ava.stubs.StubVoiceSatelliteAudioInput -import com.example.ava.stubs.StubVoiceSatellitePlayer -import com.example.ava.stubs.StubVoiceSatelliteSettingsStore -import com.example.ava.stubs.stubSettingState +import com.example.ava.esphome.voiceassistant.AudioResult +import com.example.ava.esphome.voiceassistant.VoiceAssistant +import com.example.ava.esphome.voiceassistant.VoiceInput +import com.example.ava.esphome.voiceassistant.VoiceOutput +import com.example.ava.esphome.voiceassistant.VoiceTimer +import com.example.ava.stubs.StubVoiceInput +import com.example.ava.stubs.StubVoiceOutput import com.example.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.voiceAssistantTimerEventResponse import kotlinx.coroutines.flow.first @@ -24,38 +20,25 @@ import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -class VoiceSatelliteTimerTest { - private fun TestScope.start_satellite( - server: Server, - player: AudioPlayer = StubAudioPlayer(), - repeatTimerFinishedSound: Boolean = false - ) = - VoiceSatellite( - coroutineContext = coroutineContext, - name = "Test Satellite", - server = server, - audioInput = StubVoiceSatelliteAudioInput(), - player = StubVoiceSatellitePlayer( - ttsPlayer = player, - wakeSound = stubSettingState("wake.mp3"), - timerFinishedSound = stubSettingState("timer.mp3"), - repeatTimerFinishedSound = stubSettingState(repeatTimerFinishedSound) - ), - settingsStore = StubVoiceSatelliteSettingsStore() - ).apply { - start() - // Internally the voice satellite starts collecting server and - // microphone messages in separate coroutines, wait for collection - // to start to ensure all messages are collected. - advanceUntilIdle() - } +class VoiceAssistantTimerTest { + private suspend fun TestScope.createVoiceAssistant( + voiceInput: VoiceInput = StubVoiceInput(), + voiceOutput: VoiceOutput = StubVoiceOutput() + ) = VoiceAssistant( + coroutineContext = this.coroutineContext, + voiceInput = voiceInput, + voiceOutput = voiceOutput, + ).apply { + start() + onConnected() + advanceUntilIdle() + } @Test fun should_store_and_sort_timers() = runTest { - val server = StubServer() - val voiceSatellite = start_satellite(server) + val voiceAssistant = createVoiceAssistant() - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "running1" totalSeconds = 61 @@ -63,14 +46,14 @@ class VoiceSatelliteTimerTest { isActive = true }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "paused1" totalSeconds = 62 secondsLeft = 10 isActive = false // Sorted last because paused }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "running2" totalSeconds = 63 @@ -79,7 +62,7 @@ class VoiceSatelliteTimerTest { name = "Named" }) - var timers = voiceSatellite.allTimers.first() + var timers = voiceAssistant.allTimers.first() assertEquals(3, timers.size) assertEquals(listOf("running2", "running1", "paused1"), timers.map { it.id }) assertEquals(listOf("Named", "", ""), timers.map { it.name }) @@ -90,11 +73,11 @@ class VoiceSatelliteTimerTest { assertTrue { remaining2Millis <= 50_000 } assertTrue { remaining2Millis > 49_900 } - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_CANCELLED timerId = "running1" }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_UPDATED timerId = "paused1" totalSeconds = 62 @@ -102,24 +85,23 @@ class VoiceSatelliteTimerTest { isActive = true // Unpaused now }) - timers = voiceSatellite.allTimers.first() + timers = voiceAssistant.allTimers.first() assertEquals(listOf("paused1", "running2"), timers.map { it.id }) - voiceSatellite.close() + voiceAssistant.close() } @Test fun should_display_then_remove_finished_timer() = runTest { - var audioPlayed: String? = null - val audioPlayer = object : StubAudioPlayer() { - override fun play(mediaUri: String, onCompletion: () -> Unit) { - audioPlayed = mediaUri - onCompletion() + var audioPlayed = false + val voiceOutput = object : StubVoiceOutput() { + override suspend fun playTimerFinishedSound(onCompletion: (repeat: Boolean) -> Unit) { + audioPlayed = true + onCompletion(false) } } - val server = StubServer() - val voiceSatellite = start_satellite(server, audioPlayer, false) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + val voiceAssistant = createVoiceAssistant(voiceOutput = voiceOutput) + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "timer2" totalSeconds = 61 @@ -127,18 +109,18 @@ class VoiceSatelliteTimerTest { isActive = true name = "Will ring" }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "timer1" totalSeconds = 60 secondsLeft = 60 isActive = true }) - var timers = voiceSatellite.allTimers.first() + var timers = voiceAssistant.allTimers.first() assertEquals(listOf("timer2", "timer1"), timers.map { it.id }) assert(timers[0] is VoiceTimer.Running) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "timer2" totalSeconds = 61 @@ -146,7 +128,7 @@ class VoiceSatelliteTimerTest { isActive = false name = "Will ring" }) - timers = voiceSatellite.allTimers.first() + timers = voiceAssistant.allTimers.first() assertEquals(VoiceTimer.Ringing("timer2", "Will ring", 61.seconds), timers[0]) assertEquals(Duration.ZERO, timers[0].remainingDuration(Clock.System.now())) @@ -155,20 +137,19 @@ class VoiceSatelliteTimerTest { // so it needs to waited on here advanceUntilIdle() - timers = voiceSatellite.allTimers.first() + timers = voiceAssistant.allTimers.first() assertEquals(listOf("timer1"), timers.map { it.id }) assert(timers[0] is VoiceTimer.Running) - assertEquals("timer.mp3", audioPlayed) + assert(audioPlayed) - voiceSatellite.close() + voiceAssistant.close() } @Test fun should_remove_repeating_timer_on_wake_word() = runTest { - val server = StubServer() - val voiceSatellite = start_satellite(server, repeatTimerFinishedSound = true) + val voiceAssistant = createVoiceAssistant() - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "ringing1" totalSeconds = 61 @@ -176,26 +157,26 @@ class VoiceSatelliteTimerTest { isActive = false name = "Will ring" }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "paused1" totalSeconds = 62 secondsLeft = 10 isActive = false }) - var timers = voiceSatellite.allTimers.first() + var timers = voiceAssistant.allTimers.first() assertEquals(2, timers.size) assert(timers[0] is VoiceTimer.Ringing) assert(timers[1] is VoiceTimer.Paused) - (voiceSatellite.audioInput as StubVoiceSatelliteAudioInput).audioResults.emit( + (voiceAssistant.voiceInput as StubVoiceInput).audioResults.emit( AudioResult.WakeDetected("stop") ) - timers = voiceSatellite.allTimers.first() + timers = voiceAssistant.allTimers.first() assertEquals(1, timers.size) assert(timers[0] is VoiceTimer.Paused) - voiceSatellite.close() + voiceAssistant.close() } } diff --git a/app/src/test/java/com/example/ava/VoiceOutputTest.kt b/app/src/test/java/com/example/ava/VoiceOutputTest.kt new file mode 100644 index 0000000..986855f --- /dev/null +++ b/app/src/test/java/com/example/ava/VoiceOutputTest.kt @@ -0,0 +1,154 @@ +package com.example.ava + +import com.example.ava.esphome.voiceassistant.VoiceOutputImpl +import com.example.ava.players.AudioPlayer +import com.example.ava.stubs.StubAudioPlayer +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class VoiceOutputTest { + fun createVoiceOutput( + ttsPlayer: AudioPlayer = StubAudioPlayer(), + mediaPlayer: AudioPlayer = StubAudioPlayer(), + volume: Float = 1f, + muted: Boolean = false, + duckMultiplier: Float = 1f + ) = VoiceOutputImpl( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer, + enableWakeSound = { false }, + wakeSound = { "" }, + timerFinishedSound = { "" }, + repeatTimerFinishedSound = { true }, + enableErrorSound = { true }, + errorSound = { "" }, + volume = volume, + muted = muted, + duckMultiplier = duckMultiplier + ) + + @Test + fun should_set_initial_volume() { + val ttsPlayer = StubAudioPlayer() + val mediaPlayer = StubAudioPlayer() + val volume = 0.5f + createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer, + volume = volume + ) + + assert(ttsPlayer.volume == volume) + assert(mediaPlayer.volume == volume) + } + + @Test + fun should_set_initial_muted_state() { + val ttsPlayer = StubAudioPlayer() + val mediaPlayer = StubAudioPlayer() + val muted = true + createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer, + muted = muted + ) + + assert(ttsPlayer.volume == 0f) + assert(mediaPlayer.volume == 0f) + } + + @Test + fun should_set_volume_when_not_muted() = runTest { + val ttsPlayer = StubAudioPlayer() + val mediaPlayer = StubAudioPlayer() + val voiceOutput = createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer + ) + val volume = 0.5f + + voiceOutput.setVolume(volume) + + assert(ttsPlayer.volume == volume) + assert(mediaPlayer.volume == volume) + } + + @Test + fun should_not_set_volume_when_muted() = runTest { + val ttsPlayer = StubAudioPlayer() + val mediaPlayer = StubAudioPlayer() + val voiceOutput = createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer + ) + val volume = 0.5f + + voiceOutput.setMuted(true) + voiceOutput.setVolume(volume) + + assert(ttsPlayer.volume == 0f) + assert(mediaPlayer.volume == 0f) + + voiceOutput.setMuted(false) + + assert(ttsPlayer.volume == volume) + assert(mediaPlayer.volume == volume) + } + + @Test + fun should_set_muted() = runTest { + val ttsPlayer = StubAudioPlayer() + val mediaPlayer = StubAudioPlayer() + val voiceOutput = createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer + ) + + voiceOutput.setMuted(true) + + assert(ttsPlayer.volume == 0f) + assert(mediaPlayer.volume == 0f) + + voiceOutput.setMuted(false) + + assert(ttsPlayer.volume == 1f) + assert(mediaPlayer.volume == 1f) + } + + @Test + fun should_duck_media_player() { + val ttsPlayer = StubAudioPlayer() + val mediaPlayer = StubAudioPlayer() + val duckMultiplier = 0.5f + val voiceOutput = createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer, + duckMultiplier = duckMultiplier + ) + + voiceOutput.duck() + + assert(ttsPlayer.volume == voiceOutput.volume.value) + assert(mediaPlayer.volume == voiceOutput.volume.value * duckMultiplier) + + voiceOutput.unDuck() + + assert(ttsPlayer.volume == voiceOutput.volume.value) + assert(mediaPlayer.volume == voiceOutput.volume.value) + } + + @Test + fun should_not_play_empty_preannounce_sound() = runTest { + val playedUrls = mutableListOf() + val voiceOutput = createVoiceOutput( + ttsPlayer = object : StubAudioPlayer() { + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + playedUrls.addAll(mediaUris) + } + } + ) + voiceOutput.playAnnouncement(preannounceUrl = "", mediaUrl = "media") + assertEquals(listOf("media"), playedUrls) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/VoicePipelineTest.kt b/app/src/test/java/com/example/ava/VoicePipelineTest.kt index d9d10d0..e247043 100644 --- a/app/src/test/java/com/example/ava/VoicePipelineTest.kt +++ b/app/src/test/java/com/example/ava/VoicePipelineTest.kt @@ -2,12 +2,10 @@ package com.example.ava import com.example.ava.esphome.Connected 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.ava.esphome.voiceassistant.Listening +import com.example.ava.esphome.voiceassistant.Responding +import com.example.ava.esphome.voiceassistant.VoicePipeline +import com.example.ava.stubs.StubVoiceOutput import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished import com.example.esphomeproto.api.VoiceAssistantAudio import com.example.esphomeproto.api.VoiceAssistantEvent @@ -24,14 +22,14 @@ import kotlin.test.assertEquals class VoicePipelineTest { fun TestScope.createPipeline( - player: StubVoiceSatellitePlayer = StubVoiceSatellitePlayer(), + voiceOutput: StubVoiceOutput = StubVoiceOutput(), sendMessage: suspend (MessageLite) -> Unit = {}, listeningChanged: (Boolean) -> Unit = {}, stateChanged: (EspHomeState) -> Unit = {}, ended: suspend (continueConversation: Boolean) -> Unit = {} ) = VoicePipeline( scope = this, - player = player, + voiceOutput = voiceOutput, sendMessage = sendMessage, listeningChanged = listeningChanged, stateChanged = stateChanged, @@ -151,13 +149,10 @@ class VoicePipelineTest { val notTtsStreamUrl = "not_tts_stream" var playbackUrl: String? = null val pipeline = createPipeline( - player = object : StubVoiceSatellitePlayer() { - override val ttsPlayer: AudioPlayer - get() = object : StubAudioPlayer() { - override fun play(mediaUri: String, onCompletion: () -> Unit) { - playbackUrl = mediaUri - } - } + voiceOutput = object : StubVoiceOutput() { + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) { + playbackUrl = ttsUrl + } } ) @@ -183,13 +178,10 @@ class VoicePipelineTest { val notTtsStreamUrl = "not_tts_stream" var playbackUrl: String? = null val pipeline = createPipeline( - player = object : StubVoiceSatellitePlayer() { - override val ttsPlayer: AudioPlayer - get() = object : StubAudioPlayer() { - override fun play(mediaUri: String, onCompletion: () -> Unit) { - playbackUrl = mediaUri - } - } + voiceOutput = object : StubVoiceOutput() { + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) { + playbackUrl = ttsUrl + } } ) @@ -225,13 +217,10 @@ class VoicePipelineTest { var ended = false var playerCompletion: () -> Unit = {} val pipeline = createPipeline( - player = object : StubVoiceSatellitePlayer() { - override val ttsPlayer: AudioPlayer - get() = object : StubAudioPlayer() { - override fun play(mediaUri: String, onCompletion: () -> Unit) { - playerCompletion = onCompletion - } - } + voiceOutput = object : StubVoiceOutput() { + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) { + playerCompletion = onCompletion + } }, ended = { ended = true } ) @@ -328,7 +317,7 @@ class VoicePipelineTest { fun should_play_error_sound_on_pipeline_error() = runTest { var errorPlayed = false val pipeline = createPipeline( - player = object : StubVoiceSatellitePlayer() { + voiceOutput = object : StubVoiceOutput() { override suspend fun playErrorSound(onCompletion: () -> Unit) { errorPlayed = true } diff --git a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt b/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt deleted file mode 100644 index 63adcfc..0000000 --- a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.example.ava - -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayerImpl -import com.example.ava.players.AudioPlayer -import com.example.ava.settings.SettingState -import com.example.ava.stubs.StubAudioPlayer -import com.example.ava.stubs.stubSettingState -import org.junit.Test - -class VoiceSatellitePlayerTest { - fun createPlayer( - ttsPlayer: AudioPlayer = StubAudioPlayer(), - mediaPlayer: AudioPlayer = StubAudioPlayer(), - enableWakeSound: SettingState = stubSettingState(true), - wakeSound: SettingState = stubSettingState(""), - timerFinishedSound: SettingState = stubSettingState(""), - repeatTimerFinishedSound: SettingState = stubSettingState(true), - enableErrorSound: SettingState = stubSettingState(false), - errorSound: SettingState = stubSettingState(""), - duckMultiplier: Float = 1f - ) = VoiceSatellitePlayerImpl( - ttsPlayer = ttsPlayer, - mediaPlayer = mediaPlayer, - enableWakeSound = enableWakeSound, - wakeSound = wakeSound, - timerFinishedSound = timerFinishedSound, - repeatTimerFinishedSound = repeatTimerFinishedSound, - enableErrorSound = enableErrorSound, - errorSound = errorSound, - duckMultiplier = duckMultiplier - ) - - @Test - fun should_set_volume_when_not_muted() { - val player = createPlayer() - val volume = 0.5f - - player.setVolume(volume) - - assert(player.ttsPlayer.volume == volume) - assert(player.mediaPlayer.volume == volume) - } - - @Test - fun should_not_set_volume_when_muted() { - val player = createPlayer() - val volume = 0.5f - - player.setMuted(true) - player.setVolume(volume) - - assert(player.ttsPlayer.volume == 0f) - assert(player.mediaPlayer.volume == 0f) - - player.setMuted(false) - - assert(player.ttsPlayer.volume == volume) - assert(player.mediaPlayer.volume == volume) - } - - @Test - fun should_set_muted() { - val player = createPlayer() - - player.setMuted(true) - - assert(player.ttsPlayer.volume == 0f) - assert(player.mediaPlayer.volume == 0f) - - player.setMuted(false) - - assert(player.ttsPlayer.volume == 1f) - assert(player.mediaPlayer.volume == 1f) - } - - @Test - fun should_duck_media_player() { - val duckMultiplier = 0.5f - val player = createPlayer(duckMultiplier = duckMultiplier) - - player.duck() - - assert(player.ttsPlayer.volume == player.volume.value) - assert(player.mediaPlayer.volume == player.volume.value * duckMultiplier) - - player.unDuck() - - assert(player.ttsPlayer.volume == player.volume.value) - assert(player.mediaPlayer.volume == player.volume.value) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/VoiceTimerTest.kt b/app/src/test/java/com/example/ava/VoiceTimerTest.kt index e2860d2..84a5593 100644 --- a/app/src/test/java/com/example/ava/VoiceTimerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceTimerTest.kt @@ -1,6 +1,6 @@ package com.example.ava -import com.example.ava.esphome.voicesatellite.VoiceTimer +import com.example.ava.esphome.voiceassistant.VoiceTimer import com.example.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.voiceAssistantTimerEventResponse import org.junit.Test diff --git a/app/src/test/java/com/example/ava/stubs/StubServer.kt b/app/src/test/java/com/example/ava/stubs/StubServer.kt deleted file mode 100644 index 8805e8f..0000000 --- a/app/src/test/java/com/example/ava/stubs/StubServer.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.ava.stubs - -import com.example.ava.server.Server -import com.google.protobuf.MessageLite -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.flowOf - -open class StubServer : Server { - override val isConnected = flowOf(true) - val receivedMessages = MutableSharedFlow() - override fun start(port: Int) = receivedMessages - override fun disconnectCurrentClient() {} - val sentMessages = mutableListOf() - override suspend fun sendMessage(message: MessageLite) { - sentMessages.add(message) - } - - override fun close() {} -} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/stubs/StubSetting.kt b/app/src/test/java/com/example/ava/stubs/StubSetting.kt index 73275d0..5a7261f 100644 --- a/app/src/test/java/com/example/ava/stubs/StubSetting.kt +++ b/app/src/test/java/com/example/ava/stubs/StubSetting.kt @@ -1,26 +1,9 @@ package com.example.ava.stubs import com.example.ava.settings.SettingState -import com.example.ava.settings.VoiceSatelliteSettings -import com.example.ava.settings.VoiceSatelliteSettingsStore import kotlinx.coroutines.flow.MutableStateFlow fun stubSettingState(value: T): SettingState { val state = MutableStateFlow(value) return SettingState(state) { state.value = it } -} - -// Simplified Settings Store: Use a secondary constructor or default property values -open class StubVoiceSatelliteSettingsStore( - val settings: VoiceSatelliteSettings = VoiceSatelliteSettings( - name = "Test", macAddress = "00:11:22:33:44:55", autoStart = false, serverPort = 16054 - ) -) : VoiceSatelliteSettingsStore { - override val name = stubSettingState(settings.name) - override val serverPort = stubSettingState(settings.serverPort) - override val autoStart = stubSettingState(settings.autoStart) - override suspend fun ensureMacAddressIsSet() {} - override fun getFlow() = MutableStateFlow(settings) - override suspend fun get() = settings - override suspend fun update(transform: suspend (VoiceSatelliteSettings) -> VoiceSatelliteSettings) {} } \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt new file mode 100644 index 0000000..f7a8827 --- /dev/null +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt @@ -0,0 +1,18 @@ +package com.example.ava.stubs + +import com.example.ava.esphome.voiceassistant.AudioResult +import com.example.ava.esphome.voiceassistant.VoiceInput +import com.example.ava.wakewords.models.WakeWordWithId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +open class StubVoiceInput : VoiceInput { + override suspend fun getAvailableWakeWords() = emptyList() + override suspend fun getAvailableStopWords() = emptyList() + override val activeWakeWords = stubSettingState(emptyList()) + override val activeStopWords = stubSettingState(emptyList()) + override val muted = stubSettingState(false) + override var isStreaming: Boolean = false + val audioResults = MutableSharedFlow() + override fun start(): Flow = audioResults +} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt new file mode 100644 index 0000000..b057528 --- /dev/null +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt @@ -0,0 +1,55 @@ +package com.example.ava.stubs + +import com.example.ava.esphome.voiceassistant.VoiceOutput +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +open class StubVoiceOutput( + val wakeSound: String = "", + val timerFinishedSound: String = "", + val errorSound: String = "", + val repeatTimerFinishedSound: Boolean = true +) : VoiceOutput { + protected val _volume = MutableStateFlow(1.0f) + override val volume: StateFlow = _volume + override suspend fun setVolume(value: Float) { + _volume.value = value + } + + protected val _muted = MutableStateFlow(false) + override val muted: StateFlow = _muted + override suspend fun setMuted(value: Boolean) { + _muted.value = value + } + + open fun play(mediaUris: Iterable, onCompletion: () -> Unit) = onCompletion() + + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) = + play(listOf(ttsUrl), onCompletion) + + override fun playAnnouncement( + preannounceUrl: String, + mediaUrl: String, + onCompletion: () -> Unit + ) = play(listOf(preannounceUrl, mediaUrl), onCompletion) + + override suspend fun playWakeSound(onCompletion: () -> Unit) = + play(listOf(wakeSound), onCompletion) + + override suspend fun playTimerFinishedSound( + onCompletion: (repeat: Boolean) -> Unit + ) = play(listOf(timerFinishedSound)) { + onCompletion(repeatTimerFinishedSound) + } + + override suspend fun playErrorSound(onCompletion: () -> Unit) = + play(listOf(errorSound), onCompletion) + + override fun duck() {} + + override fun unDuck() {} + + override fun stopTTS() {} + + override fun close() {} +} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceSatelliteAudioInput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceSatelliteAudioInput.kt deleted file mode 100644 index d465ba2..0000000 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceSatelliteAudioInput.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.ava.stubs - -import com.example.ava.esphome.voicesatellite.AudioResult -import com.example.ava.esphome.voicesatellite.VoiceSatelliteAudioInput -import com.example.ava.wakewords.models.WakeWordWithId -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -open class StubVoiceSatelliteAudioInput : VoiceSatelliteAudioInput { - override val availableWakeWords = emptyList() - override val availableStopWords = emptyList() - protected val _activeWakeWords = MutableStateFlow(emptyList()) - override val activeWakeWords: StateFlow> = _activeWakeWords - override fun setActiveWakeWords(value: List) { - _activeWakeWords.value = value - } - - protected val _activeStopWords = MutableStateFlow(emptyList()) - override val activeStopWords: StateFlow> = _activeStopWords - override fun setActiveStopWords(value: List) { - _activeStopWords.value = value - } - - protected val _muted = MutableStateFlow(false) - override val muted: StateFlow = _muted - override fun setMuted(value: Boolean) { - _muted.value = value - } - - override var isStreaming: Boolean = false - val audioResults = MutableSharedFlow() - override fun start(): Flow = audioResults -} \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt deleted file mode 100644 index 0e0a6db..0000000 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.example.ava.stubs - -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer -import com.example.ava.players.AudioPlayer -import com.example.ava.settings.SettingState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -open class StubVoiceSatellitePlayer( - override val ttsPlayer: AudioPlayer = StubAudioPlayer(), - override val mediaPlayer: AudioPlayer = StubAudioPlayer(), - override val enableWakeSound: SettingState = stubSettingState(true), - override val wakeSound: SettingState = stubSettingState(""), - override val timerFinishedSound: SettingState = stubSettingState(""), - 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 - override fun setVolume(value: Float) { - _volume.value = value - } - - protected val _muted = MutableStateFlow(false) - override val muted: StateFlow = _muted - override fun setMuted(value: Boolean) { - _muted.value = value - } - - override fun playAnnouncement( - preannounceUrl: String, - mediaUrl: String, - onCompletion: () -> Unit - ) { - onCompletion() - } - - override suspend fun playWakeSound(onCompletion: () -> Unit) { - ttsPlayer.play(wakeSound.get(), onCompletion) - } - - override suspend fun playTimerFinishedSound(onCompletion: () -> Unit) { - ttsPlayer.play(timerFinishedSound.get(), onCompletion) - } - - override suspend fun playErrorSound(onCompletion: () -> Unit) { - ttsPlayer.play(errorSound.get(), onCompletion) - } - - override fun duck() {} - - override fun unDuck() {} - - override fun close() {} -} \ No newline at end of file