From 6a79d6fbcc0062a77a47cb7163dcbd0ad32781f2 Mon Sep 17 00:00:00 2001 From: brownard Date: Sat, 21 Mar 2026 12:59:59 +0000 Subject: [PATCH 1/6] Begin refactoring voice satellite to make it a component of an ESPHome device to simplify dependencies/testing - Remove voice satellite dependency on server, entities and settings. These only need to be known by the overarching 'device' - Add an injectable device builder for building a voice satellite device from settings - Update tests --- app/build.gradle.kts | 1 + .../com/example/ava/esphome/EspHomeDevice.kt | 81 +++++-- .../esphome/voicesatellite/VoiceSatellite.kt | 96 +++----- .../com/example/ava/services/DeviceBuilder.kt | 138 ++++++++++++ .../ava/services/VoiceSatelliteService.kt | 97 ++------ .../java/com/example/ava/SatelliteTest.kt | 213 ++++++++++-------- .../java/com/example/ava/TaskerPluginsTest.kt | 32 +-- .../example/ava/VoiceSatelliteTimerTest.kt | 41 ++-- .../java/com/example/ava/stubs/StubServer.kt | 19 -- .../java/com/example/ava/stubs/StubSetting.kt | 17 -- 10 files changed, 385 insertions(+), 350 deletions(-) create mode 100644 app/src/main/java/com/example/ava/services/DeviceBuilder.kt delete mode 100644 app/src/test/java/com/example/ava/stubs/StubServer.kt 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/esphome/EspHomeDevice.kt b/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt index 773bc8b..1648df8 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.voicesatellite.VoiceSatellite 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: VoiceSatellite, 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/voicesatellite/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt index c77a253..af75137 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt @@ -3,37 +3,36 @@ package com.example.ava.esphome.voicesatellite 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.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 @@ -47,39 +46,17 @@ data class VoiceError(val message: String) : EspHomeState class VoiceSatellite( 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) } +) : 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,16 +70,17 @@ class VoiceSatellite( get() = _ringingTimer.value != null @RequiresPermission(Manifest.permission.RECORD_AUDIO) - override fun start() { - super.start() + fun start() { startAudioInput() // Wire up tasker actions WakeSatelliteRunner.register { scope.launch { wakeSatellite() } } StopRingingRunner.register { scope.launch { stopTimer() } } } + fun subscribe() = subscription.asSharedFlow() + @RequiresPermission(Manifest.permission.RECORD_AUDIO) - private fun startAudioInput() = server.isConnected + private fun startAudioInput() = isConnected .flatMapLatest { isConnected -> if (isConnected) audioInput.start() else emptyFlow() } @@ -111,25 +89,19 @@ class VoiceSatellite( } .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 { voiceAssistantWakeWord { @@ -165,8 +137,6 @@ class VoiceSatellite( pipeline?.handleEvent(message) ?: Timber.w("No pipeline to handle event: $message") is VoiceAssistantTimerEventResponse -> handleTimerMessage(message) - - else -> super.handleMessage(message) } } @@ -210,7 +180,7 @@ class VoiceSatellite( announcement = Announcement( scope = scope, player = player.ttsPlayer, - sendMessage = { sendMessage(it) }, + sendMessage = { subscription.emit(it) }, stateChanged = { _state.value = it }, ended = { onTtsFinished(it) } ).apply { @@ -276,7 +246,7 @@ class VoiceSatellite( private fun createPipeline() = VoicePipeline( scope = scope, player = player, - sendMessage = { sendMessage(it) }, + sendMessage = { subscription.emit(it) }, listeningChanged = { if (it) player.duck() audioInput.isStreaming = it @@ -330,7 +300,7 @@ class VoiceSatellite( } } - private suspend fun resetState() { + private suspend fun resetState(newState: EspHomeState = Connected) { pipeline?.stop() pipeline = null announcement?.stop() @@ -338,11 +308,11 @@ class VoiceSatellite( _ringingTimer.update { null } audioInput.isStreaming = false player.ttsPlayer.stop() - _state.value = Connected + _state.value = newState } override fun close() { - super.close() + scope.cancel() player.close() WakeSatelliteRunner.unregister() StopRingingRunner.unregister() 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..a29a65f --- /dev/null +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -0,0 +1,138 @@ +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.voicesatellite.VoiceSatellite +import com.example.ava.esphome.voicesatellite.VoiceSatelliteAudioInputImpl +import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayerImpl +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.esphomeproto.api.VoiceAssistantFeature +import com.example.esphomeproto.api.deviceInfoResponse +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +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 microphoneSettings = microphoneSettingsStore.get() + val playerSettings = playerSettingsStore.get() + val satelliteSettings = satelliteSettingsStore.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 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 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 = VoiceSatellite( + coroutineContext = coroutineContext, + audioInput = audioInput, + player = player + ), + entities = listOf( + MediaPlayerEntity( + key = 0, + name = "Media Player", + objectId = "media_player", + 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) } + ) + ) + } + + @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..cd1cd02 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -2,28 +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 @@ -38,7 +27,6 @@ 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 @@ -62,16 +50,19 @@ class VoiceSatelliteService() : LifecycleService() { @Inject lateinit var playerSettingsStore: PlayerSettingsStore + @Inject + 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() { @@ -122,7 +113,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() } @@ -137,17 +130,17 @@ class VoiceSatelliteService() : LifecycleService() { // Update settings when satellite changes, // dropping the initial value to avoid overwriting // settings with the initial/default values - satellite.audioInput.activeWakeWords.drop(1).onEach { + satellite.voiceAssistant.audioInput.activeWakeWords.drop(1).onEach { microphoneSettingsStore.wakeWord.set(it.firstOrNull().orEmpty()) microphoneSettingsStore.secondWakeWord.set(it.elementAtOrNull(1)) }, - satellite.audioInput.muted.drop(1).onEach { + satellite.voiceAssistant.audioInput.muted.drop(1).onEach { microphoneSettingsStore.muted.set(it) }, - satellite.player.volume.drop(1).onEach { + satellite.voiceAssistant.player.volume.drop(1).onEach { playerSettingsStore.volume.set(it) }, - satellite.player.muted.drop(1).onEach { + satellite.voiceAssistant.player.muted.drop(1).onEach { playerSettingsStore.muted.set(it) } ) @@ -161,56 +154,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 +169,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/test/java/com/example/ava/SatelliteTest.kt b/app/src/test/java/com/example/ava/SatelliteTest.kt index 2dda12c..60275b0 100644 --- a/app/src/test/java/com/example/ava/SatelliteTest.kt +++ b/app/src/test/java/com/example/ava/SatelliteTest.kt @@ -8,12 +8,9 @@ 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 @@ -21,6 +18,9 @@ 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 @@ -28,53 +28,53 @@ import org.junit.Test import kotlin.test.assertEquals class SatelliteTest { - fun TestScope.createSatellite( - server: Server = StubServer(), + suspend fun TestScope.createSatellite( audioInput: VoiceSatelliteAudioInput = StubVoiceSatelliteAudioInput(), player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() ) = VoiceSatellite( coroutineContext = this.coroutineContext, - "Test Satellite", - server = server, audioInput = audioInput, player = player, - settingsStore = StubVoiceSatelliteSettingsStore() ).apply { start() + onConnected() advanceUntilIdle() } @Test fun should_handle_wake_word_intercept_during_setup() = runTest { - val server = StubServer() val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(server = server, audioInput = audioInput) + val satellite = createSatellite(audioInput = audioInput) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) 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) + assertEquals(1, sentMessages.size) + assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END }) advanceUntilIdle() assertEquals(Connected, satellite.state.value) assertEquals(false, audioInput.isStreaming) - assertEquals(1, server.sentMessages.size) + assertEquals(1, sentMessages.size) + messageJob.cancel() satellite.close() } @Test fun should_ignore_duplicate_wake_detections() = runTest { - val server = StubServer() val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(server = server, audioInput = audioInput) + val satellite = createSatellite(audioInput = audioInput) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) @@ -83,15 +83,15 @@ class SatelliteTest { assertEquals(Listening, satellite.state.value) assertEquals(true, audioInput.isStreaming) - assertEquals(1, server.sentMessages.size) - assertEquals("wake word", (server.sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) + assertEquals(1, sentMessages.size) + assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) + messageJob.cancel() 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 @@ -100,18 +100,19 @@ class SatelliteTest { } } val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer) + audioInput = audioInput, + player = StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer) ) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END }) advanceUntilIdle() @@ -119,7 +120,7 @@ class SatelliteTest { assertEquals(Processing, satellite.state.value) assertEquals(false, audioInput.isStreaming) - server.sentMessages.clear() + sentMessages.clear() audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() @@ -127,17 +128,17 @@ class SatelliteTest { 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) + 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 - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END }) advanceUntilIdle() - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) advanceUntilIdle() @@ -145,12 +146,12 @@ class SatelliteTest { assertEquals(Listening, satellite.state.value) assertEquals(true, audioInput.isStreaming) + messageJob.cancel() 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() @@ -166,12 +167,16 @@ class SatelliteTest { } } val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) + audioInput = audioInput, + player = StubVoiceSatellitePlayer( + ttsPlayer = ttsPlayer, + wakeSound = stubSettingState("wake") + ) ) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { + satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) @@ -187,8 +192,8 @@ class SatelliteTest { // Should stop playback and send an announce finished response assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assert(server.sentMessages[0] is VoiceAssistantAnnounceFinished) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // Wake sound playback and completion assertEquals(listOf("wake"), ttsPlayer.mediaUrls) @@ -196,18 +201,18 @@ class SatelliteTest { advanceUntilIdle() // Should send pipeline start request - assertEquals(2, server.sentMessages.size) - assertEquals(true, (server.sentMessages[1] as VoiceAssistantRequest).start) + assertEquals(2, sentMessages.size) + assertEquals(true, (sentMessages[1] as VoiceAssistantRequest).start) assertEquals(Listening, satellite.state.value) assertEquals(true, audioInput.isStreaming) + messageJob.cancel() 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() @@ -223,12 +228,16 @@ class SatelliteTest { } } val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) + audioInput = audioInput, + player = StubVoiceSatellitePlayer( + ttsPlayer = ttsPlayer, + wakeSound = stubSettingState("wake") + ) ) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { + satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) @@ -239,7 +248,7 @@ class SatelliteTest { assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) ttsPlayer.mediaUrls.clear() - server.receivedMessages.emit(voiceAssistantAnnounceRequest { + satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce2" mediaId = "media2" }) @@ -247,8 +256,8 @@ class SatelliteTest { // Should stop playback and send an announce finished response assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assert(server.sentMessages[0] is VoiceAssistantAnnounceFinished) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // New announcement played assertEquals(listOf("preannounce2", "media2"), ttsPlayer.mediaUrls) @@ -259,19 +268,19 @@ class SatelliteTest { advanceUntilIdle() // Should send an announce finished response - assertEquals(2, server.sentMessages.size) - assert(server.sentMessages[1] is VoiceAssistantAnnounceFinished) + assertEquals(2, sentMessages.size) + assert(sentMessages[1] is VoiceAssistantAnnounceFinished) // And revert to idle assertEquals(Connected, satellite.state.value) assertEquals(false, audioInput.isStreaming) + messageJob.cancel() 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 @@ -280,18 +289,22 @@ class SatelliteTest { } } val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) + audioInput = audioInput, + player = StubVoiceSatellitePlayer( + ttsPlayer = ttsPlayer, + wakeSound = stubSettingState("wake") + ) ) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END }) advanceUntilIdle() @@ -299,25 +312,25 @@ class SatelliteTest { assertEquals(Processing, satellite.state.value) assertEquals(false, audioInput.isStreaming) - server.sentMessages.clear() + 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) + assertEquals(1, sentMessages.size) + assertEquals(false, (sentMessages[0] as VoiceAssistantRequest).start) // And revert to idle assertEquals(Connected, satellite.state.value) assertEquals(false, audioInput.isStreaming) + messageJob.cancel() 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() @@ -333,10 +346,14 @@ class SatelliteTest { } } val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) + audioInput = audioInput, + player = StubVoiceSatellitePlayer( + ttsPlayer = ttsPlayer, + wakeSound = stubSettingState("wake") + ) ) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() @@ -347,14 +364,14 @@ class SatelliteTest { advanceUntilIdle() ttsPlayer.mediaUrls.clear() - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) // Start TTS playback - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_START }) - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_END data += voiceAssistantEventData { name = "url"; value = "tts" } }) @@ -364,25 +381,25 @@ class SatelliteTest { assertEquals(Responding, satellite.state.value) assertEquals(false, audioInput.isStreaming) - server.sentMessages.clear() + 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) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // And revert to idle assertEquals(Connected, satellite.state.value) assertEquals(false, audioInput.isStreaming) + messageJob.cancel() satellite.close() } @Test fun should_stop_announcement_on_stop_word() = runTest { - val server = StubServer() val audioInput = StubVoiceSatelliteAudioInput() val ttsPlayer = object : StubAudioPlayer() { val mediaUrls = mutableListOf() @@ -398,12 +415,16 @@ class SatelliteTest { } } val satellite = createSatellite( - server, - audioInput, - StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake")) + audioInput = audioInput, + player = StubVoiceSatellitePlayer( + ttsPlayer = ttsPlayer, + wakeSound = stubSettingState("wake") + ) ) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { + satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) @@ -418,25 +439,24 @@ class SatelliteTest { // Should stop playback and send an announce finished response assertEquals(true, ttsPlayer.stopped) - assertEquals(1, server.sentMessages.size) - assert(server.sentMessages[0] is VoiceAssistantAnnounceFinished) + assertEquals(1, sentMessages.size) + assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // And revert to idle assertEquals(Connected, satellite.state.value) assertEquals(false, audioInput.isStreaming) + messageJob.cancel() 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( + audioInput = audioInput, + player = object : StubVoiceSatellitePlayer( enableWakeSound = stubSettingState(false) ) { override fun duck() { @@ -448,13 +468,14 @@ class SatelliteTest { } } ) + audioInput.audioResults.emit(AudioResult.WakeDetected("")) advanceUntilIdle() // Should duck immediately after the wake word assertEquals(true, isDucked) - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END }) advanceUntilIdle() @@ -468,13 +489,11 @@ class SatelliteTest { @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( + audioInput = audioInput, + player = object : StubVoiceSatellitePlayer( enableWakeSound = stubSettingState(false) ) { override fun duck() { @@ -495,7 +514,7 @@ class SatelliteTest { // Stop detections are ignored when the satellite is in // the listening state, so change state to processing - server.receivedMessages.emit(voiceAssistantEventResponse { + satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END }) @@ -515,7 +534,6 @@ class SatelliteTest { @Test fun should_duck_media_volume_during_announcement() = runTest { - val server = StubServer() val audioInput = StubVoiceSatelliteAudioInput() val ttsPlayer = object : StubAudioPlayer() { lateinit var onCompletion: () -> Unit @@ -525,9 +543,8 @@ class SatelliteTest { } var isDucked = false val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( + audioInput = audioInput, + player = object : StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) ) { @@ -540,7 +557,7 @@ class SatelliteTest { } } ) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { + satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) @@ -561,7 +578,6 @@ class SatelliteTest { @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 @@ -571,9 +587,8 @@ class SatelliteTest { } var isDucked = false val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( + audioInput = audioInput, + player = object : StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) ) { @@ -586,7 +601,7 @@ class SatelliteTest { } } ) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { + satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) @@ -608,7 +623,6 @@ class SatelliteTest { @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 @@ -618,9 +632,8 @@ class SatelliteTest { } var isDucked = false val satellite = createSatellite( - server, - audioInput, - object : StubVoiceSatellitePlayer( + audioInput = audioInput, + player = object : StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) ) { @@ -633,7 +646,7 @@ class SatelliteTest { } } ) - server.receivedMessages.emit(voiceAssistantAnnounceRequest { + satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" startConversation = true diff --git a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt index c3ab25b..a6c19bc 100644 --- a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt +++ b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt @@ -1,30 +1,24 @@ 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.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 @@ -35,16 +29,12 @@ class TaskerPluginsTest { private val dummyContext = ContextWrapper(null) private 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() + player = player ).apply { start() advanceUntilIdle() @@ -52,9 +42,10 @@ class TaskerPluginsTest { @Test fun should_handle_wake_satellite_action() = runTest { - val server = StubServer() val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(server = server, audioInput = audioInput) + val satellite = createSatellite(audioInput = audioInput) + val sentMessages = mutableListOf() + val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) advanceUntilIdle() val result = WakeSatelliteRunner().run(dummyContext, TaskerInput(Unit)) @@ -63,15 +54,15 @@ class TaskerPluginsTest { assertEquals(Listening, satellite.state.value) assertEquals(true, audioInput.isStreaming) - assertEquals(1, server.sentMessages.size) - assertEquals(true, (server.sentMessages[0] as VoiceAssistantRequest).start) + assertEquals(1, sentMessages.size) + assertEquals(true, (sentMessages[0] as VoiceAssistantRequest).start) + messageJob.cancel() satellite.close() } @Test fun should_handle_stop_ringing_action() = runTest { - val server = StubServer() val ttsPlayer = object : StubAudioPlayer() { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit @@ -86,7 +77,6 @@ class TaskerPluginsTest { } } val satellite = createSatellite( - server = server, player = StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, repeatTimerFinishedSound = stubSettingState(true), @@ -95,7 +85,7 @@ class TaskerPluginsTest { ) // Make it ring by sending a timer finished event - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + satellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "id" totalSeconds = 60 diff --git a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt index cd3f398..ccb08bc 100644 --- a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt @@ -4,12 +4,9 @@ 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.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.voiceAssistantTimerEventResponse @@ -25,25 +22,22 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds class VoiceSatelliteTimerTest { - private fun TestScope.start_satellite( - server: Server, + private suspend fun TestScope.start_satellite( 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() + onConnected() // Internally the voice satellite starts collecting server and // microphone messages in separate coroutines, wait for collection // to start to ensure all messages are collected. @@ -52,10 +46,9 @@ class VoiceSatelliteTimerTest { @Test fun should_store_and_sort_timers() = runTest { - val server = StubServer() - val voiceSatellite = start_satellite(server) + val voiceSatellite = start_satellite() - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "running1" totalSeconds = 61 @@ -63,14 +56,14 @@ class VoiceSatelliteTimerTest { isActive = true }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "paused1" totalSeconds = 62 secondsLeft = 10 isActive = false // Sorted last because paused }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "running2" totalSeconds = 63 @@ -90,11 +83,11 @@ class VoiceSatelliteTimerTest { assertTrue { remaining2Millis <= 50_000 } assertTrue { remaining2Millis > 49_900 } - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_CANCELLED timerId = "running1" }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_UPDATED timerId = "paused1" totalSeconds = 62 @@ -117,9 +110,8 @@ class VoiceSatelliteTimerTest { onCompletion() } } - val server = StubServer() - val voiceSatellite = start_satellite(server, audioPlayer, false) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + val voiceSatellite = start_satellite(audioPlayer, false) + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "timer2" totalSeconds = 61 @@ -127,7 +119,7 @@ class VoiceSatelliteTimerTest { isActive = true name = "Will ring" }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "timer1" totalSeconds = 60 @@ -138,7 +130,7 @@ class VoiceSatelliteTimerTest { assertEquals(listOf("timer2", "timer1"), timers.map { it.id }) assert(timers[0] is VoiceTimer.Running) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "timer2" totalSeconds = 61 @@ -165,10 +157,9 @@ class VoiceSatelliteTimerTest { @Test fun should_remove_repeating_timer_on_wake_word() = runTest { - val server = StubServer() - val voiceSatellite = start_satellite(server, repeatTimerFinishedSound = true) + val voiceSatellite = start_satellite(repeatTimerFinishedSound = true) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "ringing1" totalSeconds = 61 @@ -176,7 +167,7 @@ class VoiceSatelliteTimerTest { isActive = false name = "Will ring" }) - server.receivedMessages.emit(voiceAssistantTimerEventResponse { + voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "paused1" totalSeconds = 62 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 From 8d6cf54bc82bba779a15e7135a5c79decd7a3fbb Mon Sep 17 00:00:00 2001 From: brownard Date: Sat, 21 Mar 2026 19:25:02 +0000 Subject: [PATCH 2/6] Rename VoiceSatelliteAudioInput to VoiceInput --- ...ceSatelliteAudioInput.kt => VoiceInput.kt} | 6 +- .../esphome/voicesatellite/VoiceSatellite.kt | 20 +-- .../com/example/ava/services/DeviceBuilder.kt | 10 +- .../ava/services/VoiceSatelliteService.kt | 4 +- .../java/com/example/ava/SatelliteTest.kt | 124 +++++++++--------- .../java/com/example/ava/TaskerPluginsTest.kt | 14 +- .../example/ava/VoiceSatelliteTimerTest.kt | 6 +- ...telliteAudioInput.kt => StubVoiceInput.kt} | 4 +- 8 files changed, 94 insertions(+), 94 deletions(-) rename app/src/main/java/com/example/ava/esphome/voicesatellite/{VoiceSatelliteAudioInput.kt => VoiceInput.kt} (98%) rename app/src/test/java/com/example/ava/stubs/{StubVoiceSatelliteAudioInput.kt => StubVoiceInput.kt} (90%) diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatelliteAudioInput.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt similarity index 98% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatelliteAudioInput.kt rename to app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt index e9bf48b..112446b 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatelliteAudioInput.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt @@ -27,7 +27,7 @@ sealed class AudioResult { class StopDetected() : AudioResult() } -interface VoiceSatelliteAudioInput { +interface VoiceInput { /** * The list of wake words available for selection. */ @@ -82,14 +82,14 @@ interface VoiceSatelliteAudioInput { fun start(): Flow } -class VoiceSatelliteAudioInputImpl( +class VoiceInputImpl( activeWakeWords: List, activeStopWords: List, override val availableWakeWords: List, override val availableStopWords: List, muted: Boolean = false, private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) : VoiceSatelliteAudioInput { +) : VoiceInput { private val _availableWakeWords = availableWakeWords.associateBy { it.id } private val _availableStopWords = availableStopWords.associateBy { it.id } diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt index af75137..68e6f25 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt @@ -46,7 +46,7 @@ data class VoiceError(val message: String) : EspHomeState class VoiceSatellite( coroutineContext: CoroutineContext, - val audioInput: VoiceSatelliteAudioInput, + val voiceInput: VoiceInput, val player: VoiceSatellitePlayer, ) : AutoCloseable { private val scope = CoroutineScope( @@ -71,7 +71,7 @@ class VoiceSatellite( @RequiresPermission(Manifest.permission.RECORD_AUDIO) fun start() { - startAudioInput() + startVoiceInput() // Wire up tasker actions WakeSatelliteRunner.register { scope.launch { wakeSatellite() } } @@ -80,9 +80,9 @@ class VoiceSatellite( fun subscribe() = subscription.asSharedFlow() @RequiresPermission(Manifest.permission.RECORD_AUDIO) - private fun startAudioInput() = isConnected + private fun startVoiceInput() = isConnected .flatMapLatest { isConnected -> - if (isConnected) audioInput.start() else emptyFlow() + if (isConnected) voiceInput.start() else emptyFlow() } .onEach { handleAudioResult(audioResult = it) @@ -103,23 +103,23 @@ class VoiceSatellite( when (message) { is VoiceAssistantConfigurationRequest -> subscription.emit( voiceAssistantConfigurationResponse { - availableWakeWords += audioInput.availableWakeWords.map { + availableWakeWords += voiceInput.availableWakeWords.map { voiceAssistantWakeWord { id = it.id wakeWord = it.wakeWord.wake_word trainedLanguages += it.wakeWord.trained_languages.toList() } } - activeWakeWords += audioInput.activeWakeWords.value + activeWakeWords += voiceInput.activeWakeWords.value maxActiveWakeWords = 2 }) is VoiceAssistantSetConfiguration -> { val activeWakeWords = - message.activeWakeWordsList.filter { audioInput.availableWakeWords.any { wakeWord -> wakeWord.id == it } } + message.activeWakeWordsList.filter { voiceInput.availableWakeWords.any { wakeWord -> wakeWord.id == it } } Timber.d("Setting active wake words: $activeWakeWords") if (activeWakeWords.isNotEmpty()) { - audioInput.setActiveWakeWords(activeWakeWords) + voiceInput.setActiveWakeWords(activeWakeWords) } val ignoredWakeWords = message.activeWakeWordsList.filter { !activeWakeWords.contains(it) } @@ -249,7 +249,7 @@ class VoiceSatellite( sendMessage = { subscription.emit(it) }, listeningChanged = { if (it) player.duck() - audioInput.isStreaming = it + voiceInput.isStreaming = it }, stateChanged = { _state.value = it }, ended = { onTtsFinished(it) } @@ -306,7 +306,7 @@ class VoiceSatellite( announcement?.stop() announcement = null _ringingTimer.update { null } - audioInput.isStreaming = false + voiceInput.isStreaming = false player.ttsPlayer.stop() _state.value = newState } diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index a29a65f..c112b3a 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -14,8 +14,8 @@ 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.voicesatellite.VoiceInputImpl 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.players.AudioPlayer import com.example.ava.players.AudioPlayerImpl @@ -41,7 +41,7 @@ class DeviceBuilder @Inject constructor( val playerSettings = playerSettingsStore.get() val satelliteSettings = satelliteSettingsStore.get() - val audioInput = VoiceSatelliteAudioInputImpl( + val voiceInput = VoiceInputImpl( activeWakeWords = listOfNotNull( microphoneSettings.wakeWord, microphoneSettings.secondWakeWord @@ -90,7 +90,7 @@ class DeviceBuilder @Inject constructor( }, voiceAssistant = VoiceSatellite( coroutineContext = coroutineContext, - audioInput = audioInput, + voiceInput = voiceInput, player = player ), entities = listOf( @@ -104,8 +104,8 @@ class DeviceBuilder @Inject constructor( key = 1, name = "Mute Microphone", objectId = "mute_microphone", - getState = audioInput.muted - ) { audioInput.setMuted(it) }, + getState = voiceInput.muted + ) { voiceInput.setMuted(it) }, SwitchEntity( key = 2, name = "Enable Wake Sound", 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 cd1cd02..c1d4f0d 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -130,11 +130,11 @@ class VoiceSatelliteService() : LifecycleService() { // Update settings when satellite changes, // dropping the initial value to avoid overwriting // settings with the initial/default values - satellite.voiceAssistant.audioInput.activeWakeWords.drop(1).onEach { + satellite.voiceAssistant.voiceInput.activeWakeWords.drop(1).onEach { microphoneSettingsStore.wakeWord.set(it.firstOrNull().orEmpty()) microphoneSettingsStore.secondWakeWord.set(it.elementAtOrNull(1)) }, - satellite.voiceAssistant.audioInput.muted.drop(1).onEach { + satellite.voiceAssistant.voiceInput.muted.drop(1).onEach { microphoneSettingsStore.muted.set(it) }, satellite.voiceAssistant.player.volume.drop(1).onEach { diff --git a/app/src/test/java/com/example/ava/SatelliteTest.kt b/app/src/test/java/com/example/ava/SatelliteTest.kt index 60275b0..735f90e 100644 --- a/app/src/test/java/com/example/ava/SatelliteTest.kt +++ b/app/src/test/java/com/example/ava/SatelliteTest.kt @@ -5,11 +5,11 @@ 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.VoiceInput 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.stubs.StubAudioPlayer -import com.example.ava.stubs.StubVoiceSatelliteAudioInput +import com.example.ava.stubs.StubVoiceInput import com.example.ava.stubs.StubVoiceSatellitePlayer import com.example.ava.stubs.stubSettingState import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished @@ -29,11 +29,11 @@ import kotlin.test.assertEquals class SatelliteTest { suspend fun TestScope.createSatellite( - audioInput: VoiceSatelliteAudioInput = StubVoiceSatelliteAudioInput(), + voiceInput: VoiceInput = StubVoiceInput(), player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() ) = VoiceSatellite( coroutineContext = this.coroutineContext, - audioInput = audioInput, + voiceInput = voiceInput, player = player, ).apply { start() @@ -43,16 +43,16 @@ class SatelliteTest { @Test fun should_handle_wake_word_intercept_during_setup() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(audioInput = audioInput) + val voiceInput = StubVoiceInput() + val satellite = createSatellite(voiceInput = voiceInput) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) + assertEquals(true, voiceInput.isStreaming) assertEquals(1, sentMessages.size) assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) @@ -62,7 +62,7 @@ class SatelliteTest { advanceUntilIdle() assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) assertEquals(1, sentMessages.size) messageJob.cancel() @@ -71,18 +71,18 @@ class SatelliteTest { @Test fun should_ignore_duplicate_wake_detections() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(audioInput = audioInput) + val voiceInput = StubVoiceInput() + val satellite = createSatellite(voiceInput = voiceInput) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) + assertEquals(true, voiceInput.isStreaming) assertEquals(1, sentMessages.size) assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) @@ -92,7 +92,7 @@ class SatelliteTest { @Test fun should_stop_existing_pipeline_and_restart_on_wake_word() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { var stopped = false override fun stop() { @@ -100,13 +100,13 @@ class SatelliteTest { } } val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer) ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() satellite.handleMessage(voiceAssistantEventResponse { @@ -118,11 +118,11 @@ class SatelliteTest { advanceUntilIdle() assertEquals(Processing, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) sentMessages.clear() - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() assertEquals(true, ttsPlayer.stopped) @@ -144,7 +144,7 @@ class SatelliteTest { advanceUntilIdle() assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) + assertEquals(true, voiceInput.isStreaming) messageJob.cancel() satellite.close() @@ -152,7 +152,7 @@ class SatelliteTest { @Test fun should_stop_existing_announcement_and_start_pipeline_on_wake_word() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit @@ -167,7 +167,7 @@ class SatelliteTest { } } val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") @@ -183,11 +183,11 @@ class SatelliteTest { advanceUntilIdle() assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) ttsPlayer.mediaUrls.clear() - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() // Should stop playback and send an announce finished response @@ -205,7 +205,7 @@ class SatelliteTest { assertEquals(true, (sentMessages[1] as VoiceAssistantRequest).start) assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) + assertEquals(true, voiceInput.isStreaming) messageJob.cancel() satellite.close() @@ -213,7 +213,7 @@ class SatelliteTest { @Test fun should_stop_existing_announcement_and_restart_on_new_announcement() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit @@ -228,7 +228,7 @@ class SatelliteTest { } } val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") @@ -244,7 +244,7 @@ class SatelliteTest { advanceUntilIdle() assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) ttsPlayer.mediaUrls.clear() @@ -262,7 +262,7 @@ class SatelliteTest { // New announcement played assertEquals(listOf("preannounce2", "media2"), ttsPlayer.mediaUrls) assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) ttsPlayer.onCompletion() advanceUntilIdle() @@ -273,7 +273,7 @@ class SatelliteTest { // And revert to idle assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) messageJob.cancel() satellite.close() @@ -281,7 +281,7 @@ class SatelliteTest { @Test fun should_stop_processing_pipeline_on_stop_word() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { var stopped = false override fun stop() { @@ -289,7 +289,7 @@ class SatelliteTest { } } val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") @@ -298,7 +298,7 @@ class SatelliteTest { val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() satellite.handleMessage(voiceAssistantEventResponse { @@ -310,10 +310,10 @@ class SatelliteTest { advanceUntilIdle() assertEquals(Processing, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) sentMessages.clear() - audioInput.audioResults.emit(AudioResult.StopDetected()) + voiceInput.audioResults.emit(AudioResult.StopDetected()) advanceUntilIdle() // Should stop playback and send a pipeline stop request @@ -323,7 +323,7 @@ class SatelliteTest { // And revert to idle assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) messageJob.cancel() satellite.close() @@ -331,7 +331,7 @@ class SatelliteTest { @Test fun should_stop_tts_playback_on_stop_word() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit @@ -346,7 +346,7 @@ class SatelliteTest { } } val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") @@ -355,7 +355,7 @@ class SatelliteTest { val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - audioInput.audioResults.emit(AudioResult.WakeDetected("wake word")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() assertEquals("wake", ttsPlayer.mediaUrls[0]) @@ -379,10 +379,10 @@ class SatelliteTest { assertEquals("tts", ttsPlayer.mediaUrls[0]) assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) sentMessages.clear() - audioInput.audioResults.emit(AudioResult.StopDetected()) + voiceInput.audioResults.emit(AudioResult.StopDetected()) advanceUntilIdle() // Should stop playback and send an announce finished response @@ -392,7 +392,7 @@ class SatelliteTest { // And revert to idle assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) messageJob.cancel() satellite.close() @@ -400,7 +400,7 @@ class SatelliteTest { @Test fun should_stop_announcement_on_stop_word() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit @@ -415,7 +415,7 @@ class SatelliteTest { } } val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") @@ -431,10 +431,10 @@ class SatelliteTest { advanceUntilIdle() assertEquals(Responding, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) - audioInput.audioResults.emit(AudioResult.StopDetected()) + voiceInput.audioResults.emit(AudioResult.StopDetected()) advanceUntilIdle() // Should stop playback and send an announce finished response @@ -444,7 +444,7 @@ class SatelliteTest { // And revert to idle assertEquals(Connected, satellite.state.value) - assertEquals(false, audioInput.isStreaming) + assertEquals(false, voiceInput.isStreaming) messageJob.cancel() satellite.close() @@ -452,10 +452,10 @@ class SatelliteTest { @Test fun should_duck_media_volume_during_pipeline_run() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() var isDucked = false val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = object : StubVoiceSatellitePlayer( enableWakeSound = stubSettingState(false) ) { @@ -469,7 +469,7 @@ class SatelliteTest { } ) - audioInput.audioResults.emit(AudioResult.WakeDetected("")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("")) advanceUntilIdle() // Should duck immediately after the wake word @@ -489,10 +489,10 @@ class SatelliteTest { @Test fun should_un_duck_media_volume_when_pipeline_stopped() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() var isDucked = false val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = object : StubVoiceSatellitePlayer( enableWakeSound = stubSettingState(false) ) { @@ -506,7 +506,7 @@ class SatelliteTest { } ) - audioInput.audioResults.emit(AudioResult.WakeDetected("")) + voiceInput.audioResults.emit(AudioResult.WakeDetected("")) advanceUntilIdle() // Should duck immediately after the wake word @@ -522,7 +522,7 @@ class SatelliteTest { assertEquals(true, isDucked) // Stop the pipeline - audioInput.audioResults.emit(AudioResult.StopDetected()) + voiceInput.audioResults.emit(AudioResult.StopDetected()) advanceUntilIdle() // Should un-duck and revert to idle @@ -534,7 +534,7 @@ class SatelliteTest { @Test fun should_duck_media_volume_during_announcement() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -543,7 +543,7 @@ class SatelliteTest { } var isDucked = false val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = object : StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) @@ -578,7 +578,7 @@ class SatelliteTest { @Test fun should_un_duck_media_volume_when_announcement_stopped() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -587,7 +587,7 @@ class SatelliteTest { } var isDucked = false val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = object : StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) @@ -611,7 +611,7 @@ class SatelliteTest { assertEquals(true, isDucked) // Stop the announcement - audioInput.audioResults.emit(AudioResult.StopDetected()) + voiceInput.audioResults.emit(AudioResult.StopDetected()) advanceUntilIdle() // Should un-duck and revert to idle @@ -623,7 +623,7 @@ class SatelliteTest { @Test fun should_not_un_duck_media_volume_when_starting_conversation() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() + val voiceInput = StubVoiceInput() val ttsPlayer = object : StubAudioPlayer() { lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -632,7 +632,7 @@ class SatelliteTest { } var isDucked = false val satellite = createSatellite( - audioInput = audioInput, + voiceInput = voiceInput, player = object : StubVoiceSatellitePlayer( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) diff --git a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt index a6c19bc..b9b6e8a 100644 --- a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt +++ b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt @@ -2,11 +2,11 @@ package com.example.ava import android.content.ContextWrapper import com.example.ava.esphome.voicesatellite.Listening +import com.example.ava.esphome.voicesatellite.VoiceInput 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.stubs.StubAudioPlayer -import com.example.ava.stubs.StubVoiceSatelliteAudioInput +import com.example.ava.stubs.StubVoiceInput import com.example.ava.stubs.StubVoiceSatellitePlayer import com.example.ava.stubs.stubSettingState import com.example.ava.tasker.StopRingingRunner @@ -29,11 +29,11 @@ class TaskerPluginsTest { private val dummyContext = ContextWrapper(null) private fun TestScope.createSatellite( - audioInput: VoiceSatelliteAudioInput = StubVoiceSatelliteAudioInput(), + voiceInput: VoiceInput = StubVoiceInput(), player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() ) = VoiceSatellite( coroutineContext = this.coroutineContext, - audioInput = audioInput, + voiceInput = voiceInput, player = player ).apply { start() @@ -42,8 +42,8 @@ class TaskerPluginsTest { @Test fun should_handle_wake_satellite_action() = runTest { - val audioInput = StubVoiceSatelliteAudioInput() - val satellite = createSatellite(audioInput = audioInput) + val voiceInput = StubVoiceInput() + val satellite = createSatellite(voiceInput = voiceInput) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) advanceUntilIdle() @@ -53,7 +53,7 @@ class TaskerPluginsTest { advanceUntilIdle() assertEquals(Listening, satellite.state.value) - assertEquals(true, audioInput.isStreaming) + assertEquals(true, voiceInput.isStreaming) assertEquals(1, sentMessages.size) assertEquals(true, (sentMessages[0] as VoiceAssistantRequest).start) diff --git a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt index ccb08bc..37a9b16 100644 --- a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt @@ -5,7 +5,7 @@ import com.example.ava.esphome.voicesatellite.VoiceSatellite import com.example.ava.esphome.voicesatellite.VoiceTimer import com.example.ava.players.AudioPlayer import com.example.ava.stubs.StubAudioPlayer -import com.example.ava.stubs.StubVoiceSatelliteAudioInput +import com.example.ava.stubs.StubVoiceInput import com.example.ava.stubs.StubVoiceSatellitePlayer import com.example.ava.stubs.stubSettingState import com.example.esphomeproto.api.VoiceAssistantTimerEvent @@ -28,7 +28,7 @@ class VoiceSatelliteTimerTest { ) = VoiceSatellite( coroutineContext = coroutineContext, - audioInput = StubVoiceSatelliteAudioInput(), + voiceInput = StubVoiceInput(), player = StubVoiceSatellitePlayer( ttsPlayer = player, wakeSound = stubSettingState("wake.mp3"), @@ -179,7 +179,7 @@ class VoiceSatelliteTimerTest { assert(timers[0] is VoiceTimer.Ringing) assert(timers[1] is VoiceTimer.Paused) - (voiceSatellite.audioInput as StubVoiceSatelliteAudioInput).audioResults.emit( + (voiceSatellite.voiceInput as StubVoiceInput).audioResults.emit( AudioResult.WakeDetected("stop") ) diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceSatelliteAudioInput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt similarity index 90% rename from app/src/test/java/com/example/ava/stubs/StubVoiceSatelliteAudioInput.kt rename to app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt index d465ba2..d1e8990 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceSatelliteAudioInput.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt @@ -1,14 +1,14 @@ package com.example.ava.stubs import com.example.ava.esphome.voicesatellite.AudioResult -import com.example.ava.esphome.voicesatellite.VoiceSatelliteAudioInput +import com.example.ava.esphome.voicesatellite.VoiceInput 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 { +open class StubVoiceInput : VoiceInput { override val availableWakeWords = emptyList() override val availableStopWords = emptyList() protected val _activeWakeWords = MutableStateFlow(emptyList()) From 30a607c07e8f7bb35e20efbb50a9274396609793 Mon Sep 17 00:00:00 2001 From: brownard Date: Sat, 21 Mar 2026 22:05:23 +0000 Subject: [PATCH 3/6] Simplify VoiceInput interface and wake word change handling - Get/set wake words and muted directly from/to data store to ensure its always the source of truth - Rework voice input flow to recreate the detector when wake word settings change - Remove now unnecessary settings watcher from VoiceSatelliteService --- .../com/example/ava/audio/MicrophoneInput.kt | 1 + .../ava/esphome/voicesatellite/VoiceInput.kt | 144 +++++++----------- .../esphome/voicesatellite/VoiceSatellite.kt | 9 +- .../com/example/ava/services/DeviceBuilder.kt | 37 +++-- .../ava/services/VoiceSatelliteService.kt | 7 - .../ava/settings/MicrophoneSettings.kt | 23 +++ .../microwakeword/MicroWakeWordDetector.kt | 2 + .../com/example/ava/stubs/StubVoiceInput.kt | 27 +--- 8 files changed, 111 insertions(+), 139 deletions(-) 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/voicesatellite/VoiceInput.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt index 112446b..5ddb6b0 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt @@ -3,6 +3,7 @@ package com.example.ava.esphome.voicesatellite 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 @@ -10,10 +11,10 @@ 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.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 @@ -31,42 +32,27 @@ interface VoiceInput { /** * The list of wake words available for selection. */ - val availableWakeWords: List + suspend fun getAvailableWakeWords(): List /** * The list of stop words available for selection. */ - val availableStopWords: List + suspend fun getAvailableStopWords(): List /** * The list of currently active wake words. */ - val activeWakeWords: StateFlow> - - /** - * Sets the currently active wake words. - */ - fun setActiveWakeWords(value: List) + val activeWakeWords: SettingState> /** * The list of currently active stop words. */ - val activeStopWords: StateFlow> - - /** - * Sets the currently active stop words. - */ - fun setActiveStopWords(value: List) + val activeStopWords: SettingState> /** * Whether the microphone is muted. */ - val muted: StateFlow - - /** - * Sets whether the microphone is muted. - */ - fun setMuted(value: Boolean) + val muted: SettingState /** * Whether the microphone is currently streaming audio. @@ -83,33 +69,15 @@ interface VoiceInput { } class VoiceInputImpl( - activeWakeWords: List, - activeStopWords: List, - override val availableWakeWords: List, - override val availableStopWords: List, - muted: Boolean = false, + 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 { - 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 - } + override suspend fun getAvailableWakeWords() = availableWakeWords.first() + override suspend fun getAvailableStopWords() = availableStopWords.first() private val _isStreaming = AtomicBoolean(false) override var isStreaming: Boolean @@ -121,62 +89,66 @@ class VoiceInputImpl( // 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().use { microphoneInput -> microphoneInput.start() - while (true) { - if (wakeWords != activeWakeWords.value || stopWords != activeStopWords.value) { - wakeWords = activeWakeWords.value - stopWords = activeStopWords.value - detector.close() - detector = createDetector(wakeWords, stopWords) - } + emitAll( + combine(activeWakeWords, activeStopWords) { activeWakeWords, activeStopWords -> + readMicrophone(microphoneInput, activeWakeWords, activeStopWords) + }.flatMapLatest { it } + ) + } + } + }.flowOn(dispatcher) - val audio = microphoneInput.read() - if (isStreaming) { - emit(AudioResult.Audio(ByteString.copyFrom(audio))) - audio.rewind() - } + 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 wakeWords) { - emit(AudioResult.WakeDetected(detection.wakeWordPhrase)) - } else if (detection.wakeWordId in stopWords) { - emit(AudioResult.StopDetected()) - } + // 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()) } - - // yield to ensure upstream emissions and - // cancellation have a chance to occur - yield() } - } finally { - microphoneInput.close() - detector.close() + + // 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() } } - }.flowOn(dispatcher) + } private suspend fun createDetector( wakeWords: List, stopWords: List ) = MicroWakeWordDetector( - loadWakeWords(wakeWords, _availableWakeWords) + - loadWakeWords(stopWords, _availableStopWords) + loadWakeWords(wakeWords, availableWakeWords.first()) + + loadWakeWords(stopWords, availableStopWords.first()) ) private suspend fun loadWakeWords( ids: List, - wakeWords: Map + wakeWords: List ): List = buildList { for (id in ids) { - wakeWords[id]?.let { wakeWord -> + wakeWords.firstOrNull { it.id == id }?.let { wakeWord -> runCatching { add(MicroWakeWord.fromWakeWord(wakeWord)) }.onFailure { diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt index 68e6f25..bde19c7 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt @@ -103,23 +103,24 @@ class VoiceSatellite( when (message) { is VoiceAssistantConfigurationRequest -> subscription.emit( voiceAssistantConfigurationResponse { - availableWakeWords += voiceInput.availableWakeWords.map { + availableWakeWords += voiceInput.getAvailableWakeWords().map { voiceAssistantWakeWord { id = it.id wakeWord = it.wakeWord.wake_word trainedLanguages += it.wakeWord.trained_languages.toList() } } - activeWakeWords += voiceInput.activeWakeWords.value + activeWakeWords += voiceInput.activeWakeWords.get() maxActiveWakeWords = 2 }) is VoiceAssistantSetConfiguration -> { + val availableWakeWords = voiceInput.getAvailableWakeWords() val activeWakeWords = - message.activeWakeWordsList.filter { voiceInput.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()) { - voiceInput.setActiveWakeWords(activeWakeWords) + voiceInput.activeWakeWords.set(activeWakeWords) } val ignoredWakeWords = message.activeWakeWordsList.filter { !activeWakeWords.contains(it) } diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index c112b3a..aacf307 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -23,10 +23,11 @@ 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 kotlinx.coroutines.flow.first import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -37,21 +38,9 @@ class DeviceBuilder @Inject constructor( private val playerSettingsStore: PlayerSettingsStore ) { suspend fun buildVoiceSatellite(coroutineContext: CoroutineContext): EspHomeDevice { - val microphoneSettings = microphoneSettingsStore.get() val playerSettings = playerSettingsStore.get() val satelliteSettings = satelliteSettingsStore.get() - val voiceInput = VoiceInputImpl( - activeWakeWords = listOfNotNull( - microphoneSettings.wakeWord, - microphoneSettings.secondWakeWord - ), - activeStopWords = listOf(microphoneSettings.stopWord), - availableWakeWords = microphoneSettingsStore.availableWakeWords.first(), - availableStopWords = microphoneSettingsStore.availableStopWords.first(), - muted = microphoneSettings.muted - ) - val player = VoiceSatellitePlayerImpl( ttsPlayer = createAudioPlayer( USAGE_ASSISTANT, @@ -90,7 +79,7 @@ class DeviceBuilder @Inject constructor( }, voiceAssistant = VoiceSatellite( coroutineContext = coroutineContext, - voiceInput = voiceInput, + voiceInput = microphoneSettingsStore.toVoiceInput(), player = player ), entities = listOf( @@ -104,24 +93,32 @@ class DeviceBuilder @Inject constructor( key = 1, name = "Mute Microphone", objectId = "mute_microphone", - getState = voiceInput.muted - ) { voiceInput.setMuted(it) }, + getState = microphoneSettingsStore.muted + ) { microphoneSettingsStore.muted.set(it) }, SwitchEntity( key = 2, name = "Enable Wake Sound", objectId = "enable_wake_sound", - getState = player.enableWakeSound - ) { player.enableWakeSound.set(it) }, + getState = playerSettingsStore.enableWakeSound + ) { playerSettingsStore.enableWakeSound.set(it) }, SwitchEntity( key = 3, name = "Repeat Timer Sound", objectId = "repeat_timer_sound", - getState = player.repeatTimerFinishedSound - ) { player.repeatTimerFinishedSound.set(it) } + getState = playerSettingsStore.repeatTimerFinishedSound + ) { playerSettingsStore.repeatTimerFinishedSound.set(it) } ) ) } + private fun MicrophoneSettingsStore.toVoiceInput() = VoiceInputImpl( + availableWakeWords = availableWakeWords, + availableStopWords = availableStopWords, + activeWakeWords = activeWakeWords, + activeStopWords = activeStopWords, + muted = muted + ) + @OptIn(UnstableApi::class) fun createAudioPlayer(usage: Int, contentType: Int, focusGain: Int): AudioPlayer { val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager 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 c1d4f0d..6e76506 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -130,13 +130,6 @@ class VoiceSatelliteService() : LifecycleService() { // Update settings when satellite changes, // dropping the initial value to avoid overwriting // settings with the initial/default values - satellite.voiceAssistant.voiceInput.activeWakeWords.drop(1).onEach { - microphoneSettingsStore.wakeWord.set(it.firstOrNull().orEmpty()) - microphoneSettingsStore.secondWakeWord.set(it.elementAtOrNull(1)) - }, - satellite.voiceAssistant.voiceInput.muted.drop(1).onEach { - microphoneSettingsStore.muted.set(it) - }, satellite.voiceAssistant.player.volume.drop(1).onEach { playerSettingsStore.volume.set(it) }, 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/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/stubs/StubVoiceInput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt index d1e8990..c753fdd 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt @@ -5,30 +5,13 @@ import com.example.ava.esphome.voicesatellite.VoiceInput 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 StubVoiceInput : VoiceInput { - 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 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 From aee65e17b1b911bfc84b06b3779b4128533f5598 Mon Sep 17 00:00:00 2001 From: brownard Date: Sat, 21 Mar 2026 23:04:37 +0000 Subject: [PATCH 4/6] Rename VoiceSatellitePlayer to VoiceOutput --- .../ava/esphome/entities/MediaPlayerEntity.kt | 30 ++++++---- ...VoiceSatellitePlayer.kt => VoiceOutput.kt} | 6 +- .../esphome/voicesatellite/VoicePipeline.kt | 15 ++--- .../esphome/voicesatellite/VoiceSatellite.kt | 36 +++++------ .../com/example/ava/services/DeviceBuilder.kt | 8 +-- .../ava/services/VoiceSatelliteService.kt | 4 +- .../java/com/example/ava/SatelliteTest.kt | 30 +++++----- .../java/com/example/ava/TaskerPluginsTest.kt | 10 ++-- ...ellitePlayerTest.kt => VoiceOutputTest.kt} | 60 +++++++++---------- .../java/com/example/ava/VoicePipelineTest.kt | 14 ++--- .../example/ava/VoiceSatelliteTimerTest.kt | 4 +- ...eSatellitePlayer.kt => StubVoiceOutput.kt} | 6 +- 12 files changed, 115 insertions(+), 108 deletions(-) rename app/src/main/java/com/example/ava/esphome/voicesatellite/{VoiceSatellitePlayer.kt => VoiceOutput.kt} (97%) rename app/src/test/java/com/example/ava/{VoiceSatellitePlayerTest.kt => VoiceOutputTest.kt} (50%) rename app/src/test/java/com/example/ava/stubs/{StubVoiceSatellitePlayer.kt => StubVoiceOutput.kt} (93%) 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..87bec47 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,7 +2,7 @@ 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.esphome.voicesatellite.VoiceOutput import com.example.ava.players.AudioPlayerState import com.example.esphomeproto.api.ListEntitiesRequest import com.example.esphomeproto.api.MediaPlayerCommand @@ -19,7 +19,7 @@ class MediaPlayerEntity( val key: Int, val name: String, val objectId: String, - val player: VoiceSatellitePlayer + val voiceOutput: VoiceOutput ) : Entity { override fun handleMessage(message: MessageLite) = flow { @@ -34,18 +34,24 @@ class MediaPlayerEntity( is MediaPlayerCommandRequest -> { if (message.key == key) { if (message.hasMediaUrl) { - player.mediaPlayer.play(message.mediaUrl) + voiceOutput.mediaPlayer.play(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 -> voiceOutput.mediaPlayer.pause() + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PLAY -> voiceOutput.mediaPlayer.unpause() + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_STOP -> voiceOutput.mediaPlayer.stop() + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_MUTE -> voiceOutput.setMuted( + true + ) + + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_UNMUTE -> voiceOutput.setMuted( + false + ) + else -> {} } } else if (message.hasVolume) { - player.setVolume(message.volume) + voiceOutput.setVolume(message.volume) } } } @@ -53,9 +59,9 @@ class MediaPlayerEntity( } override fun subscribe() = combine( - player.mediaPlayer.state, - player.volume, - player.muted, + voiceOutput.mediaPlayer.state, + voiceOutput.volume, + voiceOutput.muted, ) { state, volume, muted -> mediaPlayerStateResponse { key = this@MediaPlayerEntity.key diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt similarity index 97% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt rename to app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt index 77e156f..eaf24bf 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellitePlayer.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -interface VoiceSatellitePlayer : AutoCloseable { +interface VoiceOutput : AutoCloseable { /** * The player to use for TTS playback, will also be used for wake and timer finished sounds. */ @@ -105,7 +105,7 @@ interface VoiceSatellitePlayer : AutoCloseable { } @OptIn(UnstableApi::class) -class VoiceSatellitePlayerImpl( +class VoiceOutputImpl( override val ttsPlayer: AudioPlayer, override val mediaPlayer: AudioPlayer, override val enableWakeSound: SettingState, @@ -115,7 +115,7 @@ class VoiceSatellitePlayerImpl( override val enableErrorSound: SettingState, override val errorSound: SettingState, private val duckMultiplier: Float = 0.5f -) : VoiceSatellitePlayer { +) : VoiceOutput { private var _isDucked = false private val _volume = MutableStateFlow(1.0f) private val _muted = MutableStateFlow(false) diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt index 0a30b3b..13dfdf9 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt @@ -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.ttsPlayer.stop() 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.ttsPlayer.init() 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.ttsPlayer.play(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.ttsPlayer.play(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/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt index bde19c7..3bfdc2c 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt @@ -47,7 +47,7 @@ data class VoiceError(val message: String) : EspHomeState class VoiceSatellite( coroutineContext: CoroutineContext, val voiceInput: VoiceInput, - val player: VoiceSatellitePlayer, + val voiceOutput: VoiceOutput, ) : AutoCloseable { private val scope = CoroutineScope( coroutineContext + Job(coroutineContext.job) + CoroutineName("${this.javaClass.simpleName} Scope") @@ -161,8 +161,8 @@ class VoiceSatellite( _ringingTimer.update { timer } if (wasNotRinging) { - player.duck() - player.playTimerFinishedSound { + voiceOutput.duck() + voiceOutput.playTimerFinishedSound { scope.launch { onTimerSoundFinished() } } } @@ -180,12 +180,12 @@ class VoiceSatellite( resetState() announcement = Announcement( scope = scope, - player = player.ttsPlayer, + player = voiceOutput.ttsPlayer, sendMessage = { subscription.emit(it) }, stateChanged = { _state.value = it }, ended = { onTtsFinished(it) } ).apply { - player.duck() + voiceOutput.duck() announce(mediaId, preannounceId, startConversation) } } @@ -234,9 +234,9 @@ class VoiceSatellite( 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 { @@ -246,10 +246,10 @@ class VoiceSatellite( private fun createPipeline() = VoicePipeline( scope = scope, - player = player, + voiceOutput = voiceOutput, sendMessage = { subscription.emit(it) }, listeningChanged = { - if (it) player.duck() + if (it) voiceOutput.duck() voiceInput.isStreaming = it }, stateChanged = { _state.value = it }, @@ -264,15 +264,15 @@ class VoiceSatellite( if (state is Connected || state is Listening) return Timber.d("Stop satellite") resetState() - player.unDuck() + voiceOutput.unDuck() } private fun stopTimer() { Timber.d("Stop timer") if (isRinging) { _ringingTimer.update { null } - player.ttsPlayer.stop() - player.unDuck() + voiceOutput.ttsPlayer.stop() + voiceOutput.unDuck() } } @@ -282,22 +282,22 @@ class VoiceSatellite( Timber.d("Continuing conversation") wakeSatellite(isContinueConversation = true) } else { - player.unDuck() + voiceOutput.unDuck() } } private suspend fun onTimerSoundFinished() { delay(1000) if (isRinging) { - if (player.repeatTimerFinishedSound.get()) { - player.playTimerFinishedSound { + if (voiceOutput.repeatTimerFinishedSound.get()) { + voiceOutput.playTimerFinishedSound { scope.launch { onTimerSoundFinished() } } } else { stopTimer() } } else { - player.unDuck() + voiceOutput.unDuck() } } @@ -308,13 +308,13 @@ class VoiceSatellite( announcement = null _ringingTimer.update { null } voiceInput.isStreaming = false - player.ttsPlayer.stop() + voiceOutput.ttsPlayer.stop() _state.value = newState } override fun close() { scope.cancel() - player.close() + voiceOutput.close() WakeSatelliteRunner.unregister() StopRingingRunner.unregister() } diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index aacf307..b4ecf41 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -15,8 +15,8 @@ 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.voicesatellite.VoiceInputImpl +import com.example.ava.esphome.voicesatellite.VoiceOutputImpl import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayerImpl import com.example.ava.players.AudioPlayer import com.example.ava.players.AudioPlayerImpl import com.example.ava.server.ServerImpl @@ -41,7 +41,7 @@ class DeviceBuilder @Inject constructor( val playerSettings = playerSettingsStore.get() val satelliteSettings = satelliteSettingsStore.get() - val player = VoiceSatellitePlayerImpl( + val voiceOutput = VoiceOutputImpl( ttsPlayer = createAudioPlayer( USAGE_ASSISTANT, AUDIO_CONTENT_TYPE_SPEECH, @@ -80,14 +80,14 @@ class DeviceBuilder @Inject constructor( voiceAssistant = VoiceSatellite( coroutineContext = coroutineContext, voiceInput = microphoneSettingsStore.toVoiceInput(), - player = player + voiceOutput = voiceOutput ), entities = listOf( MediaPlayerEntity( key = 0, name = "Media Player", objectId = "media_player", - player = player + voiceOutput = voiceOutput ), SwitchEntity( key = 1, 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 6e76506..854d668 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -130,10 +130,10 @@ class VoiceSatelliteService() : LifecycleService() { // Update settings when satellite changes, // dropping the initial value to avoid overwriting // settings with the initial/default values - satellite.voiceAssistant.player.volume.drop(1).onEach { + satellite.voiceAssistant.voiceOutput.volume.drop(1).onEach { playerSettingsStore.volume.set(it) }, - satellite.voiceAssistant.player.muted.drop(1).onEach { + satellite.voiceAssistant.voiceOutput.muted.drop(1).onEach { playerSettingsStore.muted.set(it) } ) diff --git a/app/src/test/java/com/example/ava/SatelliteTest.kt b/app/src/test/java/com/example/ava/SatelliteTest.kt index 735f90e..1691051 100644 --- a/app/src/test/java/com/example/ava/SatelliteTest.kt +++ b/app/src/test/java/com/example/ava/SatelliteTest.kt @@ -6,11 +6,11 @@ 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.VoiceInput +import com.example.ava.esphome.voicesatellite.VoiceOutput import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer import com.example.ava.stubs.StubAudioPlayer import com.example.ava.stubs.StubVoiceInput -import com.example.ava.stubs.StubVoiceSatellitePlayer +import com.example.ava.stubs.StubVoiceOutput import com.example.ava.stubs.stubSettingState import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished import com.example.esphomeproto.api.VoiceAssistantEvent @@ -30,11 +30,11 @@ import kotlin.test.assertEquals class SatelliteTest { suspend fun TestScope.createSatellite( voiceInput: VoiceInput = StubVoiceInput(), - player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() + voiceOutput: VoiceOutput = StubVoiceOutput() ) = VoiceSatellite( coroutineContext = this.coroutineContext, voiceInput = voiceInput, - player = player, + voiceOutput = voiceOutput, ).apply { start() onConnected() @@ -101,7 +101,7 @@ class SatelliteTest { } val satellite = createSatellite( voiceInput = voiceInput, - player = StubVoiceSatellitePlayer(ttsPlayer = ttsPlayer) + voiceOutput = StubVoiceOutput(ttsPlayer = ttsPlayer) ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) @@ -168,7 +168,7 @@ class SatelliteTest { } val satellite = createSatellite( voiceInput = voiceInput, - player = StubVoiceSatellitePlayer( + voiceOutput = StubVoiceOutput( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") ) @@ -229,7 +229,7 @@ class SatelliteTest { } val satellite = createSatellite( voiceInput = voiceInput, - player = StubVoiceSatellitePlayer( + voiceOutput = StubVoiceOutput( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") ) @@ -290,7 +290,7 @@ class SatelliteTest { } val satellite = createSatellite( voiceInput = voiceInput, - player = StubVoiceSatellitePlayer( + voiceOutput = StubVoiceOutput( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") ) @@ -347,7 +347,7 @@ class SatelliteTest { } val satellite = createSatellite( voiceInput = voiceInput, - player = StubVoiceSatellitePlayer( + voiceOutput = StubVoiceOutput( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") ) @@ -416,7 +416,7 @@ class SatelliteTest { } val satellite = createSatellite( voiceInput = voiceInput, - player = StubVoiceSatellitePlayer( + voiceOutput = StubVoiceOutput( ttsPlayer = ttsPlayer, wakeSound = stubSettingState("wake") ) @@ -456,7 +456,7 @@ class SatelliteTest { var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - player = object : StubVoiceSatellitePlayer( + voiceOutput = object : StubVoiceOutput( enableWakeSound = stubSettingState(false) ) { override fun duck() { @@ -493,7 +493,7 @@ class SatelliteTest { var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - player = object : StubVoiceSatellitePlayer( + voiceOutput = object : StubVoiceOutput( enableWakeSound = stubSettingState(false) ) { override fun duck() { @@ -544,7 +544,7 @@ class SatelliteTest { var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - player = object : StubVoiceSatellitePlayer( + voiceOutput = object : StubVoiceOutput( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) ) { @@ -588,7 +588,7 @@ class SatelliteTest { var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - player = object : StubVoiceSatellitePlayer( + voiceOutput = object : StubVoiceOutput( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) ) { @@ -633,7 +633,7 @@ class SatelliteTest { var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - player = object : StubVoiceSatellitePlayer( + voiceOutput = object : StubVoiceOutput( ttsPlayer = ttsPlayer, enableWakeSound = stubSettingState(false) ) { diff --git a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt index b9b6e8a..47ee4aa 100644 --- a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt +++ b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt @@ -3,11 +3,11 @@ package com.example.ava import android.content.ContextWrapper import com.example.ava.esphome.voicesatellite.Listening import com.example.ava.esphome.voicesatellite.VoiceInput +import com.example.ava.esphome.voicesatellite.VoiceOutput import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer import com.example.ava.stubs.StubAudioPlayer import com.example.ava.stubs.StubVoiceInput -import com.example.ava.stubs.StubVoiceSatellitePlayer +import com.example.ava.stubs.StubVoiceOutput import com.example.ava.stubs.stubSettingState import com.example.ava.tasker.StopRingingRunner import com.example.ava.tasker.WakeSatelliteRunner @@ -30,11 +30,11 @@ class TaskerPluginsTest { private fun TestScope.createSatellite( voiceInput: VoiceInput = StubVoiceInput(), - player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() + voiceOutput: VoiceOutput = StubVoiceOutput() ) = VoiceSatellite( coroutineContext = this.coroutineContext, voiceInput = voiceInput, - player = player + voiceOutput = voiceOutput ).apply { start() advanceUntilIdle() @@ -77,7 +77,7 @@ class TaskerPluginsTest { } } val satellite = createSatellite( - player = StubVoiceSatellitePlayer( + voiceOutput = StubVoiceOutput( ttsPlayer = ttsPlayer, repeatTimerFinishedSound = stubSettingState(true), timerFinishedSound = stubSettingState("ring") diff --git a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt b/app/src/test/java/com/example/ava/VoiceOutputTest.kt similarity index 50% rename from app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt rename to app/src/test/java/com/example/ava/VoiceOutputTest.kt index 63adcfc..7d71859 100644 --- a/app/src/test/java/com/example/ava/VoiceSatellitePlayerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceOutputTest.kt @@ -1,14 +1,14 @@ package com.example.ava -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayerImpl +import com.example.ava.esphome.voicesatellite.VoiceOutputImpl 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( +class VoiceOutputTest { + fun createVoiceOutput( ttsPlayer: AudioPlayer = StubAudioPlayer(), mediaPlayer: AudioPlayer = StubAudioPlayer(), enableWakeSound: SettingState = stubSettingState(true), @@ -18,7 +18,7 @@ class VoiceSatellitePlayerTest { enableErrorSound: SettingState = stubSettingState(false), errorSound: SettingState = stubSettingState(""), duckMultiplier: Float = 1f - ) = VoiceSatellitePlayerImpl( + ) = VoiceOutputImpl( ttsPlayer = ttsPlayer, mediaPlayer = mediaPlayer, enableWakeSound = enableWakeSound, @@ -32,60 +32,60 @@ class VoiceSatellitePlayerTest { @Test fun should_set_volume_when_not_muted() { - val player = createPlayer() + val voiceOutput = createVoiceOutput() val volume = 0.5f - player.setVolume(volume) + voiceOutput.setVolume(volume) - assert(player.ttsPlayer.volume == volume) - assert(player.mediaPlayer.volume == volume) + assert(voiceOutput.ttsPlayer.volume == volume) + assert(voiceOutput.mediaPlayer.volume == volume) } @Test fun should_not_set_volume_when_muted() { - val player = createPlayer() + val voiceOutput = createVoiceOutput() val volume = 0.5f - player.setMuted(true) - player.setVolume(volume) + voiceOutput.setMuted(true) + voiceOutput.setVolume(volume) - assert(player.ttsPlayer.volume == 0f) - assert(player.mediaPlayer.volume == 0f) + assert(voiceOutput.ttsPlayer.volume == 0f) + assert(voiceOutput.mediaPlayer.volume == 0f) - player.setMuted(false) + voiceOutput.setMuted(false) - assert(player.ttsPlayer.volume == volume) - assert(player.mediaPlayer.volume == volume) + assert(voiceOutput.ttsPlayer.volume == volume) + assert(voiceOutput.mediaPlayer.volume == volume) } @Test fun should_set_muted() { - val player = createPlayer() + val voiceOutput = createVoiceOutput() - player.setMuted(true) + voiceOutput.setMuted(true) - assert(player.ttsPlayer.volume == 0f) - assert(player.mediaPlayer.volume == 0f) + assert(voiceOutput.ttsPlayer.volume == 0f) + assert(voiceOutput.mediaPlayer.volume == 0f) - player.setMuted(false) + voiceOutput.setMuted(false) - assert(player.ttsPlayer.volume == 1f) - assert(player.mediaPlayer.volume == 1f) + assert(voiceOutput.ttsPlayer.volume == 1f) + assert(voiceOutput.mediaPlayer.volume == 1f) } @Test fun should_duck_media_player() { val duckMultiplier = 0.5f - val player = createPlayer(duckMultiplier = duckMultiplier) + val voiceOutput = createVoiceOutput(duckMultiplier = duckMultiplier) - player.duck() + voiceOutput.duck() - assert(player.ttsPlayer.volume == player.volume.value) - assert(player.mediaPlayer.volume == player.volume.value * duckMultiplier) + assert(voiceOutput.ttsPlayer.volume == voiceOutput.volume.value) + assert(voiceOutput.mediaPlayer.volume == voiceOutput.volume.value * duckMultiplier) - player.unDuck() + voiceOutput.unDuck() - assert(player.ttsPlayer.volume == player.volume.value) - assert(player.mediaPlayer.volume == player.volume.value) + assert(voiceOutput.ttsPlayer.volume == voiceOutput.volume.value) + assert(voiceOutput.mediaPlayer.volume == voiceOutput.volume.value) } } \ 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..cf345c7 100644 --- a/app/src/test/java/com/example/ava/VoicePipelineTest.kt +++ b/app/src/test/java/com/example/ava/VoicePipelineTest.kt @@ -7,7 +7,7 @@ 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.stubs.StubVoiceOutput import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished import com.example.esphomeproto.api.VoiceAssistantAudio import com.example.esphomeproto.api.VoiceAssistantEvent @@ -24,14 +24,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,7 +151,7 @@ class VoicePipelineTest { val notTtsStreamUrl = "not_tts_stream" var playbackUrl: String? = null val pipeline = createPipeline( - player = object : StubVoiceSatellitePlayer() { + voiceOutput = object : StubVoiceOutput() { override val ttsPlayer: AudioPlayer get() = object : StubAudioPlayer() { override fun play(mediaUri: String, onCompletion: () -> Unit) { @@ -183,7 +183,7 @@ class VoicePipelineTest { val notTtsStreamUrl = "not_tts_stream" var playbackUrl: String? = null val pipeline = createPipeline( - player = object : StubVoiceSatellitePlayer() { + voiceOutput = object : StubVoiceOutput() { override val ttsPlayer: AudioPlayer get() = object : StubAudioPlayer() { override fun play(mediaUri: String, onCompletion: () -> Unit) { @@ -225,7 +225,7 @@ class VoicePipelineTest { var ended = false var playerCompletion: () -> Unit = {} val pipeline = createPipeline( - player = object : StubVoiceSatellitePlayer() { + voiceOutput = object : StubVoiceOutput() { override val ttsPlayer: AudioPlayer get() = object : StubAudioPlayer() { override fun play(mediaUri: String, onCompletion: () -> Unit) { @@ -328,7 +328,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/VoiceSatelliteTimerTest.kt b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt index 37a9b16..f2b7ec0 100644 --- a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt @@ -6,7 +6,7 @@ import com.example.ava.esphome.voicesatellite.VoiceTimer import com.example.ava.players.AudioPlayer import com.example.ava.stubs.StubAudioPlayer import com.example.ava.stubs.StubVoiceInput -import com.example.ava.stubs.StubVoiceSatellitePlayer +import com.example.ava.stubs.StubVoiceOutput import com.example.ava.stubs.stubSettingState import com.example.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.voiceAssistantTimerEventResponse @@ -29,7 +29,7 @@ class VoiceSatelliteTimerTest { VoiceSatellite( coroutineContext = coroutineContext, voiceInput = StubVoiceInput(), - player = StubVoiceSatellitePlayer( + voiceOutput = StubVoiceOutput( ttsPlayer = player, wakeSound = stubSettingState("wake.mp3"), timerFinishedSound = stubSettingState("timer.mp3"), diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt similarity index 93% rename from app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt rename to app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt index 0e0a6db..aae31cc 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceSatellitePlayer.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt @@ -1,12 +1,12 @@ package com.example.ava.stubs -import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer +import com.example.ava.esphome.voicesatellite.VoiceOutput 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( +open class StubVoiceOutput( override val ttsPlayer: AudioPlayer = StubAudioPlayer(), override val mediaPlayer: AudioPlayer = StubAudioPlayer(), override val enableWakeSound: SettingState = stubSettingState(true), @@ -15,7 +15,7 @@ open class StubVoiceSatellitePlayer( override val repeatTimerFinishedSound: SettingState = stubSettingState(true), override val enableErrorSound: SettingState = stubSettingState(false), override val errorSound: SettingState = stubSettingState("") -) : VoiceSatellitePlayer { +) : VoiceOutput { protected val _volume = MutableStateFlow(1.0f) override val volume: StateFlow = _volume override fun setVolume(value: Float) { From 4faa8af945333c33556e921ad4c188b38048fc8d Mon Sep 17 00:00:00 2001 From: brownard Date: Sun, 22 Mar 2026 11:00:43 +0000 Subject: [PATCH 5/6] Simplify VoiceOutput interface and add a separate MediaPlayer interface for use with MediaPlayerEntity - Remove properties that are only used internally from the VoiceOutput interface - Add a separate MediaPlayer interface tor use with MediaPlayerEntity - Don't expose the tts/media players, all calls should go through VoiceOutput/MediaPlayer interface - Update StubVoiceOutput and tests --- .../ava/esphome/entities/MediaPlayerEntity.kt | 88 +++++--- .../esphome/voicesatellite/Announcement.kt | 12 +- .../ava/esphome/voicesatellite/VoiceOutput.kt | 158 ++++++++------ .../esphome/voicesatellite/VoicePipeline.kt | 8 +- .../esphome/voicesatellite/VoiceSatellite.kt | 16 +- .../com/example/ava/services/DeviceBuilder.kt | 57 ++--- .../ava/services/VoiceSatelliteService.kt | 28 --- .../java/com/example/ava/AnnouncementTest.kt | 25 +-- .../java/com/example/ava/SatelliteTest.kt | 194 ++++++++---------- .../java/com/example/ava/TaskerPluginsTest.kt | 22 +- .../java/com/example/ava/VoiceOutputTest.kt | 133 ++++++++---- .../java/com/example/ava/VoicePipelineTest.kt | 29 +-- .../example/ava/VoiceSatelliteTimerTest.kt | 53 ++--- .../com/example/ava/stubs/StubVoiceOutput.kt | 45 ++-- 14 files changed, 450 insertions(+), 418 deletions(-) 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 87bec47..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.VoiceOutput -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 voiceOutput: VoiceOutput + val mediaPlayer: MediaPlayer ) : Entity { override fun handleMessage(message: MessageLite) = flow { @@ -34,24 +76,28 @@ class MediaPlayerEntity( is MediaPlayerCommandRequest -> { if (message.key == key) { if (message.hasMediaUrl) { - voiceOutput.mediaPlayer.play(message.mediaUrl) + mediaPlayer.playMedia(message.mediaUrl) } else if (message.hasCommand) { when (message.command) { - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PAUSE -> voiceOutput.mediaPlayer.pause() - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PLAY -> voiceOutput.mediaPlayer.unpause() - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_STOP -> voiceOutput.mediaPlayer.stop() - MediaPlayerCommand.MEDIA_PLAYER_COMMAND_MUTE -> voiceOutput.setMuted( - true - ) + 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_UNMUTE -> voiceOutput.setMuted( - false - ) + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_MUTE -> + mediaPlayer.setMuted(true) + + MediaPlayerCommand.MEDIA_PLAYER_COMMAND_UNMUTE -> + mediaPlayer.setMuted(false) else -> {} } } else if (message.hasVolume) { - voiceOutput.setVolume(message.volume) + mediaPlayer.setVolume(message.volume) } } } @@ -59,21 +105,15 @@ class MediaPlayerEntity( } override fun subscribe() = combine( - voiceOutput.mediaPlayer.state, - voiceOutput.volume, - voiceOutput.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/voicesatellite/Announcement.kt index 9ffecbe..154d054 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/Announcement.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/Announcement.kt @@ -2,7 +2,6 @@ package com.example.ava.esphome.voicesatellite 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/VoiceOutput.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt index eaf24bf..2de4f5f 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt @@ -2,53 +2,16 @@ package com.example.ava.esphome.voicesatellite 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.settings.SettingState +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 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. */ @@ -57,7 +20,7 @@ interface VoiceOutput : AutoCloseable { /** * Sets the playback volume. */ - fun setVolume(value: Float) + suspend fun setVolume(value: Float) /** * Whether playback is muted. @@ -67,7 +30,12 @@ interface VoiceOutput : AutoCloseable { /** * Sets whether playback is muted. */ - fun setMuted(value: Boolean) + suspend fun setMuted(value: Boolean) + + /** + * Plays a TTS response. + */ + fun playTTS(ttsUrl: String, onCompletion: () -> Unit = {}) /** * Plays an announcement, optionally with a preannounce sound. @@ -79,17 +47,18 @@ interface VoiceOutput : AutoCloseable { ) /** - * Plays the wake sound if [enableWakeSound] is true. + * Plays the wake sound. */ suspend fun playWakeSound(onCompletion: () -> Unit = {}) /** - * Plays the timer finished sound. + * Plays the timer finished sound. The [onCompletion] callback indicates whether the + * the timer finished sound should be repeated. */ - suspend fun playTimerFinishedSound(onCompletion: () -> Unit = {}) + suspend fun playTimerFinishedSound(onCompletion: (repeat: Boolean) -> Unit = {}) /** - * Plays the error sound if [errorSound] is not null. + * Plays the error sound. */ suspend fun playErrorSound(onCompletion: () -> Unit = {}) @@ -102,35 +71,55 @@ interface VoiceOutput : AutoCloseable { * Un-ducks the media player volume. */ fun unDuck() + + /** + * Stops any currently playing response or sound. + */ + fun stopTTS() } @OptIn(UnstableApi::class) class VoiceOutputImpl( - 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 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 { +) : VoiceOutput, MediaPlayer { private var _isDucked = false - private val _volume = MutableStateFlow(1.0f) - private val _muted = MutableStateFlow(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 fun setVolume(value: Float) { + 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 fun setMuted(value: Boolean) { + override suspend fun setMuted(value: Boolean) { _muted.value = value if (value) { mediaPlayer.volume = 0.0f @@ -139,6 +128,11 @@ class VoiceOutputImpl( 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( @@ -155,18 +149,21 @@ class VoiceOutputImpl( } override suspend fun playWakeSound(onCompletion: () -> Unit) { - if (enableWakeSound.get()) { - ttsPlayer.play(wakeSound.get(), onCompletion) + if (enableWakeSound()) { + ttsPlayer.play(wakeSound(), onCompletion) } else onCompletion() } - override suspend fun playTimerFinishedSound(onCompletion: () -> Unit) { - ttsPlayer.play(timerFinishedSound.get(), 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.get()) { - ttsPlayer.play(errorSound.get(), onCompletion) + if (enableErrorSound()) { + ttsPlayer.play(errorSound(), onCompletion) } else onCompletion() } @@ -175,6 +172,9 @@ class VoiceOutputImpl( 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() { @@ -184,6 +184,32 @@ class VoiceOutputImpl( } } + 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/voicesatellite/VoicePipeline.kt index 13dfdf9..63110fb 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoicePipeline.kt @@ -52,7 +52,7 @@ class VoicePipeline( suspend fun stop() { val state = _state if (state is Responding) { - voiceOutput.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 - voiceOutput.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 - voiceOutput.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 - voiceOutput.ttsPlayer.play(it) { scope.launch { fireEnded() } } + voiceOutput.playTTS(it) { scope.launch { fireEnded() } } } } } diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt index 3bfdc2c..279cb8a 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt @@ -162,8 +162,8 @@ class VoiceSatellite( if (wasNotRinging) { voiceOutput.duck() - voiceOutput.playTimerFinishedSound { - scope.launch { onTimerSoundFinished() } + voiceOutput.playTimerFinishedSound { repeat -> + scope.launch { onTimerSoundFinished(repeat) } } } } @@ -180,7 +180,7 @@ class VoiceSatellite( resetState() announcement = Announcement( scope = scope, - player = voiceOutput.ttsPlayer, + voiceOutput = voiceOutput, sendMessage = { subscription.emit(it) }, stateChanged = { _state.value = it }, ended = { onTtsFinished(it) } @@ -271,7 +271,7 @@ class VoiceSatellite( Timber.d("Stop timer") if (isRinging) { _ringingTimer.update { null } - voiceOutput.ttsPlayer.stop() + voiceOutput.stopTTS() voiceOutput.unDuck() } } @@ -286,12 +286,12 @@ class VoiceSatellite( } } - private suspend fun onTimerSoundFinished() { + private suspend fun onTimerSoundFinished(repeat: Boolean) { delay(1000) if (isRinging) { - if (voiceOutput.repeatTimerFinishedSound.get()) { + if (repeat) { voiceOutput.playTimerFinishedSound { - scope.launch { onTimerSoundFinished() } + scope.launch { onTimerSoundFinished(it) } } } else { stopTimer() @@ -308,7 +308,7 @@ class VoiceSatellite( announcement = null _ringingTimer.update { null } voiceInput.isStreaming = false - voiceOutput.ttsPlayer.stop() + voiceOutput.stopTTS() _state.value = newState } diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index b4ecf41..7d6fb83 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -38,32 +38,10 @@ class DeviceBuilder @Inject constructor( private val playerSettingsStore: PlayerSettingsStore ) { suspend fun buildVoiceSatellite(coroutineContext: CoroutineContext): EspHomeDevice { - val playerSettings = playerSettingsStore.get() val satelliteSettings = satelliteSettingsStore.get() - - val voiceOutput = 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 = playerSettingsStore.enableWakeSound, - wakeSound = playerSettingsStore.wakeSound, - timerFinishedSound = playerSettingsStore.timerFinishedSound, - repeatTimerFinishedSound = playerSettingsStore.repeatTimerFinishedSound, - enableErrorSound = playerSettingsStore.enableErrorSound, - errorSound = playerSettingsStore.errorSound - ).apply { - setVolume(playerSettings.volume) - setMuted(playerSettings.muted) - } - + // 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, @@ -87,7 +65,7 @@ class DeviceBuilder @Inject constructor( key = 0, name = "Media Player", objectId = "media_player", - voiceOutput = voiceOutput + mediaPlayer = voiceOutput ), SwitchEntity( key = 1, @@ -119,6 +97,33 @@ class DeviceBuilder @Inject constructor( 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 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 854d668..9dc4de6 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -13,8 +13,6 @@ 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.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 @@ -25,13 +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.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 @@ -44,12 +40,6 @@ class VoiceSatelliteService() : LifecycleService() { @Inject lateinit var satelliteSettingsStore: VoiceSatelliteSettingsStore - @Inject - lateinit var microphoneSettingsStore: MicrophoneSettingsStore - - @Inject - lateinit var playerSettingsStore: PlayerSettingsStore - @Inject lateinit var deviceBuilder: DeviceBuilder @@ -87,7 +77,6 @@ class VoiceSatelliteService() : LifecycleService() { wifiWakeLock.create(applicationContext, TAG) createVoiceSatelliteServiceNotificationChannel(this) updateNotificationOnStateChanges() - startSettingsWatcher() startTaskerStateObserver() } @@ -123,23 +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.voiceAssistant.voiceOutput.volume.drop(1).onEach { - playerSettingsStore.volume.set(it) - }, - satellite.voiceAssistant.voiceOutput.muted.drop(1).onEach { - playerSettingsStore.muted.set(it) - } - ) - }.launchIn(lifecycleScope) - } - private fun startTaskerStateObserver() { combine(voiceSatelliteState, voiceTimers) { state, timers -> AvaActivityRunner.updateState(state, timers) diff --git a/app/src/test/java/com/example/ava/AnnouncementTest.kt b/app/src/test/java/com/example/ava/AnnouncementTest.kt index cbac828..101348a 100644 --- a/app/src/test/java/com/example/ava/AnnouncementTest.kt +++ b/app/src/test/java/com/example/ava/AnnouncementTest.kt @@ -4,7 +4,8 @@ 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.voicesatellite.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 index 1691051..ed1cbc5 100644 --- a/app/src/test/java/com/example/ava/SatelliteTest.kt +++ b/app/src/test/java/com/example/ava/SatelliteTest.kt @@ -8,10 +8,8 @@ import com.example.ava.esphome.voicesatellite.Responding import com.example.ava.esphome.voicesatellite.VoiceInput import com.example.ava.esphome.voicesatellite.VoiceOutput import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.stubs.StubAudioPlayer import com.example.ava.stubs.StubVoiceInput import com.example.ava.stubs.StubVoiceOutput -import com.example.ava.stubs.stubSettingState import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished import com.example.esphomeproto.api.VoiceAssistantEvent import com.example.esphomeproto.api.VoiceAssistantRequest @@ -93,15 +91,15 @@ class SatelliteTest { @Test fun should_stop_existing_pipeline_and_restart_on_wake_word() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput() { var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = StubVoiceOutput(ttsPlayer = ttsPlayer) + voiceOutput = voiceOutput ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) @@ -125,7 +123,7 @@ class SatelliteTest { voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - assertEquals(true, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) // Should send a pipeline stop request, followed by a start request assertEquals(2, sentMessages.size) @@ -153,7 +151,9 @@ class SatelliteTest { @Test fun should_stop_existing_announcement_and_start_pipeline_on_wake_word() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput( + wakeSound = "wake" + ) { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -162,16 +162,13 @@ class SatelliteTest { } var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = StubVoiceOutput( - ttsPlayer = ttsPlayer, - wakeSound = stubSettingState("wake") - ) + voiceOutput = voiceOutput ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) @@ -184,20 +181,20 @@ class SatelliteTest { assertEquals(Responding, satellite.state.value) assertEquals(false, voiceInput.isStreaming) - assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) - ttsPlayer.mediaUrls.clear() + 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, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) assertEquals(1, sentMessages.size) assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // Wake sound playback and completion - assertEquals(listOf("wake"), ttsPlayer.mediaUrls) - ttsPlayer.onCompletion() + assertEquals(listOf("wake"), voiceOutput.mediaUrls) + voiceOutput.onCompletion() advanceUntilIdle() // Should send pipeline start request @@ -214,7 +211,7 @@ class SatelliteTest { @Test fun should_stop_existing_announcement_and_restart_on_new_announcement() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput() { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -223,16 +220,13 @@ class SatelliteTest { } var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = StubVoiceOutput( - ttsPlayer = ttsPlayer, - wakeSound = stubSettingState("wake") - ) + voiceOutput = voiceOutput ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) @@ -245,8 +239,8 @@ class SatelliteTest { assertEquals(Responding, satellite.state.value) assertEquals(false, voiceInput.isStreaming) - assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) - ttsPlayer.mediaUrls.clear() + assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) + voiceOutput.mediaUrls.clear() satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce2" @@ -255,16 +249,16 @@ class SatelliteTest { advanceUntilIdle() // Should stop playback and send an announce finished response - assertEquals(true, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) assertEquals(1, sentMessages.size) assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // New announcement played - assertEquals(listOf("preannounce2", "media2"), ttsPlayer.mediaUrls) + assertEquals(listOf("preannounce2", "media2"), voiceOutput.mediaUrls) assertEquals(Responding, satellite.state.value) assertEquals(false, voiceInput.isStreaming) - ttsPlayer.onCompletion() + voiceOutput.onCompletion() advanceUntilIdle() // Should send an announce finished response @@ -282,18 +276,15 @@ class SatelliteTest { @Test fun should_stop_processing_pipeline_on_stop_word() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput() { var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = StubVoiceOutput( - ttsPlayer = ttsPlayer, - wakeSound = stubSettingState("wake") - ) + voiceOutput = voiceOutput ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) @@ -317,7 +308,7 @@ class SatelliteTest { advanceUntilIdle() // Should stop playback and send a pipeline stop request - assertEquals(true, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) assertEquals(1, sentMessages.size) assertEquals(false, (sentMessages[0] as VoiceAssistantRequest).start) @@ -332,7 +323,9 @@ class SatelliteTest { @Test fun should_stop_tts_playback_on_stop_word() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput( + wakeSound = "wake" + ) { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -341,16 +334,13 @@ class SatelliteTest { } var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = StubVoiceOutput( - ttsPlayer = ttsPlayer, - wakeSound = stubSettingState("wake") - ) + voiceOutput = voiceOutput ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) @@ -358,12 +348,12 @@ class SatelliteTest { voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - assertEquals("wake", ttsPlayer.mediaUrls[0]) + assertEquals("wake", voiceOutput.mediaUrls[0]) // Wake sound finished - ttsPlayer.onCompletion() + voiceOutput.onCompletion() advanceUntilIdle() - ttsPlayer.mediaUrls.clear() + voiceOutput.mediaUrls.clear() satellite.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) @@ -377,7 +367,7 @@ class SatelliteTest { }) advanceUntilIdle() - assertEquals("tts", ttsPlayer.mediaUrls[0]) + assertEquals("tts", voiceOutput.mediaUrls[0]) assertEquals(Responding, satellite.state.value) assertEquals(false, voiceInput.isStreaming) @@ -386,7 +376,7 @@ class SatelliteTest { advanceUntilIdle() // Should stop playback and send an announce finished response - assertEquals(true, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) assertEquals(1, sentMessages.size) assert(sentMessages[0] is VoiceAssistantAnnounceFinished) @@ -401,7 +391,7 @@ class SatelliteTest { @Test fun should_stop_announcement_on_stop_word() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput() { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -410,16 +400,13 @@ class SatelliteTest { } var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = StubVoiceOutput( - ttsPlayer = ttsPlayer, - wakeSound = stubSettingState("wake") - ) + voiceOutput = voiceOutput ) val sentMessages = mutableListOf() val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) @@ -432,13 +419,13 @@ class SatelliteTest { assertEquals(Responding, satellite.state.value) assertEquals(false, voiceInput.isStreaming) - assertEquals(listOf("preannounce", "media"), ttsPlayer.mediaUrls) + assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) voiceInput.audioResults.emit(AudioResult.StopDetected()) advanceUntilIdle() // Should stop playback and send an announce finished response - assertEquals(true, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) assertEquals(1, sentMessages.size) assert(sentMessages[0] is VoiceAssistantAnnounceFinished) @@ -456,9 +443,7 @@ class SatelliteTest { var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = object : StubVoiceOutput( - enableWakeSound = stubSettingState(false) - ) { + voiceOutput = object : StubVoiceOutput() { override fun duck() { isDucked = true } @@ -493,9 +478,7 @@ class SatelliteTest { var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = object : StubVoiceOutput( - enableWakeSound = stubSettingState(false) - ) { + voiceOutput = object : StubVoiceOutput() { override fun duck() { isDucked = true } @@ -535,27 +518,24 @@ class SatelliteTest { @Test fun should_duck_media_volume_during_announcement() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + 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 + } } - var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = object : StubVoiceOutput( - ttsPlayer = ttsPlayer, - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } + voiceOutput = voiceOutput ) satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" @@ -564,13 +544,13 @@ class SatelliteTest { advanceUntilIdle() // Should duck whilst the announcement is playing - assertEquals(true, isDucked) + assertEquals(true, voiceOutput.isDucked) - ttsPlayer.onCompletion() + voiceOutput.onCompletion() advanceUntilIdle() // Should un-duck and revert to idle when the announcement finishes - assertEquals(false, isDucked) + assertEquals(false, voiceOutput.isDucked) assertEquals(Connected, satellite.state.value) satellite.close() @@ -579,27 +559,24 @@ class SatelliteTest { @Test fun should_un_duck_media_volume_when_announcement_stopped() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + 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 + } } - var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = object : StubVoiceOutput( - ttsPlayer = ttsPlayer, - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } + voiceOutput = voiceOutput ) satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" @@ -608,14 +585,14 @@ class SatelliteTest { advanceUntilIdle() // Should duck whilst the announcement is playing - assertEquals(true, isDucked) + assertEquals(true, voiceOutput.isDucked) // Stop the announcement voiceInput.audioResults.emit(AudioResult.StopDetected()) advanceUntilIdle() // Should un-duck and revert to idle - assertEquals(false, isDucked) + assertEquals(false, voiceOutput.isDucked) assertEquals(Connected, satellite.state.value) satellite.close() @@ -624,27 +601,24 @@ class SatelliteTest { @Test fun should_not_un_duck_media_volume_when_starting_conversation() = runTest { val voiceInput = StubVoiceInput() - val ttsPlayer = object : StubAudioPlayer() { + 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 + } } - var isDucked = false val satellite = createSatellite( voiceInput = voiceInput, - voiceOutput = object : StubVoiceOutput( - ttsPlayer = ttsPlayer, - enableWakeSound = stubSettingState(false) - ) { - override fun duck() { - isDucked = true - } - - override fun unDuck() { - isDucked = false - } - } + voiceOutput = voiceOutput ) satellite.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" @@ -654,14 +628,14 @@ class SatelliteTest { advanceUntilIdle() // Should duck whilst the announcement is playing - assertEquals(true, isDucked) + assertEquals(true, voiceOutput.isDucked) // End the announcement and start conversation - ttsPlayer.onCompletion() + voiceOutput.onCompletion() advanceUntilIdle() // Should be ducked and in the listening state - assertEquals(true, isDucked) + assertEquals(true, voiceOutput.isDucked) assertEquals(Listening, satellite.state.value) satellite.close() } diff --git a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt index 47ee4aa..6e8a4e3 100644 --- a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt +++ b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt @@ -5,10 +5,8 @@ import com.example.ava.esphome.voicesatellite.Listening import com.example.ava.esphome.voicesatellite.VoiceInput import com.example.ava.esphome.voicesatellite.VoiceOutput import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.stubs.StubAudioPlayer import com.example.ava.stubs.StubVoiceInput import com.example.ava.stubs.StubVoiceOutput -import com.example.ava.stubs.stubSettingState import com.example.ava.tasker.StopRingingRunner import com.example.ava.tasker.WakeSatelliteRunner import com.example.esphomeproto.api.VoiceAssistantRequest @@ -63,7 +61,7 @@ class TaskerPluginsTest { @Test fun should_handle_stop_ringing_action() = runTest { - val ttsPlayer = object : StubAudioPlayer() { + val voiceOutput = object : StubVoiceOutput(timerFinishedSound = "ring") { val mediaUrls = mutableListOf() lateinit var onCompletion: () -> Unit override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { @@ -72,17 +70,11 @@ class TaskerPluginsTest { } var stopped = false - override fun stop() { + override fun stopTTS() { stopped = true } } - val satellite = createSatellite( - voiceOutput = StubVoiceOutput( - ttsPlayer = ttsPlayer, - repeatTimerFinishedSound = stubSettingState(true), - timerFinishedSound = stubSettingState("ring") - ) - ) + val satellite = createSatellite(voiceOutput = voiceOutput) // Make it ring by sending a timer finished event satellite.handleMessage(voiceAssistantTimerEventResponse { @@ -94,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)) @@ -104,7 +96,7 @@ class TaskerPluginsTest { advanceUntilIdle() // Should no longer be ringing - assertEquals(true, ttsPlayer.stopped) + assertEquals(true, voiceOutput.stopped) satellite.close() } diff --git a/app/src/test/java/com/example/ava/VoiceOutputTest.kt b/app/src/test/java/com/example/ava/VoiceOutputTest.kt index 7d71859..5d98214 100644 --- a/app/src/test/java/com/example/ava/VoiceOutputTest.kt +++ b/app/src/test/java/com/example/ava/VoiceOutputTest.kt @@ -2,90 +2,153 @@ package com.example.ava import com.example.ava.esphome.voicesatellite.VoiceOutputImpl 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 kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.test.assertEquals class VoiceOutputTest { fun createVoiceOutput( 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(""), + volume: Float = 1f, + muted: Boolean = false, duckMultiplier: Float = 1f ) = VoiceOutputImpl( ttsPlayer = ttsPlayer, mediaPlayer = mediaPlayer, - enableWakeSound = enableWakeSound, - wakeSound = wakeSound, - timerFinishedSound = timerFinishedSound, - repeatTimerFinishedSound = repeatTimerFinishedSound, - enableErrorSound = enableErrorSound, - errorSound = errorSound, + enableWakeSound = { false }, + wakeSound = { "" }, + timerFinishedSound = { "" }, + repeatTimerFinishedSound = { true }, + enableErrorSound = { true }, + errorSound = { "" }, + volume = volume, + muted = muted, duckMultiplier = duckMultiplier ) @Test - fun should_set_volume_when_not_muted() { - val voiceOutput = createVoiceOutput() + 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(voiceOutput.ttsPlayer.volume == volume) - assert(voiceOutput.mediaPlayer.volume == volume) + assert(ttsPlayer.volume == volume) + assert(mediaPlayer.volume == volume) } @Test - fun should_not_set_volume_when_muted() { - val voiceOutput = createVoiceOutput() + 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(voiceOutput.ttsPlayer.volume == 0f) - assert(voiceOutput.mediaPlayer.volume == 0f) + assert(ttsPlayer.volume == 0f) + assert(mediaPlayer.volume == 0f) voiceOutput.setMuted(false) - assert(voiceOutput.ttsPlayer.volume == volume) - assert(voiceOutput.mediaPlayer.volume == volume) + assert(ttsPlayer.volume == volume) + assert(mediaPlayer.volume == volume) } @Test - fun should_set_muted() { - val voiceOutput = createVoiceOutput() + fun should_set_muted() = runTest { + val ttsPlayer = StubAudioPlayer() + val mediaPlayer = StubAudioPlayer() + val voiceOutput = createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer + ) voiceOutput.setMuted(true) - assert(voiceOutput.ttsPlayer.volume == 0f) - assert(voiceOutput.mediaPlayer.volume == 0f) + assert(ttsPlayer.volume == 0f) + assert(mediaPlayer.volume == 0f) voiceOutput.setMuted(false) - assert(voiceOutput.ttsPlayer.volume == 1f) - assert(voiceOutput.mediaPlayer.volume == 1f) + 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(duckMultiplier = duckMultiplier) + val voiceOutput = createVoiceOutput( + ttsPlayer = ttsPlayer, + mediaPlayer = mediaPlayer, + duckMultiplier = duckMultiplier + ) voiceOutput.duck() - assert(voiceOutput.ttsPlayer.volume == voiceOutput.volume.value) - assert(voiceOutput.mediaPlayer.volume == voiceOutput.volume.value * duckMultiplier) + assert(ttsPlayer.volume == voiceOutput.volume.value) + assert(mediaPlayer.volume == voiceOutput.volume.value * duckMultiplier) voiceOutput.unDuck() - assert(voiceOutput.ttsPlayer.volume == voiceOutput.volume.value) - assert(voiceOutput.mediaPlayer.volume == voiceOutput.volume.value) + 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 cf345c7..4cdf4e6 100644 --- a/app/src/test/java/com/example/ava/VoicePipelineTest.kt +++ b/app/src/test/java/com/example/ava/VoicePipelineTest.kt @@ -5,8 +5,6 @@ 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.StubVoiceOutput import com.example.esphomeproto.api.VoiceAssistantAnnounceFinished import com.example.esphomeproto.api.VoiceAssistantAudio @@ -152,12 +150,9 @@ class VoicePipelineTest { var playbackUrl: String? = null val pipeline = createPipeline( voiceOutput = object : StubVoiceOutput() { - override val ttsPlayer: AudioPlayer - get() = object : StubAudioPlayer() { - override fun play(mediaUri: String, onCompletion: () -> Unit) { - playbackUrl = mediaUri - } - } + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) { + playbackUrl = ttsUrl + } } ) @@ -184,12 +179,9 @@ class VoicePipelineTest { var playbackUrl: String? = null val pipeline = createPipeline( voiceOutput = object : StubVoiceOutput() { - override val ttsPlayer: AudioPlayer - get() = object : StubAudioPlayer() { - override fun play(mediaUri: String, onCompletion: () -> Unit) { - playbackUrl = mediaUri - } - } + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) { + playbackUrl = ttsUrl + } } ) @@ -226,12 +218,9 @@ class VoicePipelineTest { var playerCompletion: () -> Unit = {} val pipeline = createPipeline( voiceOutput = object : StubVoiceOutput() { - override val ttsPlayer: AudioPlayer - get() = object : StubAudioPlayer() { - override fun play(mediaUri: String, onCompletion: () -> Unit) { - playerCompletion = onCompletion - } - } + override fun playTTS(ttsUrl: String, onCompletion: () -> Unit) { + playerCompletion = onCompletion + } }, ended = { ended = true } ) diff --git a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt index f2b7ec0..4b13243 100644 --- a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt @@ -1,13 +1,11 @@ package com.example.ava import com.example.ava.esphome.voicesatellite.AudioResult +import com.example.ava.esphome.voicesatellite.VoiceOutput import com.example.ava.esphome.voicesatellite.VoiceSatellite import com.example.ava.esphome.voicesatellite.VoiceTimer -import com.example.ava.players.AudioPlayer -import com.example.ava.stubs.StubAudioPlayer import com.example.ava.stubs.StubVoiceInput import com.example.ava.stubs.StubVoiceOutput -import com.example.ava.stubs.stubSettingState import com.example.esphomeproto.api.VoiceAssistantTimerEvent import com.example.esphomeproto.api.voiceAssistantTimerEventResponse import kotlinx.coroutines.flow.first @@ -23,26 +21,19 @@ import kotlin.time.Duration.Companion.seconds class VoiceSatelliteTimerTest { private suspend fun TestScope.start_satellite( - player: AudioPlayer = StubAudioPlayer(), - repeatTimerFinishedSound: Boolean = false - ) = - VoiceSatellite( - coroutineContext = coroutineContext, - voiceInput = StubVoiceInput(), - voiceOutput = StubVoiceOutput( - ttsPlayer = player, - wakeSound = stubSettingState("wake.mp3"), - timerFinishedSound = stubSettingState("timer.mp3"), - repeatTimerFinishedSound = stubSettingState(repeatTimerFinishedSound) - ) - ).apply { - start() - onConnected() - // 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() - } + voiceOutput: VoiceOutput = StubVoiceOutput() + ) = VoiceSatellite( + coroutineContext = coroutineContext, + voiceInput = StubVoiceInput(), + voiceOutput = voiceOutput + ).apply { + start() + onConnected() + // 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() + } @Test fun should_store_and_sort_timers() = runTest { @@ -103,14 +94,14 @@ class VoiceSatelliteTimerTest { @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 voiceSatellite = start_satellite(audioPlayer, false) + val voiceSatellite = start_satellite(voiceOutput) voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "timer2" @@ -150,14 +141,14 @@ class VoiceSatelliteTimerTest { timers = voiceSatellite.allTimers.first() assertEquals(listOf("timer1"), timers.map { it.id }) assert(timers[0] is VoiceTimer.Running) - assertEquals("timer.mp3", audioPlayed) + assert(audioPlayed) voiceSatellite.close() } @Test fun should_remove_repeating_timer_on_wake_word() = runTest { - val voiceSatellite = start_satellite(repeatTimerFinishedSound = true) + val voiceSatellite = start_satellite() voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt index aae31cc..ce69246 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt @@ -1,56 +1,55 @@ package com.example.ava.stubs import com.example.ava.esphome.voicesatellite.VoiceOutput -import com.example.ava.players.AudioPlayer -import com.example.ava.settings.SettingState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow open class StubVoiceOutput( - 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("") + 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 fun setVolume(value: Float) { + override suspend fun setVolume(value: Float) { _volume.value = value } protected val _muted = MutableStateFlow(false) override val muted: StateFlow = _muted - override fun setMuted(value: Boolean) { + 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 - ) { - onCompletion() - } + ) = play(listOf(preannounceUrl, mediaUrl), onCompletion) - override suspend fun playWakeSound(onCompletion: () -> Unit) { - ttsPlayer.play(wakeSound.get(), onCompletion) - } + override suspend fun playWakeSound(onCompletion: () -> Unit) = + play(listOf(wakeSound), onCompletion) - override suspend fun playTimerFinishedSound(onCompletion: () -> Unit) { - ttsPlayer.play(timerFinishedSound.get(), onCompletion) + override suspend fun playTimerFinishedSound( + onCompletion: (repeat: Boolean) -> Unit + ) = play(listOf(timerFinishedSound)) { + onCompletion(repeatTimerFinishedSound) } - override suspend fun playErrorSound(onCompletion: () -> Unit) { - ttsPlayer.play(errorSound.get(), onCompletion) - } + 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 From ff9b8f9e4d900cd3b7eabb952082b5b21e538b26 Mon Sep 17 00:00:00 2001 From: brownard Date: Mon, 23 Mar 2026 22:05:37 +0000 Subject: [PATCH 6/6] Rename VoiceSatellite to VoiceAssistant Reflects the naming in other ESPHome implementations and conceptually the voice assistant implementation is just one components of a larger 'voice satellite' device --- .../com/example/ava/esphome/EspHomeDevice.kt | 4 +- .../Announcement.kt | 2 +- .../VoiceAssistant.kt} | 31 ++-- .../VoiceInput.kt | 2 +- .../VoiceOutput.kt | 2 +- .../VoicePipeline.kt | 2 +- .../VoiceTimer.kt | 2 +- .../com/example/ava/services/DeviceBuilder.kt | 8 +- .../ava/tasker/AvaActivityCondition.kt | 8 +- .../ava/ui/services/components/VoiceTimers.kt | 2 +- .../ava/utils/EspHomeStateTranslations.kt | 6 +- .../java/com/example/ava/AnnouncementTest.kt | 6 +- .../java/com/example/ava/TaskerPluginsTest.kt | 26 +-- ...SatelliteTest.kt => VoiceAssistantTest.kt} | 170 +++++++++--------- ...imerTest.kt => VoiceAssistantTimerTest.kt} | 73 ++++---- .../java/com/example/ava/VoiceOutputTest.kt | 2 +- .../java/com/example/ava/VoicePipelineTest.kt | 6 +- .../java/com/example/ava/VoiceTimerTest.kt | 2 +- .../com/example/ava/stubs/StubVoiceInput.kt | 4 +- .../com/example/ava/stubs/StubVoiceOutput.kt | 2 +- 20 files changed, 180 insertions(+), 180 deletions(-) rename app/src/main/java/com/example/ava/esphome/{voicesatellite => voiceassistant}/Announcement.kt (96%) rename app/src/main/java/com/example/ava/esphome/{voicesatellite/VoiceSatellite.kt => voiceassistant/VoiceAssistant.kt} (93%) rename app/src/main/java/com/example/ava/esphome/{voicesatellite => voiceassistant}/VoiceInput.kt (99%) rename app/src/main/java/com/example/ava/esphome/{voicesatellite => voiceassistant}/VoiceOutput.kt (99%) rename app/src/main/java/com/example/ava/esphome/{voicesatellite => voiceassistant}/VoicePipeline.kt (99%) rename app/src/main/java/com/example/ava/esphome/{voicesatellite => voiceassistant}/VoiceTimer.kt (98%) rename app/src/test/java/com/example/ava/{SatelliteTest.kt => VoiceAssistantTest.kt} (78%) rename app/src/test/java/com/example/ava/{VoiceSatelliteTimerTest.kt => VoiceAssistantTimerTest.kt} (72%) 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 1648df8..533d7e1 100644 --- a/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt +++ b/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt @@ -3,7 +3,7 @@ package com.example.ava.esphome import android.Manifest import androidx.annotation.RequiresPermission import com.example.ava.esphome.entities.Entity -import com.example.ava.esphome.voicesatellite.VoiceSatellite +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 @@ -55,7 +55,7 @@ class EspHomeDevice( private val port: Int = DEFAULT_SERVER_PORT, private val server: Server = ServerImpl(), private val deviceInfo: DeviceInfoResponse, - val voiceAssistant: VoiceSatellite, + val voiceAssistant: VoiceAssistant, entities: Iterable = emptyList() ) : AutoCloseable { private val entities = entities.toList() 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 96% 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 154d054..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,4 +1,4 @@ -package com.example.ava.esphome.voicesatellite +package com.example.ava.esphome.voiceassistant import com.example.ava.esphome.Connected import com.example.ava.esphome.EspHomeState 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 93% 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 279cb8a..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,11 +1,11 @@ -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.Disconnected import com.example.ava.esphome.EspHomeState -import com.example.ava.esphome.voicesatellite.VoiceTimer.Companion.timerFromEvent +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.VoiceAssistantAnnounceRequest @@ -44,7 +44,7 @@ data object Processing : EspHomeState data class VoiceError(val message: String) : EspHomeState -class VoiceSatellite( +class VoiceAssistant( coroutineContext: CoroutineContext, val voiceInput: VoiceInput, val voiceOutput: VoiceOutput, @@ -74,9 +74,10 @@ class VoiceSatellite( 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) @@ -203,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) } } @@ -216,21 +217,21 @@ 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) { @@ -256,13 +257,13 @@ class VoiceSatellite( 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() voiceOutput.unDuck() } @@ -280,7 +281,7 @@ class VoiceSatellite( Timber.d("TTS finished") if (continueConversation) { Timber.d("Continuing conversation") - wakeSatellite(isContinueConversation = true) + wakeAssistant(isContinueConversation = true) } else { voiceOutput.unDuck() } diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceInput.kt similarity index 99% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt rename to app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceInput.kt index 5ddb6b0..105c185 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceInput.kt +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceInput.kt @@ -1,4 +1,4 @@ -package com.example.ava.esphome.voicesatellite +package com.example.ava.esphome.voiceassistant import android.Manifest import androidx.annotation.RequiresPermission diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceOutput.kt similarity index 99% rename from app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt rename to app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceOutput.kt index 2de4f5f..eeaab8e 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceOutput.kt +++ b/app/src/main/java/com/example/ava/esphome/voiceassistant/VoiceOutput.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 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 99% 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 63110fb..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 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/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index 7d6fb83..49328df 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -14,9 +14,9 @@ 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.voicesatellite.VoiceInputImpl -import com.example.ava.esphome.voicesatellite.VoiceOutputImpl -import com.example.ava.esphome.voicesatellite.VoiceSatellite +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 @@ -55,7 +55,7 @@ class DeviceBuilder @Inject constructor( VoiceAssistantFeature.ANNOUNCE.flag or VoiceAssistantFeature.START_CONVERSATION.flag }, - voiceAssistant = VoiceSatellite( + voiceAssistant = VoiceAssistant( coroutineContext = coroutineContext, voiceInput = microphoneSettingsStore.toVoiceInput(), voiceOutput = voiceOutput 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/test/java/com/example/ava/AnnouncementTest.kt b/app/src/test/java/com/example/ava/AnnouncementTest.kt index 101348a..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,9 @@ 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.esphome.voicesatellite.VoiceOutput +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 diff --git a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt index 6e8a4e3..cf69ba0 100644 --- a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt +++ b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt @@ -1,10 +1,10 @@ package com.example.ava import android.content.ContextWrapper -import com.example.ava.esphome.voicesatellite.Listening -import com.example.ava.esphome.voicesatellite.VoiceInput -import com.example.ava.esphome.voicesatellite.VoiceOutput -import com.example.ava.esphome.voicesatellite.VoiceSatellite +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 @@ -26,10 +26,10 @@ import kotlin.test.assertEquals class TaskerPluginsTest { private val dummyContext = ContextWrapper(null) - private fun TestScope.createSatellite( + private fun TestScope.createVoiceAssistant( voiceInput: VoiceInput = StubVoiceInput(), voiceOutput: VoiceOutput = StubVoiceOutput() - ) = VoiceSatellite( + ) = VoiceAssistant( coroutineContext = this.coroutineContext, voiceInput = voiceInput, voiceOutput = voiceOutput @@ -41,22 +41,22 @@ class TaskerPluginsTest { @Test fun should_handle_wake_satellite_action() = runTest { val voiceInput = StubVoiceInput() - val satellite = createSatellite(voiceInput = voiceInput) + val voiceAssistant = createVoiceAssistant(voiceInput = voiceInput) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + 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(Listening, voiceAssistant.state.value) assertEquals(true, voiceInput.isStreaming) assertEquals(1, sentMessages.size) assertEquals(true, (sentMessages[0] as VoiceAssistantRequest).start) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test @@ -74,10 +74,10 @@ class TaskerPluginsTest { stopped = true } } - val satellite = createSatellite(voiceOutput = voiceOutput) + val voiceAssistant = createVoiceAssistant(voiceOutput = voiceOutput) // Make it ring by sending a timer finished event - satellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "id" totalSeconds = 60 @@ -98,6 +98,6 @@ class TaskerPluginsTest { // Should no longer be ringing assertEquals(true, voiceOutput.stopped) - satellite.close() + voiceAssistant.close() } } \ No newline at end of file diff --git a/app/src/test/java/com/example/ava/SatelliteTest.kt b/app/src/test/java/com/example/ava/VoiceAssistantTest.kt similarity index 78% rename from app/src/test/java/com/example/ava/SatelliteTest.kt rename to app/src/test/java/com/example/ava/VoiceAssistantTest.kt index ed1cbc5..81142f3 100644 --- a/app/src/test/java/com/example/ava/SatelliteTest.kt +++ b/app/src/test/java/com/example/ava/VoiceAssistantTest.kt @@ -1,13 +1,13 @@ 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.VoiceInput -import com.example.ava.esphome.voicesatellite.VoiceOutput -import com.example.ava.esphome.voicesatellite.VoiceSatellite +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 @@ -25,11 +25,11 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals -class SatelliteTest { - suspend fun TestScope.createSatellite( +class VoiceAssistantTest { + suspend fun TestScope.createVoiceAssistant( voiceInput: VoiceInput = StubVoiceInput(), voiceOutput: VoiceOutput = StubVoiceOutput() - ) = VoiceSatellite( + ) = VoiceAssistant( coroutineContext = this.coroutineContext, voiceInput = voiceInput, voiceOutput = voiceOutput, @@ -42,50 +42,50 @@ class SatelliteTest { @Test fun should_handle_wake_word_intercept_during_setup() = runTest { val voiceInput = StubVoiceInput() - val satellite = createSatellite(voiceInput = voiceInput) + val voiceAssistant = createVoiceAssistant(voiceInput = voiceInput) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - assertEquals(Listening, satellite.state.value) + assertEquals(Listening, voiceAssistant.state.value) assertEquals(true, voiceInput.isStreaming) assertEquals(1, sentMessages.size) assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END }) advanceUntilIdle() - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) assertEquals(1, sentMessages.size) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test fun should_ignore_duplicate_wake_detections() = runTest { val voiceInput = StubVoiceInput() - val satellite = createSatellite(voiceInput = voiceInput) + val voiceAssistant = createVoiceAssistant(voiceInput = voiceInput) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + 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, satellite.state.value) + assertEquals(Listening, voiceAssistant.state.value) assertEquals(true, voiceInput.isStreaming) assertEquals(1, sentMessages.size) assertEquals("wake word", (sentMessages[0] as VoiceAssistantRequest).wakeWordPhrase) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test @@ -97,25 +97,25 @@ class SatelliteTest { stopped = true } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END }) advanceUntilIdle() - assertEquals(Processing, satellite.state.value) + assertEquals(Processing, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) sentMessages.clear() @@ -132,20 +132,20 @@ class SatelliteTest { // Should correctly handle receiving confirmation of the // previous pipeline stop before the new pipeline is started - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_END }) advanceUntilIdle() - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) advanceUntilIdle() - assertEquals(Listening, satellite.state.value) + assertEquals(Listening, voiceAssistant.state.value) assertEquals(true, voiceInput.isStreaming) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test @@ -166,20 +166,20 @@ class SatelliteTest { stopped = true } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - satellite.handleMessage(voiceAssistantAnnounceRequest { + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) advanceUntilIdle() - assertEquals(Responding, satellite.state.value) + assertEquals(Responding, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) voiceOutput.mediaUrls.clear() @@ -201,11 +201,11 @@ class SatelliteTest { assertEquals(2, sentMessages.size) assertEquals(true, (sentMessages[1] as VoiceAssistantRequest).start) - assertEquals(Listening, satellite.state.value) + assertEquals(Listening, voiceAssistant.state.value) assertEquals(true, voiceInput.isStreaming) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test @@ -224,25 +224,25 @@ class SatelliteTest { stopped = true } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - satellite.handleMessage(voiceAssistantAnnounceRequest { + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) advanceUntilIdle() - assertEquals(Responding, satellite.state.value) + assertEquals(Responding, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) voiceOutput.mediaUrls.clear() - satellite.handleMessage(voiceAssistantAnnounceRequest { + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce2" mediaId = "media2" }) @@ -255,7 +255,7 @@ class SatelliteTest { // New announcement played assertEquals(listOf("preannounce2", "media2"), voiceOutput.mediaUrls) - assertEquals(Responding, satellite.state.value) + assertEquals(Responding, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) voiceOutput.onCompletion() @@ -266,11 +266,11 @@ class SatelliteTest { assert(sentMessages[1] is VoiceAssistantAnnounceFinished) // And revert to idle - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test @@ -282,25 +282,25 @@ class SatelliteTest { stopped = true } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END }) advanceUntilIdle() - assertEquals(Processing, satellite.state.value) + assertEquals(Processing, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) sentMessages.clear() @@ -313,11 +313,11 @@ class SatelliteTest { assertEquals(false, (sentMessages[0] as VoiceAssistantRequest).start) // And revert to idle - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test @@ -338,12 +338,12 @@ class SatelliteTest { stopped = true } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) voiceInput.audioResults.emit(AudioResult.WakeDetected("wake word")) advanceUntilIdle() @@ -354,21 +354,21 @@ class SatelliteTest { advanceUntilIdle() voiceOutput.mediaUrls.clear() - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_RUN_START }) // Start TTS playback - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_START }) - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_TTS_END data += voiceAssistantEventData { name = "url"; value = "tts" } }) advanceUntilIdle() assertEquals("tts", voiceOutput.mediaUrls[0]) - assertEquals(Responding, satellite.state.value) + assertEquals(Responding, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) sentMessages.clear() @@ -381,11 +381,11 @@ class SatelliteTest { assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // And revert to idle - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test @@ -404,20 +404,20 @@ class SatelliteTest { stopped = true } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) val sentMessages = mutableListOf() - val messageJob = satellite.subscribe().onEach { sentMessages.add(it) }.launchIn(this) + val messageJob = voiceAssistant.subscribe().onEach { sentMessages.add(it) }.launchIn(this) - satellite.handleMessage(voiceAssistantAnnounceRequest { + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) advanceUntilIdle() - assertEquals(Responding, satellite.state.value) + assertEquals(Responding, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) assertEquals(listOf("preannounce", "media"), voiceOutput.mediaUrls) @@ -430,18 +430,18 @@ class SatelliteTest { assert(sentMessages[0] is VoiceAssistantAnnounceFinished) // And revert to idle - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) assertEquals(false, voiceInput.isStreaming) messageJob.cancel() - satellite.close() + voiceAssistant.close() } @Test fun should_duck_media_volume_during_pipeline_run() = runTest { val voiceInput = StubVoiceInput() var isDucked = false - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = object : StubVoiceOutput() { override fun duck() { @@ -460,23 +460,23 @@ class SatelliteTest { // Should duck immediately after the wake word assertEquals(true, isDucked) - satellite.handleMessage(voiceAssistantEventResponse { + 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, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) - satellite.close() + voiceAssistant.close() } @Test fun should_un_duck_media_volume_when_pipeline_stopped() = runTest { val voiceInput = StubVoiceInput() var isDucked = false - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = object : StubVoiceOutput() { override fun duck() { @@ -497,11 +497,11 @@ class SatelliteTest { // Stop detections are ignored when the satellite is in // the listening state, so change state to processing - satellite.handleMessage(voiceAssistantEventResponse { + voiceAssistant.handleMessage(voiceAssistantEventResponse { eventType = VoiceAssistantEvent.VOICE_ASSISTANT_STT_END }) - assertEquals(Processing, satellite.state.value) + assertEquals(Processing, voiceAssistant.state.value) assertEquals(true, isDucked) // Stop the pipeline @@ -510,9 +510,9 @@ class SatelliteTest { // Should un-duck and revert to idle assertEquals(false, isDucked) - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) - satellite.close() + voiceAssistant.close() } @Test @@ -533,11 +533,11 @@ class SatelliteTest { isDucked = false } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) - satellite.handleMessage(voiceAssistantAnnounceRequest { + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) @@ -551,9 +551,9 @@ class SatelliteTest { // Should un-duck and revert to idle when the announcement finishes assertEquals(false, voiceOutput.isDucked) - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) - satellite.close() + voiceAssistant.close() } @Test @@ -574,11 +574,11 @@ class SatelliteTest { isDucked = false } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) - satellite.handleMessage(voiceAssistantAnnounceRequest { + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" }) @@ -593,9 +593,9 @@ class SatelliteTest { // Should un-duck and revert to idle assertEquals(false, voiceOutput.isDucked) - assertEquals(Connected, satellite.state.value) + assertEquals(Connected, voiceAssistant.state.value) - satellite.close() + voiceAssistant.close() } @Test @@ -616,11 +616,11 @@ class SatelliteTest { isDucked = false } } - val satellite = createSatellite( + val voiceAssistant = createVoiceAssistant( voiceInput = voiceInput, voiceOutput = voiceOutput ) - satellite.handleMessage(voiceAssistantAnnounceRequest { + voiceAssistant.handleMessage(voiceAssistantAnnounceRequest { preannounceMediaId = "preannounce" mediaId = "media" startConversation = true @@ -636,7 +636,7 @@ class SatelliteTest { // Should be ducked and in the listening state assertEquals(true, voiceOutput.isDucked) - assertEquals(Listening, satellite.state.value) - satellite.close() + 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 72% rename from app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt rename to app/src/test/java/com/example/ava/VoiceAssistantTimerTest.kt index 4b13243..a97b512 100644 --- a/app/src/test/java/com/example/ava/VoiceSatelliteTimerTest.kt +++ b/app/src/test/java/com/example/ava/VoiceAssistantTimerTest.kt @@ -1,9 +1,10 @@ package com.example.ava -import com.example.ava.esphome.voicesatellite.AudioResult -import com.example.ava.esphome.voicesatellite.VoiceOutput -import com.example.ava.esphome.voicesatellite.VoiceSatellite -import com.example.ava.esphome.voicesatellite.VoiceTimer +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 @@ -19,27 +20,25 @@ import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -class VoiceSatelliteTimerTest { - private suspend fun TestScope.start_satellite( +class VoiceAssistantTimerTest { + private suspend fun TestScope.createVoiceAssistant( + voiceInput: VoiceInput = StubVoiceInput(), voiceOutput: VoiceOutput = StubVoiceOutput() - ) = VoiceSatellite( - coroutineContext = coroutineContext, - voiceInput = StubVoiceInput(), - voiceOutput = voiceOutput + ) = VoiceAssistant( + coroutineContext = this.coroutineContext, + voiceInput = voiceInput, + voiceOutput = voiceOutput, ).apply { start() onConnected() - // 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() } @Test fun should_store_and_sort_timers() = runTest { - val voiceSatellite = start_satellite() + val voiceAssistant = createVoiceAssistant() - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "running1" totalSeconds = 61 @@ -47,14 +46,14 @@ class VoiceSatelliteTimerTest { isActive = true }) - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "paused1" totalSeconds = 62 secondsLeft = 10 isActive = false // Sorted last because paused }) - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "running2" totalSeconds = 63 @@ -63,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 }) @@ -74,11 +73,11 @@ class VoiceSatelliteTimerTest { assertTrue { remaining2Millis <= 50_000 } assertTrue { remaining2Millis > 49_900 } - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_CANCELLED timerId = "running1" }) - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_UPDATED timerId = "paused1" totalSeconds = 62 @@ -86,10 +85,10 @@ 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 @@ -101,8 +100,8 @@ class VoiceSatelliteTimerTest { onCompletion(false) } } - val voiceSatellite = start_satellite(voiceOutput) - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + val voiceAssistant = createVoiceAssistant(voiceOutput = voiceOutput) + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_STARTED timerId = "timer2" totalSeconds = 61 @@ -110,18 +109,18 @@ class VoiceSatelliteTimerTest { isActive = true name = "Will ring" }) - voiceSatellite.handleMessage(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) - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "timer2" totalSeconds = 61 @@ -129,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())) @@ -138,19 +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) assert(audioPlayed) - voiceSatellite.close() + voiceAssistant.close() } @Test fun should_remove_repeating_timer_on_wake_word() = runTest { - val voiceSatellite = start_satellite() + val voiceAssistant = createVoiceAssistant() - voiceSatellite.handleMessage(voiceAssistantTimerEventResponse { + voiceAssistant.handleMessage(voiceAssistantTimerEventResponse { eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED timerId = "ringing1" totalSeconds = 61 @@ -158,26 +157,26 @@ class VoiceSatelliteTimerTest { isActive = false name = "Will ring" }) - voiceSatellite.handleMessage(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.voiceInput as StubVoiceInput).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 index 5d98214..986855f 100644 --- a/app/src/test/java/com/example/ava/VoiceOutputTest.kt +++ b/app/src/test/java/com/example/ava/VoiceOutputTest.kt @@ -1,6 +1,6 @@ package com.example.ava -import com.example.ava.esphome.voicesatellite.VoiceOutputImpl +import com.example.ava.esphome.voiceassistant.VoiceOutputImpl import com.example.ava.players.AudioPlayer import com.example.ava.stubs.StubAudioPlayer import kotlinx.coroutines.test.runTest diff --git a/app/src/test/java/com/example/ava/VoicePipelineTest.kt b/app/src/test/java/com/example/ava/VoicePipelineTest.kt index 4cdf4e6..e247043 100644 --- a/app/src/test/java/com/example/ava/VoicePipelineTest.kt +++ b/app/src/test/java/com/example/ava/VoicePipelineTest.kt @@ -2,9 +2,9 @@ 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.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 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/StubVoiceInput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt index c753fdd..f7a8827 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceInput.kt @@ -1,7 +1,7 @@ package com.example.ava.stubs -import com.example.ava.esphome.voicesatellite.AudioResult -import com.example.ava.esphome.voicesatellite.VoiceInput +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 diff --git a/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt index ce69246..b057528 100644 --- a/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt +++ b/app/src/test/java/com/example/ava/stubs/StubVoiceOutput.kt @@ -1,6 +1,6 @@ package com.example.ava.stubs -import com.example.ava.esphome.voicesatellite.VoiceOutput +import com.example.ava.esphome.voiceassistant.VoiceOutput import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow