Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/example/ava/audio/MicrophoneInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class MicrophoneInput(
it.release()
audioRecord = null
}
Timber.d("Microphone closed")
}

companion object {
Expand Down
81 changes: 58 additions & 23 deletions app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.example.ava.esphome

import android.Manifest
import androidx.annotation.RequiresPermission
import com.example.ava.esphome.entities.Entity
import com.example.ava.esphome.voiceassistant.VoiceAssistant
import com.example.ava.server.DEFAULT_SERVER_PORT
import com.example.ava.server.Server
import com.example.ava.server.ServerException
Expand All @@ -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
Expand Down Expand Up @@ -41,30 +50,33 @@ data object Disconnected : EspHomeState
data object Stopped : EspHomeState
data class ServerError(val message: String) : EspHomeState

abstract class EspHomeDevice(
class EspHomeDevice(
coroutineContext: CoroutineContext,
protected val name: String,
protected val port: Int = DEFAULT_SERVER_PORT,
protected val server: Server = ServerImpl(),
private val port: Int = DEFAULT_SERVER_PORT,
private val server: Server = ServerImpl(),
private val deviceInfo: DeviceInfoResponse,
val voiceAssistant: VoiceAssistant,
entities: Iterable<Entity> = emptyList()
) : AutoCloseable {
protected val entities = entities.toList()
protected val _state = MutableStateFlow<EspHomeState>(Disconnected)
private val entities = entities.toList()
private val _state = MutableStateFlow<EspHomeState>(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) }
Expand All @@ -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
})
Expand All @@ -113,7 +135,7 @@ abstract class EspHomeDevice(
server.disconnectCurrentClient()
}

is DeviceInfoRequest -> sendMessage(getDeviceInfo())
is DeviceInfoRequest -> sendMessage(deviceInfo)

is PingRequest -> sendMessage(pingResponse { })

Expand All @@ -125,29 +147,42 @@ 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) }
}
}
}

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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,66 @@ package com.example.ava.esphome.entities

import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer
import com.example.ava.players.AudioPlayerState
import com.example.esphomeproto.api.ListEntitiesRequest
import com.example.esphomeproto.api.MediaPlayerCommand
import com.example.esphomeproto.api.MediaPlayerCommandRequest
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<MediaPlayerState>

/**
* 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<Float>

/**
* Sets the playback volume.
*/
suspend fun setVolume(value: Float)

/**
* Gets whether playback is muted.
*/
val muted: StateFlow<Boolean>

/**
* Sets whether playback is muted.
*/
suspend fun setMuted(value: Boolean)
}

@OptIn(UnstableApi::class)
class MediaPlayerEntity(
val key: Int,
val name: String,
val objectId: String,
val player: VoiceSatellitePlayer
val mediaPlayer: MediaPlayer
) : Entity {

override fun handleMessage(message: MessageLite) = flow {
Expand All @@ -34,40 +76,44 @@ class MediaPlayerEntity(
is MediaPlayerCommandRequest -> {
if (message.key == key) {
if (message.hasMediaUrl) {
player.mediaPlayer.play(message.mediaUrl)
mediaPlayer.playMedia(message.mediaUrl)
} else if (message.hasCommand) {
when (message.command) {
MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PAUSE -> player.mediaPlayer.pause()
MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PLAY -> player.mediaPlayer.unpause()
MediaPlayerCommand.MEDIA_PLAYER_COMMAND_STOP -> player.mediaPlayer.stop()
MediaPlayerCommand.MEDIA_PLAYER_COMMAND_MUTE -> player.setMuted(true)
MediaPlayerCommand.MEDIA_PLAYER_COMMAND_UNMUTE -> player.setMuted(false)
MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PAUSE ->
mediaPlayer.setMediaPaused(true)

MediaPlayerCommand.MEDIA_PLAYER_COMMAND_PLAY ->
mediaPlayer.setMediaPaused(false)

MediaPlayerCommand.MEDIA_PLAYER_COMMAND_STOP ->
mediaPlayer.stopMedia()

MediaPlayerCommand.MEDIA_PLAYER_COMMAND_MUTE ->
mediaPlayer.setMuted(true)

MediaPlayerCommand.MEDIA_PLAYER_COMMAND_UNMUTE ->
mediaPlayer.setMuted(false)

else -> {}
}
} else if (message.hasVolume) {
player.setVolume(message.volume)
mediaPlayer.setVolume(message.volume)
}
}
}
}
}

override fun subscribe() = combine(
player.mediaPlayer.state,
player.volume,
player.muted,
mediaPlayer.mediaState,
mediaPlayer.volume,
mediaPlayer.muted,
) { state, volume, muted ->
mediaPlayerStateResponse {
key = this@MediaPlayerEntity.key
this.state = getState(state)
this.state = state
this.volume = volume
this.muted = muted
}
}

private fun getState(state: AudioPlayerState) = when (state) {
AudioPlayerState.PLAYING -> MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING
AudioPlayerState.PAUSED -> MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED
AudioPlayerState.IDLE -> MediaPlayerState.MEDIA_PLAYER_STATE_IDLE
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package com.example.ava.esphome.voicesatellite
package com.example.ava.esphome.voiceassistant

import com.example.ava.esphome.Connected
import com.example.ava.esphome.EspHomeState
import com.example.ava.players.AudioPlayer
import com.example.esphomeproto.api.voiceAssistantAnnounceFinished
import com.google.protobuf.MessageLite
import kotlinx.coroutines.CoroutineScope
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
Expand All @@ -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)
Expand All @@ -35,7 +29,7 @@ class Announcement(

suspend fun stop() {
if (_state == Responding) {
player.stop()
voiceOutput.stopTTS()
updateState(Connected)
sendMessage(voiceAssistantAnnounceFinished { })
}
Expand Down
Loading
Loading