Skip to content

Commit

Permalink
Add rememberConnectionState and rememberVoiceAssistant (#30)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidliu authored Oct 2, 2024
1 parent 92c422f commit 9da95de
Show file tree
Hide file tree
Showing 8 changed files with 381 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/nine-boxes-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"components-android": minor
---

Add rememberConnectionState and rememberVoiceAssistant
4 changes: 0 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ name: Publish

on:
workflow_dispatch:
push:
# only publish on version tags
tags:
- v*

jobs:
publish:
Expand Down
2 changes: 2 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.android.compose.state

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import io.livekit.android.compose.local.RoomScope
import io.livekit.android.compose.local.requireRoom
import io.livekit.android.room.Room
import io.livekit.android.util.flow

/**
* Returns the [Room.State] from [passedRoom] or the local [RoomScope] if null.
*/
@Composable
fun rememberConnectionState(passedRoom: Room? = null): Room.State {
val room = requireRoom(passedRoom)
return room::state.flow.collectAsState().value
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,17 @@ import kotlinx.coroutines.flow.mapLatest
@Composable
fun rememberParticipantTrackReferences(
sources: List<Track.Source>,
participantIdentity: Participant.Identity,
participantIdentity: Participant.Identity? = null,
passedRoom: Room? = null,
usePlaceholders: Set<Track.Source> = emptySet(),
onlySubscribed: Boolean = true,
): List<TrackReference> {
val room = requireRoom(passedRoom)
val participant = room.getParticipantByIdentity(participantIdentity)
val participant = if (participantIdentity != null) {
room.getParticipantByIdentity(participantIdentity)
} else {
null
}

return rememberParticipantTrackReferences(
sources = sources,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright 2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.android.compose.state

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.livekit.android.annotations.Beta
import io.livekit.android.compose.local.requireRoom
import io.livekit.android.compose.state.transcriptions.rememberTrackTranscriptions
import io.livekit.android.compose.types.TrackReference
import io.livekit.android.room.Room
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.participant.RemoteParticipant
import io.livekit.android.room.track.Track
import io.livekit.android.room.types.TranscriptionSegment
import io.livekit.android.util.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map

/**
* This looks for the first agent-participant in the room.
*
* Requires an agent running with livekit-agents \>= 0.9.0.
*/
@Beta
@Composable
fun rememberVoiceAssistant(passedRoom: Room? = null): VoiceAssistant {
val room = requireRoom(passedRoom)
val connectionState = rememberConnectionState(room)
val remoteParticipants by room::remoteParticipants.flow.collectAsState()
val agent by remember {
derivedStateOf {
remoteParticipants.values
.firstOrNull { p -> p.kind == Participant.Kind.AGENT }
}
}
// For nullability checks
val curAgent = agent

val audioTrack = if (curAgent != null) {
rememberParticipantTrackReferences(
sources = listOf(Track.Source.MICROPHONE),
participantIdentity = curAgent.identity,
passedRoom = room,
).firstOrNull()
} else {
null
}

val agentTranscriptions = if (audioTrack != null) {
rememberTrackTranscriptions(trackReference = audioTrack)
} else {
emptyList()
}

val agentState = rememberAgentState(participant = curAgent)
val agentAttributes = if (curAgent != null) {
curAgent::attributes.flow.collectAsState().value
} else {
emptyMap()
}

val combinedAgentState = remember(agentState, connectionState) {
when {
connectionState == Room.State.DISCONNECTED -> {
AgentState.DISCONNECTED
}

connectionState == Room.State.CONNECTING -> {
AgentState.CONNECTING
}

agent == null -> {
AgentState.INITIALIZING
}

else -> {
agentState
}
}
}

return remember(agent, combinedAgentState, audioTrack, agentTranscriptions, agentAttributes) {
VoiceAssistant(
agent = agent,
state = combinedAgentState,
audioTrack = audioTrack,
agentTranscriptions = agentTranscriptions,
agentAttributes = agentAttributes
)
}
}

data class VoiceAssistant(
val agent: RemoteParticipant?,
val state: AgentState,
val audioTrack: TrackReference?,
val agentTranscriptions: List<TranscriptionSegment>,
val agentAttributes: Map<String, String>?,
)

/**
* Keeps track of the agent state for a participant.
*/
@Composable
fun rememberAgentState(participant: Participant?): AgentState {
val flow = remember(participant) {
if (participant != null) {
return@remember participant::attributes.flow
.map { attributes -> attributes[PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_KEY] }
.map { stateString -> AgentState.fromAttribute(stateString) }
} else {
return@remember flowOf(AgentState.UNKNOWN)
}
}

return flow.collectAsState(initial = AgentState.UNKNOWN).value
}

const val PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_KEY = "lk.agent.state"
const val PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_LISTENING = "listening"
const val PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_THINKING = "thinking"
const val PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_SPEAKING = "speaking"

enum class AgentState {
DISCONNECTED,
CONNECTING,
INITIALIZING,
LISTENING,
THINKING,
SPEAKING,
UNKNOWN;

companion object {
fun fromAttribute(attribute: String?): AgentState {
return when (attribute) {
"listening" -> LISTENING
"thinking" -> THINKING
"speaking" -> SPEAKING
else -> UNKNOWN
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright 2024 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.android.compose.state

import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import io.livekit.android.room.Room
import io.livekit.android.test.MockE2ETest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import org.junit.Assert.assertEquals
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class RememberConnectionStateTest : MockE2ETest() {

@Test
fun initialState() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
rememberConnectionState(room)
}.test {
assertEquals(awaitItem(), Room.State.DISCONNECTED)
}
}

@Test
fun connectAndDisconnectState() = runTest {
val job = coroutineRule.scope.launch {
moleculeFlow(RecompositionClock.Immediate) {
rememberConnectionState(room)
}.test {
assertEquals(awaitItem(), Room.State.DISCONNECTED)
assertEquals(awaitItem(), Room.State.CONNECTING)
assertEquals(awaitItem(), Room.State.CONNECTED)
assertEquals(awaitItem(), Room.State.DISCONNECTED)
}
}

connect()
room.disconnect()

job.join()
}
}
Loading

0 comments on commit 9da95de

Please sign in to comment.