Committed by
GitHub
Add pre-connect audio for use with agents (#666)
* Update protocol submodule to v1.38.0 * Basic Preconnect audio buffer implementation * Add Participant.State and related events * prerecording full implementation * Fix outgoing byte datastreams incorrectly padding data * Add pre-connect audio for use with agents
正在显示
22 个修改的文件
包含
806 行增加
和
16 行删除
.changeset/odd-squids-yell.md
0 → 100644
.changeset/rotten-brooms-look.md
0 → 100644
.changeset/weak-seas-reply.md
0 → 100644
| 1 | /* | 1 | /* |
| 2 | - * Copyright 2023-2024 LiveKit, Inc. | 2 | + * Copyright 2023-2025 LiveKit, Inc. |
| 3 | * | 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); | 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. | 5 | * you may not use this file except in compliance with the License. |
| @@ -26,6 +26,7 @@ import io.livekit.android.audio.AudioProcessorOptions | @@ -26,6 +26,7 @@ import io.livekit.android.audio.AudioProcessorOptions | ||
| 26 | import io.livekit.android.audio.AudioSwitchHandler | 26 | import io.livekit.android.audio.AudioSwitchHandler |
| 27 | import io.livekit.android.audio.NoAudioHandler | 27 | import io.livekit.android.audio.NoAudioHandler |
| 28 | import io.livekit.android.room.Room | 28 | import io.livekit.android.room.Room |
| 29 | +import io.livekit.android.room.track.LocalAudioTrack | ||
| 29 | import livekit.org.webrtc.EglBase | 30 | import livekit.org.webrtc.EglBase |
| 30 | import livekit.org.webrtc.PeerConnectionFactory | 31 | import livekit.org.webrtc.PeerConnectionFactory |
| 31 | import livekit.org.webrtc.VideoDecoderFactory | 32 | import livekit.org.webrtc.VideoDecoderFactory |
| @@ -33,6 +34,7 @@ import livekit.org.webrtc.VideoEncoderFactory | @@ -33,6 +34,7 @@ import livekit.org.webrtc.VideoEncoderFactory | ||
| 33 | import livekit.org.webrtc.audio.AudioDeviceModule | 34 | import livekit.org.webrtc.audio.AudioDeviceModule |
| 34 | import livekit.org.webrtc.audio.JavaAudioDeviceModule | 35 | import livekit.org.webrtc.audio.JavaAudioDeviceModule |
| 35 | import okhttp3.OkHttpClient | 36 | import okhttp3.OkHttpClient |
| 37 | + | ||
| 36 | /** | 38 | /** |
| 37 | * Overrides to replace LiveKit internally used components with custom implementations. | 39 | * Overrides to replace LiveKit internally used components with custom implementations. |
| 38 | */ | 40 | */ |
| @@ -110,6 +112,11 @@ class AudioOptions( | @@ -110,6 +112,11 @@ class AudioOptions( | ||
| 110 | * Called after default setup to allow for customizations on the [JavaAudioDeviceModule]. | 112 | * Called after default setup to allow for customizations on the [JavaAudioDeviceModule]. |
| 111 | * | 113 | * |
| 112 | * Not used if [audioDeviceModule] is provided. | 114 | * Not used if [audioDeviceModule] is provided. |
| 115 | + * | ||
| 116 | + * Note: We require setting the [JavaAudioDeviceModule.Builder.setSamplesReadyCallback] to provide | ||
| 117 | + * support for [LocalAudioTrack.addSink]. If you wish to grab the audio samples | ||
| 118 | + * from the local microphone track, use [LocalAudioTrack.addSink] instead of setting your own | ||
| 119 | + * callback. | ||
| 113 | */ | 120 | */ |
| 114 | val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null, | 121 | val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null, |
| 115 | 122 |
| 1 | +/* | ||
| 2 | + * Copyright 2025 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.audio | ||
| 18 | + | ||
| 19 | +import android.os.SystemClock | ||
| 20 | +import io.livekit.android.audio.PreconnectAudioBuffer.Companion.DEFAULT_TOPIC | ||
| 21 | +import io.livekit.android.audio.PreconnectAudioBuffer.Companion.TIMEOUT | ||
| 22 | +import io.livekit.android.events.RoomEvent | ||
| 23 | +import io.livekit.android.events.collect | ||
| 24 | +import io.livekit.android.room.ConnectionState | ||
| 25 | +import io.livekit.android.room.Room | ||
| 26 | +import io.livekit.android.room.datastream.StreamBytesOptions | ||
| 27 | +import io.livekit.android.room.participant.Participant | ||
| 28 | +import io.livekit.android.util.LKLog | ||
| 29 | +import io.livekit.android.util.flow | ||
| 30 | +import kotlinx.coroutines.cancel | ||
| 31 | +import kotlinx.coroutines.coroutineScope | ||
| 32 | +import kotlinx.coroutines.delay | ||
| 33 | +import kotlinx.coroutines.flow.collect | ||
| 34 | +import kotlinx.coroutines.flow.takeWhile | ||
| 35 | +import kotlinx.coroutines.launch | ||
| 36 | +import livekit.org.webrtc.AudioTrackSink | ||
| 37 | +import java.io.ByteArrayOutputStream | ||
| 38 | +import java.nio.ByteBuffer | ||
| 39 | +import kotlin.math.min | ||
| 40 | +import kotlin.time.Duration | ||
| 41 | +import kotlin.time.Duration.Companion.seconds | ||
| 42 | + | ||
| 43 | +internal class PreconnectAudioBuffer | ||
| 44 | +internal constructor(timeout: Duration) : AudioTrackSink { | ||
| 45 | + | ||
| 46 | + companion object { | ||
| 47 | + const val DEFAULT_TOPIC = "lk.agent.pre-connect-audio-buffer" | ||
| 48 | + val TIMEOUT = 10.seconds | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + private val outputStreamLock = Any() | ||
| 52 | + private val outputStream by lazy { | ||
| 53 | + ByteArrayOutputStream() | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + private lateinit var collectedBytes: ByteArray | ||
| 57 | + | ||
| 58 | + private val tempArray = ByteArray(1024) | ||
| 59 | + private var initialTime = -1L | ||
| 60 | + | ||
| 61 | + private var bitsPerSample = 16 | ||
| 62 | + private var sampleRate = 48000 // default sampleRate from JavaAudioDeviceModule | ||
| 63 | + private var numberOfChannels = 1 // default channels from JavaAudioDeviceModule | ||
| 64 | + | ||
| 65 | + private var isRecording = true | ||
| 66 | + private val timeoutMs = timeout.inWholeMilliseconds | ||
| 67 | + | ||
| 68 | + fun startRecording() { | ||
| 69 | + isRecording = true | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + fun stopRecording() { | ||
| 73 | + synchronized(outputStreamLock) { | ||
| 74 | + if (isRecording) { | ||
| 75 | + collectedBytes = outputStream.toByteArray() | ||
| 76 | + isRecording = false | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + fun clear() { | ||
| 82 | + stopRecording() | ||
| 83 | + collectedBytes = ByteArray(0) | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + override fun onData( | ||
| 87 | + audioData: ByteBuffer, | ||
| 88 | + bitsPerSample: Int, | ||
| 89 | + sampleRate: Int, | ||
| 90 | + numberOfChannels: Int, | ||
| 91 | + numberOfFrames: Int, | ||
| 92 | + absoluteCaptureTimestampMs: Long, | ||
| 93 | + ) { | ||
| 94 | + if (!isRecording) { | ||
| 95 | + return | ||
| 96 | + } | ||
| 97 | + if (initialTime == -1L) { | ||
| 98 | + initialTime = SystemClock.elapsedRealtime() | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + this.bitsPerSample = bitsPerSample | ||
| 102 | + this.sampleRate = sampleRate | ||
| 103 | + this.numberOfChannels = numberOfChannels | ||
| 104 | + val currentTime = SystemClock.elapsedRealtime() | ||
| 105 | + // Limit reached, don't buffer any more. | ||
| 106 | + if (currentTime - initialTime > timeoutMs) { | ||
| 107 | + return | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + audioData.rewind() | ||
| 111 | + | ||
| 112 | + synchronized(outputStreamLock) { | ||
| 113 | + if (audioData.hasArray()) { | ||
| 114 | + outputStream.write(audioData.array()) | ||
| 115 | + } else { | ||
| 116 | + while (audioData.hasRemaining()) { | ||
| 117 | + val readBytes = min(tempArray.size, audioData.remaining()) | ||
| 118 | + audioData.get(tempArray, 0, readBytes) | ||
| 119 | + outputStream.write(tempArray) | ||
| 120 | + } | ||
| 121 | + } | ||
| 122 | + } | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + suspend fun sendAudioData(room: Room, trackSid: String?, agentIdentities: List<Participant.Identity>, topic: String = DEFAULT_TOPIC) { | ||
| 126 | + if (agentIdentities.isEmpty()) { | ||
| 127 | + return | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + val audioData = outputStream.toByteArray() | ||
| 131 | + if (audioData.size <= 1024) { | ||
| 132 | + LKLog.i { "Audio data size too small, nothing to send." } | ||
| 133 | + return | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + val sender = room.localParticipant.streamBytes( | ||
| 137 | + StreamBytesOptions( | ||
| 138 | + topic = topic, | ||
| 139 | + attributes = mapOf( | ||
| 140 | + "sampleRate" to "${this.sampleRate}", | ||
| 141 | + "channels" to "${this.numberOfChannels}", | ||
| 142 | + "trackId" to (trackSid ?: ""), | ||
| 143 | + ), | ||
| 144 | + destinationIdentities = agentIdentities, | ||
| 145 | + totalSize = audioData.size.toLong(), | ||
| 146 | + name = "preconnect-audio-buffer", | ||
| 147 | + ), | ||
| 148 | + ) | ||
| 149 | + | ||
| 150 | + try { | ||
| 151 | + sender.write(audioData) | ||
| 152 | + sender.close() | ||
| 153 | + } catch (e: Exception) { | ||
| 154 | + sender.close(e.localizedMessage) | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + val samples = audioData.size / (numberOfChannels * bitsPerSample / 8) | ||
| 158 | + val duration = samples.toFloat() / sampleRate | ||
| 159 | + LKLog.i { "Sent ${duration}s (${audioData.size / 1024}KB) of audio data to ${agentIdentities.size} agent(s) (${agentIdentities.joinToString(",")})" } | ||
| 160 | + } | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +/** | ||
| 164 | + * Starts a pre-connect audio recording that will be sent to | ||
| 165 | + * any agents that connect within the [timeout]. This speeds up | ||
| 166 | + * preceived connection times, as the user can start speaking | ||
| 167 | + * prior to actual connection with the agent. | ||
| 168 | + * | ||
| 169 | + * This will automatically be cleaned up when the room disconnects or the operation fails. | ||
| 170 | + * | ||
| 171 | + * Example: | ||
| 172 | + * ``` | ||
| 173 | + * try { | ||
| 174 | + * room.withPreconnectAudio { | ||
| 175 | + * // Audio is being captured automatically | ||
| 176 | + * // Perform any other (async) setup here | ||
| 177 | + * val (url, token) = tokenService.fetchConnectionDetails() | ||
| 178 | + * room.connect( | ||
| 179 | + * url = url, | ||
| 180 | + * token = token, | ||
| 181 | + * ) | ||
| 182 | + * room.localParticipant.setMicrophoneEnabled(true) | ||
| 183 | + * } | ||
| 184 | + * } catch (e: Throwable) { | ||
| 185 | + * Log.e(TAG, "Error!") | ||
| 186 | + * } | ||
| 187 | + * ``` | ||
| 188 | + * @param timeout the timeout for the remote participant to subscribe to the audio track. | ||
| 189 | + * The room connection needs to be established and the remote participant needs to subscribe to the audio track | ||
| 190 | + * before the timeout is reached. Otherwise, the audio stream will be flushed without sending. | ||
| 191 | + * @param topic the topic to send the preconnect audio buffer to. By default this is configured for | ||
| 192 | + * use with LiveKit Agents. | ||
| 193 | + * @param onError The error handler to call when an error occurs while sending the audio buffer. | ||
| 194 | + * @param operation The connection lambda to call with the pre-connect audio. | ||
| 195 | + * | ||
| 196 | + */ | ||
| 197 | +suspend fun <T> Room.withPreconnectAudio( | ||
| 198 | + timeout: Duration = TIMEOUT, | ||
| 199 | + topic: String = DEFAULT_TOPIC, | ||
| 200 | + onError: ((e: Exception) -> Unit)? = null, | ||
| 201 | + operation: suspend () -> T, | ||
| 202 | +) = coroutineScope { | ||
| 203 | + isPrerecording = true | ||
| 204 | + val audioTrack = localParticipant.getOrCreateDefaultAudioTrack() | ||
| 205 | + val preconnectAudioBuffer = PreconnectAudioBuffer(timeout) | ||
| 206 | + | ||
| 207 | + LKLog.v { "Starting preconnect audio buffer" } | ||
| 208 | + preconnectAudioBuffer.startRecording() | ||
| 209 | + audioTrack.addSink(preconnectAudioBuffer) | ||
| 210 | + audioTrack.prewarm() | ||
| 211 | + | ||
| 212 | + fun stopRecording() { | ||
| 213 | + if (!isPrerecording) { | ||
| 214 | + return | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + LKLog.v { "Stopping preconnect audio buffer" } | ||
| 218 | + audioTrack.removeSink(preconnectAudioBuffer) | ||
| 219 | + preconnectAudioBuffer.stopRecording() | ||
| 220 | + isPrerecording = false | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + // Clear the preconnect audio buffer after the timeout to free memory. | ||
| 224 | + launch { | ||
| 225 | + delay(TIMEOUT) | ||
| 226 | + preconnectAudioBuffer.clear() | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + val sentIdentities = mutableSetOf<Participant.Identity>() | ||
| 230 | + launch { | ||
| 231 | + suspend fun handleSendIfNeeded(participant: Participant) { | ||
| 232 | + coroutineScope inner@{ | ||
| 233 | + engine::connectionState.flow | ||
| 234 | + .takeWhile { it != ConnectionState.CONNECTED } | ||
| 235 | + .collect() | ||
| 236 | + val kind = participant.kind | ||
| 237 | + val state = participant.state | ||
| 238 | + val identity = participant.identity | ||
| 239 | + if (sentIdentities.contains(identity) || kind != Participant.Kind.AGENT || state != Participant.State.ACTIVE || identity == null) { | ||
| 240 | + return@inner | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + stopRecording() | ||
| 244 | + launch { | ||
| 245 | + try { | ||
| 246 | + preconnectAudioBuffer.sendAudioData( | ||
| 247 | + room = this@withPreconnectAudio, | ||
| 248 | + trackSid = audioTrack.sid, | ||
| 249 | + agentIdentities = listOf(identity), | ||
| 250 | + topic = topic, | ||
| 251 | + ) | ||
| 252 | + sentIdentities.add(identity) | ||
| 253 | + } catch (e: Exception) { | ||
| 254 | + LKLog.w(e) { "Error occurred while sending the audio preconnect data." } | ||
| 255 | + onError?.invoke(e) | ||
| 256 | + } | ||
| 257 | + } | ||
| 258 | + } | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + events.collect { event -> | ||
| 262 | + when (event) { | ||
| 263 | + is RoomEvent.LocalTrackSubscribed -> { | ||
| 264 | + LKLog.i { "Local audio track has been subscribed to, stopping preconnect audio recording." } | ||
| 265 | + stopRecording() | ||
| 266 | + } | ||
| 267 | + | ||
| 268 | + is RoomEvent.ParticipantConnected -> { | ||
| 269 | + // agents may connect with ACTIVE state and not trigger a participant state changed. | ||
| 270 | + handleSendIfNeeded(event.participant) | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + is RoomEvent.ParticipantStateChanged -> { | ||
| 274 | + handleSendIfNeeded(event.participant) | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + is RoomEvent.Disconnected -> { | ||
| 278 | + cancel() | ||
| 279 | + } | ||
| 280 | + | ||
| 281 | + else -> { | ||
| 282 | + // Intentionally blank. | ||
| 283 | + } | ||
| 284 | + } | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | + | ||
| 288 | + val retValue: T | ||
| 289 | + try { | ||
| 290 | + retValue = operation.invoke() | ||
| 291 | + } catch (e: Exception) { | ||
| 292 | + cancel() | ||
| 293 | + throw e | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + return@coroutineScope retValue | ||
| 297 | +} |
| @@ -197,4 +197,13 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() { | @@ -197,4 +197,13 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() { | ||
| 197 | */ | 197 | */ |
| 198 | val publication: TrackPublication?, | 198 | val publication: TrackPublication?, |
| 199 | ) : ParticipantEvent(participant) | 199 | ) : ParticipantEvent(participant) |
| 200 | + | ||
| 201 | + /** | ||
| 202 | + * A participant's state has changed. | ||
| 203 | + */ | ||
| 204 | + class StateChanged( | ||
| 205 | + override val participant: Participant, | ||
| 206 | + val newState: Participant.State, | ||
| 207 | + val oldState: Participant.State, | ||
| 208 | + ) : ParticipantEvent(participant) | ||
| 200 | } | 209 | } |
| @@ -271,6 +271,16 @@ sealed class RoomEvent(val room: Room) : Event() { | @@ -271,6 +271,16 @@ sealed class RoomEvent(val room: Room) : Event() { | ||
| 271 | */ | 271 | */ |
| 272 | val publication: TrackPublication?, | 272 | val publication: TrackPublication?, |
| 273 | ) : RoomEvent(room) | 273 | ) : RoomEvent(room) |
| 274 | + | ||
| 275 | + /** | ||
| 276 | + * The state for a participant has changed. | ||
| 277 | + */ | ||
| 278 | + class ParticipantStateChanged( | ||
| 279 | + room: Room, | ||
| 280 | + val participant: Participant, | ||
| 281 | + val newState: Participant.State, | ||
| 282 | + val oldState: Participant.State, | ||
| 283 | + ) : RoomEvent(room) | ||
| 274 | } | 284 | } |
| 275 | 285 | ||
| 276 | enum class DisconnectReason { | 286 | enum class DisconnectReason { |
| @@ -288,6 +298,7 @@ enum class DisconnectReason { | @@ -288,6 +298,7 @@ enum class DisconnectReason { | ||
| 288 | USER_UNAVAILABLE, | 298 | USER_UNAVAILABLE, |
| 289 | USER_REJECTED, | 299 | USER_REJECTED, |
| 290 | SIP_TRUNK_FAILURE, | 300 | SIP_TRUNK_FAILURE, |
| 301 | + CONNECTION_TIMEOUT, | ||
| 291 | } | 302 | } |
| 292 | 303 | ||
| 293 | /** | 304 | /** |
| @@ -308,6 +319,7 @@ fun LivekitModels.DisconnectReason?.convert(): DisconnectReason { | @@ -308,6 +319,7 @@ fun LivekitModels.DisconnectReason?.convert(): DisconnectReason { | ||
| 308 | LivekitModels.DisconnectReason.USER_UNAVAILABLE -> DisconnectReason.USER_UNAVAILABLE | 319 | LivekitModels.DisconnectReason.USER_UNAVAILABLE -> DisconnectReason.USER_UNAVAILABLE |
| 309 | LivekitModels.DisconnectReason.USER_REJECTED -> DisconnectReason.USER_REJECTED | 320 | LivekitModels.DisconnectReason.USER_REJECTED -> DisconnectReason.USER_REJECTED |
| 310 | LivekitModels.DisconnectReason.SIP_TRUNK_FAILURE -> DisconnectReason.SIP_TRUNK_FAILURE | 321 | LivekitModels.DisconnectReason.SIP_TRUNK_FAILURE -> DisconnectReason.SIP_TRUNK_FAILURE |
| 322 | + LivekitModels.DisconnectReason.CONNECTION_TIMEOUT -> DisconnectReason.CONNECTION_TIMEOUT | ||
| 311 | LivekitModels.DisconnectReason.UNKNOWN_REASON, | 323 | LivekitModels.DisconnectReason.UNKNOWN_REASON, |
| 312 | LivekitModels.DisconnectReason.UNRECOGNIZED, | 324 | LivekitModels.DisconnectReason.UNRECOGNIZED, |
| 313 | null, | 325 | null, |
| 1 | /* | 1 | /* |
| 2 | - * Copyright 2023-2024 LiveKit, Inc. | 2 | + * Copyright 2023-2025 LiveKit, Inc. |
| 3 | * | 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); | 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. | 5 | * you may not use this file except in compliance with the License. |
| @@ -24,6 +24,9 @@ import io.livekit.android.room.track.ScreenSharePresets | @@ -24,6 +24,9 @@ import io.livekit.android.room.track.ScreenSharePresets | ||
| 24 | import javax.inject.Inject | 24 | import javax.inject.Inject |
| 25 | import javax.inject.Singleton | 25 | import javax.inject.Singleton |
| 26 | 26 | ||
| 27 | +/** | ||
| 28 | + * @suppress | ||
| 29 | + */ | ||
| 27 | @Singleton | 30 | @Singleton |
| 28 | class DefaultsManager | 31 | class DefaultsManager |
| 29 | @Inject | 32 | @Inject |
| @@ -34,4 +37,6 @@ constructor() { | @@ -34,4 +37,6 @@ constructor() { | ||
| 34 | var videoTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults() | 37 | var videoTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults() |
| 35 | var screenShareTrackCaptureDefaults: LocalVideoTrackOptions = LocalVideoTrackOptions(isScreencast = true, captureParams = ScreenSharePresets.ORIGINAL.capture) | 38 | var screenShareTrackCaptureDefaults: LocalVideoTrackOptions = LocalVideoTrackOptions(isScreencast = true, captureParams = ScreenSharePresets.ORIGINAL.capture) |
| 36 | var screenShareTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults(videoEncoding = ScreenSharePresets.ORIGINAL.encoding) | 39 | var screenShareTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults(videoEncoding = ScreenSharePresets.ORIGINAL.encoding) |
| 40 | + | ||
| 41 | + var isPrerecording: Boolean = false | ||
| 37 | } | 42 | } |
| @@ -110,7 +110,7 @@ internal constructor( | @@ -110,7 +110,7 @@ internal constructor( | ||
| 110 | * Reflects the combined connection state of SignalClient and primary PeerConnection. | 110 | * Reflects the combined connection state of SignalClient and primary PeerConnection. |
| 111 | */ | 111 | */ |
| 112 | @FlowObservable | 112 | @FlowObservable |
| 113 | - @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) | 113 | + @get:FlowObservable |
| 114 | var connectionState: ConnectionState by flowDelegate(ConnectionState.DISCONNECTED) { newVal, oldVal -> | 114 | var connectionState: ConnectionState by flowDelegate(ConnectionState.DISCONNECTED) { newVal, oldVal -> |
| 115 | if (newVal == oldVal) { | 115 | if (newVal == oldVal) { |
| 116 | return@flowDelegate | 116 | return@flowDelegate |
| @@ -302,7 +302,8 @@ internal constructor( | @@ -302,7 +302,8 @@ internal constructor( | ||
| 302 | RELIABLE_DATA_CHANNEL_LABEL, | 302 | RELIABLE_DATA_CHANNEL_LABEL, |
| 303 | reliableInit, | 303 | reliableInit, |
| 304 | ).also { dataChannel -> | 304 | ).also { dataChannel -> |
| 305 | - dataChannel.registerObserver(DataChannelObserver(dataChannel)) | 305 | + reliableDataChannelManager = DataChannelManager(dataChannel, DataChannelObserver(dataChannel)) |
| 306 | + dataChannel.registerObserver(reliableDataChannelManager) | ||
| 306 | } | 307 | } |
| 307 | } | 308 | } |
| 308 | 309 | ||
| @@ -315,7 +316,8 @@ internal constructor( | @@ -315,7 +316,8 @@ internal constructor( | ||
| 315 | LOSSY_DATA_CHANNEL_LABEL, | 316 | LOSSY_DATA_CHANNEL_LABEL, |
| 316 | lossyInit, | 317 | lossyInit, |
| 317 | ).also { dataChannel -> | 318 | ).also { dataChannel -> |
| 318 | - dataChannel.registerObserver(DataChannelObserver(dataChannel)) | 319 | + lossyDataChannelManager = DataChannelManager(dataChannel, DataChannelObserver(dataChannel)) |
| 320 | + dataChannel.registerObserver(lossyDataChannelManager) | ||
| 319 | } | 321 | } |
| 320 | } | 322 | } |
| 321 | } | 323 | } |
| @@ -1095,6 +1097,7 @@ internal constructor( | @@ -1095,6 +1097,7 @@ internal constructor( | ||
| 1095 | -> { | 1097 | -> { |
| 1096 | listener?.onRpcPacketReceived(dp) | 1098 | listener?.onRpcPacketReceived(dp) |
| 1097 | } | 1099 | } |
| 1100 | + | ||
| 1098 | LivekitModels.DataPacket.ValueCase.VALUE_NOT_SET, | 1101 | LivekitModels.DataPacket.ValueCase.VALUE_NOT_SET, |
| 1099 | null, | 1102 | null, |
| 1100 | -> { | 1103 | -> { |
| @@ -76,7 +76,7 @@ class Room | @@ -76,7 +76,7 @@ class Room | ||
| 76 | @AssistedInject | 76 | @AssistedInject |
| 77 | constructor( | 77 | constructor( |
| 78 | @Assisted private val context: Context, | 78 | @Assisted private val context: Context, |
| 79 | - private val engine: RTCEngine, | 79 | + internal val engine: RTCEngine, |
| 80 | private val eglBase: EglBase, | 80 | private val eglBase: EglBase, |
| 81 | localParticipantFactory: LocalParticipant.Factory, | 81 | localParticipantFactory: LocalParticipant.Factory, |
| 82 | private val defaultsManager: DefaultsManager, | 82 | private val defaultsManager: DefaultsManager, |
| @@ -320,6 +320,8 @@ constructor( | @@ -320,6 +320,8 @@ constructor( | ||
| 320 | 320 | ||
| 321 | private var transcriptionReceivedTimes = mutableMapOf<String, Long>() | 321 | private var transcriptionReceivedTimes = mutableMapOf<String, Long>() |
| 322 | 322 | ||
| 323 | + internal var isPrerecording by defaultsManager::isPrerecording | ||
| 324 | + | ||
| 323 | private fun getCurrentRoomOptions(): RoomOptions = | 325 | private fun getCurrentRoomOptions(): RoomOptions = |
| 324 | RoomOptions( | 326 | RoomOptions( |
| 325 | adaptiveStream = adaptiveStream, | 327 | adaptiveStream = adaptiveStream, |
| @@ -678,6 +680,15 @@ constructor( | @@ -678,6 +680,15 @@ constructor( | ||
| 678 | ) | 680 | ) |
| 679 | } | 681 | } |
| 680 | 682 | ||
| 683 | + is ParticipantEvent.StateChanged -> { | ||
| 684 | + RoomEvent.ParticipantStateChanged( | ||
| 685 | + this@Room, | ||
| 686 | + it.participant, | ||
| 687 | + it.newState, | ||
| 688 | + it.oldState, | ||
| 689 | + ) | ||
| 690 | + } | ||
| 691 | + | ||
| 681 | else -> { | 692 | else -> { |
| 682 | // do nothing | 693 | // do nothing |
| 683 | } | 694 | } |
| @@ -808,6 +819,17 @@ constructor( | @@ -808,6 +819,17 @@ constructor( | ||
| 808 | ), | 819 | ), |
| 809 | ) | 820 | ) |
| 810 | 821 | ||
| 822 | + is ParticipantEvent.StateChanged -> { | ||
| 823 | + eventBus.postEvent( | ||
| 824 | + RoomEvent.ParticipantStateChanged( | ||
| 825 | + room = this@Room, | ||
| 826 | + participant = it.participant, | ||
| 827 | + newState = it.newState, | ||
| 828 | + oldState = it.oldState, | ||
| 829 | + ), | ||
| 830 | + ) | ||
| 831 | + } | ||
| 832 | + | ||
| 811 | else -> { | 833 | else -> { |
| 812 | // do nothing | 834 | // do nothing |
| 813 | } | 835 | } |
| @@ -45,4 +45,7 @@ interface StreamDestination<T> { | @@ -45,4 +45,7 @@ interface StreamDestination<T> { | ||
| 45 | suspend fun close(reason: String?) | 45 | suspend fun close(reason: String?) |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | -internal typealias DataChunker<T> = (data: T, chunkSize: Int) -> List<ByteArray> | 48 | +/** |
| 49 | + * @suppress | ||
| 50 | + */ | ||
| 51 | +typealias DataChunker<T> = (data: T, chunkSize: Int) -> List<ByteArray> |
| @@ -24,6 +24,7 @@ import okio.Source | @@ -24,6 +24,7 @@ import okio.Source | ||
| 24 | import okio.source | 24 | import okio.source |
| 25 | import java.io.InputStream | 25 | import java.io.InputStream |
| 26 | import java.util.Arrays | 26 | import java.util.Arrays |
| 27 | +import kotlin.math.min | ||
| 27 | 28 | ||
| 28 | class ByteStreamSender( | 29 | class ByteStreamSender( |
| 29 | val info: ByteStreamInfo, | 30 | val info: ByteStreamInfo, |
| @@ -36,7 +37,16 @@ class ByteStreamSender( | @@ -36,7 +37,16 @@ class ByteStreamSender( | ||
| 36 | 37 | ||
| 37 | private val byteDataChunker: DataChunker<ByteArray> = { data: ByteArray, chunkSize: Int -> | 38 | private val byteDataChunker: DataChunker<ByteArray> = { data: ByteArray, chunkSize: Int -> |
| 38 | (data.indices step chunkSize) | 39 | (data.indices step chunkSize) |
| 39 | - .map { index -> Arrays.copyOfRange(data, index, index + chunkSize) } | 40 | + .map { index -> |
| 41 | + Arrays.copyOfRange( | ||
| 42 | + /* original = */ | ||
| 43 | + data, | ||
| 44 | + /* from = */ | ||
| 45 | + index, | ||
| 46 | + /* to = */ | ||
| 47 | + min(index + chunkSize, data.size), | ||
| 48 | + ) | ||
| 49 | + } | ||
| 40 | } | 50 | } |
| 41 | 51 | ||
| 42 | /** | 52 | /** |
| @@ -67,6 +67,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine | @@ -67,6 +67,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine | ||
| 67 | import kotlinx.coroutines.sync.Mutex | 67 | import kotlinx.coroutines.sync.Mutex |
| 68 | import kotlinx.coroutines.sync.withLock | 68 | import kotlinx.coroutines.sync.withLock |
| 69 | import livekit.LivekitModels | 69 | import livekit.LivekitModels |
| 70 | +import livekit.LivekitModels.AudioTrackFeature | ||
| 70 | import livekit.LivekitModels.Codec | 71 | import livekit.LivekitModels.Codec |
| 71 | import livekit.LivekitModels.DataPacket | 72 | import livekit.LivekitModels.DataPacket |
| 72 | import livekit.LivekitModels.TrackInfo | 73 | import livekit.LivekitModels.TrackInfo |
| @@ -434,7 +435,7 @@ internal constructor( | @@ -434,7 +435,7 @@ internal constructor( | ||
| 434 | options: AudioTrackPublishOptions = AudioTrackPublishOptions( | 435 | options: AudioTrackPublishOptions = AudioTrackPublishOptions( |
| 435 | null, | 436 | null, |
| 436 | audioTrackPublishDefaults, | 437 | audioTrackPublishDefaults, |
| 437 | - ), | 438 | + ).copy(preconnect = defaultsManager.isPrerecording), |
| 438 | publishListener: PublishListener? = null, | 439 | publishListener: PublishListener? = null, |
| 439 | ): Boolean { | 440 | ): Boolean { |
| 440 | val encodings = listOf( | 441 | val encodings = listOf( |
| @@ -450,6 +451,7 @@ internal constructor( | @@ -450,6 +451,7 @@ internal constructor( | ||
| 450 | requestConfig = { | 451 | requestConfig = { |
| 451 | disableDtx = !options.dtx | 452 | disableDtx = !options.dtx |
| 452 | disableRed = !options.red | 453 | disableRed = !options.red |
| 454 | + addAllAudioFeatures(options.getFeaturesList()) | ||
| 453 | source = options.source?.toProto() ?: LivekitModels.TrackSource.MICROPHONE | 455 | source = options.source?.toProto() ?: LivekitModels.TrackSource.MICROPHONE |
| 454 | }, | 456 | }, |
| 455 | encodings = encodings, | 457 | encodings = encodings, |
| @@ -459,7 +461,7 @@ internal constructor( | @@ -459,7 +461,7 @@ internal constructor( | ||
| 459 | if (publication != null) { | 461 | if (publication != null) { |
| 460 | val job = scope.launch { | 462 | val job = scope.launch { |
| 461 | track::features.flow.collect { | 463 | track::features.flow.collect { |
| 462 | - engine.updateLocalAudioTrack(publication.sid, it) | 464 | + engine.updateLocalAudioTrack(publication.sid, it + options.getFeaturesList()) |
| 463 | } | 465 | } |
| 464 | } | 466 | } |
| 465 | jobs[publication] = job | 467 | jobs[publication] = job |
| @@ -1763,6 +1765,7 @@ data class AudioTrackPublishOptions( | @@ -1763,6 +1765,7 @@ data class AudioTrackPublishOptions( | ||
| 1763 | override val red: Boolean = true, | 1765 | override val red: Boolean = true, |
| 1764 | override val source: Track.Source? = null, | 1766 | override val source: Track.Source? = null, |
| 1765 | override val stream: String? = null, | 1767 | override val stream: String? = null, |
| 1768 | + val preconnect: Boolean = false, | ||
| 1766 | ) : BaseAudioTrackPublishOptions(), TrackPublishOptions { | 1769 | ) : BaseAudioTrackPublishOptions(), TrackPublishOptions { |
| 1767 | constructor( | 1770 | constructor( |
| 1768 | name: String? = null, | 1771 | name: String? = null, |
| @@ -1777,6 +1780,17 @@ data class AudioTrackPublishOptions( | @@ -1777,6 +1780,17 @@ data class AudioTrackPublishOptions( | ||
| 1777 | source = source, | 1780 | source = source, |
| 1778 | stream = stream, | 1781 | stream = stream, |
| 1779 | ) | 1782 | ) |
| 1783 | + | ||
| 1784 | + internal fun getFeaturesList(): Set<AudioTrackFeature> { | ||
| 1785 | + val features = mutableSetOf<AudioTrackFeature>() | ||
| 1786 | + if (!dtx) { | ||
| 1787 | + features.add(AudioTrackFeature.TF_NO_DTX) | ||
| 1788 | + } | ||
| 1789 | + if (preconnect) { | ||
| 1790 | + features.add(AudioTrackFeature.TF_PRECONNECT_BUFFER) | ||
| 1791 | + } | ||
| 1792 | + return features | ||
| 1793 | + } | ||
| 1780 | } | 1794 | } |
| 1781 | 1795 | ||
| 1782 | data class ParticipantTrackPermission( | 1796 | data class ParticipantTrackPermission( |
| @@ -93,6 +93,27 @@ open class Participant( | @@ -93,6 +93,27 @@ open class Participant( | ||
| 93 | @VisibleForTesting set | 93 | @VisibleForTesting set |
| 94 | 94 | ||
| 95 | /** | 95 | /** |
| 96 | + * The participant state. | ||
| 97 | + * | ||
| 98 | + * Changes can be observed by using [io.livekit.android.util.flow] | ||
| 99 | + */ | ||
| 100 | + @FlowObservable | ||
| 101 | + @get:FlowObservable | ||
| 102 | + var state: State by flowDelegate(State.UNKNOWN) { newState, oldState -> | ||
| 103 | + if (newState != oldState) { | ||
| 104 | + eventBus.postEvent( | ||
| 105 | + ParticipantEvent.StateChanged( | ||
| 106 | + participant = this, | ||
| 107 | + newState = newState, | ||
| 108 | + oldState = oldState, | ||
| 109 | + ), | ||
| 110 | + scope, | ||
| 111 | + ) | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + @VisibleForTesting set | ||
| 115 | + | ||
| 116 | + /** | ||
| 96 | * Changes can be observed by using [io.livekit.android.util.flow] | 117 | * Changes can be observed by using [io.livekit.android.util.flow] |
| 97 | */ | 118 | */ |
| 98 | @FlowObservable | 119 | @FlowObservable |
| @@ -377,6 +398,7 @@ open class Participant( | @@ -377,6 +398,7 @@ open class Participant( | ||
| 377 | permissions = ParticipantPermission.fromProto(info.permission) | 398 | permissions = ParticipantPermission.fromProto(info.permission) |
| 378 | } | 399 | } |
| 379 | attributes = info.attributesMap | 400 | attributes = info.attributesMap |
| 401 | + state = State.fromProto(info.state) | ||
| 380 | } | 402 | } |
| 381 | 403 | ||
| 382 | override fun equals(other: Any?): Boolean { | 404 | override fun equals(other: Any?): Boolean { |
| @@ -474,6 +496,34 @@ open class Participant( | @@ -474,6 +496,34 @@ open class Participant( | ||
| 474 | } | 496 | } |
| 475 | } | 497 | } |
| 476 | } | 498 | } |
| 499 | + | ||
| 500 | + enum class State { | ||
| 501 | + // websocket' connected, but not offered yet | ||
| 502 | + JOINING, | ||
| 503 | + | ||
| 504 | + // server received client offer | ||
| 505 | + JOINED, | ||
| 506 | + | ||
| 507 | + // ICE connectivity established | ||
| 508 | + ACTIVE, | ||
| 509 | + | ||
| 510 | + // WS disconnected | ||
| 511 | + DISCONNECTED, | ||
| 512 | + | ||
| 513 | + UNKNOWN; | ||
| 514 | + | ||
| 515 | + companion object { | ||
| 516 | + fun fromProto(proto: LivekitModels.ParticipantInfo.State): State { | ||
| 517 | + return when (proto) { | ||
| 518 | + LivekitModels.ParticipantInfo.State.JOINING -> JOINING | ||
| 519 | + LivekitModels.ParticipantInfo.State.JOINED -> JOINED | ||
| 520 | + LivekitModels.ParticipantInfo.State.ACTIVE -> ACTIVE | ||
| 521 | + LivekitModels.ParticipantInfo.State.DISCONNECTED -> DISCONNECTED | ||
| 522 | + LivekitModels.ParticipantInfo.State.UNRECOGNIZED -> UNKNOWN | ||
| 523 | + } | ||
| 524 | + } | ||
| 525 | + } | ||
| 526 | + } | ||
| 477 | } | 527 | } |
| 478 | 528 | ||
| 479 | /** | 529 | /** |
| 1 | +/* | ||
| 2 | + * Copyright 2025 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.test.mock.room.datastream.outgoing | ||
| 18 | + | ||
| 19 | +import io.livekit.android.room.datastream.outgoing.DataChunker | ||
| 20 | +import io.livekit.android.room.datastream.outgoing.StreamDestination | ||
| 21 | + | ||
| 22 | +class MockStreamDestination<T>(val chunkSize: Int) : StreamDestination<T> { | ||
| 23 | + override var isOpen: Boolean = true | ||
| 24 | + | ||
| 25 | + val writtenChunks = mutableListOf<ByteArray>() | ||
| 26 | + override suspend fun write(data: T, chunker: DataChunker<T>) { | ||
| 27 | + val chunks = chunker.invoke(data, chunkSize) | ||
| 28 | + | ||
| 29 | + for (chunk in chunks) { | ||
| 30 | + writtenChunks.add(chunk) | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + override suspend fun close(reason: String?) { | ||
| 35 | + isOpen = false | ||
| 36 | + } | ||
| 37 | +} |
| @@ -14,10 +14,10 @@ | @@ -14,10 +14,10 @@ | ||
| 14 | * limitations under the License. | 14 | * limitations under the License. |
| 15 | */ | 15 | */ |
| 16 | 16 | ||
| 17 | -package io.livekit.android.room | 17 | +package io.livekit.android.room.datastream |
| 18 | 18 | ||
| 19 | import com.google.protobuf.ByteString | 19 | import com.google.protobuf.ByteString |
| 20 | -import io.livekit.android.room.datastream.StreamException | 20 | +import io.livekit.android.room.RTCEngine |
| 21 | import io.livekit.android.test.MockE2ETest | 21 | import io.livekit.android.test.MockE2ETest |
| 22 | import io.livekit.android.test.assert.assertIsClass | 22 | import io.livekit.android.test.assert.assertIsClass |
| 23 | import io.livekit.android.test.mock.MockDataChannel | 23 | import io.livekit.android.test.mock.MockDataChannel |
| @@ -38,7 +38,7 @@ import org.junit.Test | @@ -38,7 +38,7 @@ import org.junit.Test | ||
| 38 | import java.nio.ByteBuffer | 38 | import java.nio.ByteBuffer |
| 39 | 39 | ||
| 40 | @OptIn(ExperimentalCoroutinesApi::class) | 40 | @OptIn(ExperimentalCoroutinesApi::class) |
| 41 | -class RoomDataStreamMockE2ETest : MockE2ETest() { | 41 | +class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { |
| 42 | @Test | 42 | @Test |
| 43 | fun dataStream() = runTest { | 43 | fun dataStream() = runTest { |
| 44 | connect() | 44 | connect() |
| 1 | +/* | ||
| 2 | + * Copyright 2023-2025 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.room.datastream | ||
| 18 | + | ||
| 19 | +import com.google.protobuf.ByteString | ||
| 20 | +import io.livekit.android.room.RTCEngine | ||
| 21 | +import io.livekit.android.room.participant.Participant | ||
| 22 | +import io.livekit.android.test.MockE2ETest | ||
| 23 | +import io.livekit.android.test.mock.MockDataChannel | ||
| 24 | +import io.livekit.android.test.mock.MockPeerConnection | ||
| 25 | +import io.livekit.android.test.mock.TestData | ||
| 26 | +import io.livekit.android.util.toOkioByteString | ||
| 27 | +import kotlinx.coroutines.ExperimentalCoroutinesApi | ||
| 28 | +import kotlinx.coroutines.launch | ||
| 29 | +import livekit.LivekitModels | ||
| 30 | +import livekit.LivekitRtc | ||
| 31 | +import org.junit.Assert.assertEquals | ||
| 32 | +import org.junit.Assert.assertTrue | ||
| 33 | +import org.junit.Test | ||
| 34 | + | ||
| 35 | +@OptIn(ExperimentalCoroutinesApi::class) | ||
| 36 | +class RoomOutgoingDataStreamMockE2ETest : MockE2ETest() { | ||
| 37 | + | ||
| 38 | + private lateinit var pubDataChannel: MockDataChannel | ||
| 39 | + | ||
| 40 | + override suspend fun connect(joinResponse: LivekitRtc.SignalResponse) { | ||
| 41 | + super.connect(joinResponse) | ||
| 42 | + | ||
| 43 | + val pubPeerConnection = component.rtcEngine().getPublisherPeerConnection() as MockPeerConnection | ||
| 44 | + pubDataChannel = pubPeerConnection.dataChannels[RTCEngine.RELIABLE_DATA_CHANNEL_LABEL] as MockDataChannel | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + @Test | ||
| 48 | + fun dataStream() = runTest { | ||
| 49 | + connect() | ||
| 50 | + | ||
| 51 | + // Remote participant to send data to | ||
| 52 | + wsFactory.listener.onMessage( | ||
| 53 | + wsFactory.ws, | ||
| 54 | + TestData.PARTICIPANT_JOIN.toOkioByteString(), | ||
| 55 | + ) | ||
| 56 | + | ||
| 57 | + val bytesToStream = ByteArray(100) | ||
| 58 | + for (i in bytesToStream.indices) { | ||
| 59 | + bytesToStream[i] = i.toByte() | ||
| 60 | + } | ||
| 61 | + val job = launch { | ||
| 62 | + val sender = room.localParticipant.streamBytes( | ||
| 63 | + StreamBytesOptions( | ||
| 64 | + topic = "topic", | ||
| 65 | + attributes = mapOf("hello" to "world"), | ||
| 66 | + streamId = "stream_id", | ||
| 67 | + destinationIdentities = listOf(Participant.Identity(TestData.REMOTE_PARTICIPANT.identity)), | ||
| 68 | + name = "stream_name", | ||
| 69 | + totalSize = bytesToStream.size.toLong(), | ||
| 70 | + ), | ||
| 71 | + ) | ||
| 72 | + sender.write(bytesToStream) | ||
| 73 | + sender.close() | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + job.join() | ||
| 77 | + | ||
| 78 | + val buffers = pubDataChannel.sentBuffers | ||
| 79 | + | ||
| 80 | + println(buffers) | ||
| 81 | + assertEquals(3, buffers.size) | ||
| 82 | + | ||
| 83 | + val headerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[0].data)) | ||
| 84 | + assertTrue(headerPacket.hasStreamHeader()) | ||
| 85 | + | ||
| 86 | + with(headerPacket.streamHeader) { | ||
| 87 | + assertTrue(hasByteHeader()) | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + val payloadPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[1].data)) | ||
| 91 | + assertTrue(payloadPacket.hasStreamChunk()) | ||
| 92 | + with(payloadPacket.streamChunk) { | ||
| 93 | + assertEquals(100, content.size()) | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + val trailerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[2].data)) | ||
| 97 | + assertTrue(trailerPacket.hasStreamTrailer()) | ||
| 98 | + with(trailerPacket.streamTrailer) { | ||
| 99 | + assertTrue(reason.isNullOrEmpty()) | ||
| 100 | + } | ||
| 101 | + } | ||
| 102 | +} |
| 1 | +/* | ||
| 2 | + * Copyright 2025 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.room.datastream.outgoing | ||
| 18 | + | ||
| 19 | +import io.livekit.android.room.datastream.ByteStreamInfo | ||
| 20 | +import io.livekit.android.test.BaseTest | ||
| 21 | +import io.livekit.android.test.mock.room.datastream.outgoing.MockStreamDestination | ||
| 22 | +import kotlinx.coroutines.launch | ||
| 23 | +import org.junit.Assert.assertEquals | ||
| 24 | +import org.junit.Assert.assertFalse | ||
| 25 | +import org.junit.Test | ||
| 26 | +import kotlin.math.roundToInt | ||
| 27 | + | ||
| 28 | +class ByteStreamSenderTest : BaseTest() { | ||
| 29 | + | ||
| 30 | + companion object { | ||
| 31 | + val CHUNK_SIZE = 2048 | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @Test | ||
| 35 | + fun sendsSmallBytes() = runTest { | ||
| 36 | + val destination = MockStreamDestination<ByteArray>(CHUNK_SIZE) | ||
| 37 | + val sender = ByteStreamSender( | ||
| 38 | + info = createInfo(), | ||
| 39 | + destination = destination, | ||
| 40 | + ) | ||
| 41 | + | ||
| 42 | + val job = launch { | ||
| 43 | + sender.write(ByteArray(100)) | ||
| 44 | + sender.close() | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + job.join() | ||
| 48 | + | ||
| 49 | + assertFalse(destination.isOpen) | ||
| 50 | + assertEquals(1, destination.writtenChunks.size) | ||
| 51 | + assertEquals(100, destination.writtenChunks[0].size) | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + fun sendsLargeBytes() = runTest { | ||
| 56 | + val destination = MockStreamDestination<ByteArray>(CHUNK_SIZE) | ||
| 57 | + val sender = ByteStreamSender( | ||
| 58 | + info = createInfo(), | ||
| 59 | + destination = destination, | ||
| 60 | + ) | ||
| 61 | + | ||
| 62 | + val bytes = ByteArray((CHUNK_SIZE * 1.5).roundToInt()) | ||
| 63 | + | ||
| 64 | + val job = launch { | ||
| 65 | + sender.write(bytes) | ||
| 66 | + sender.close() | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + job.join() | ||
| 70 | + | ||
| 71 | + assertFalse(destination.isOpen) | ||
| 72 | + assertEquals(2, destination.writtenChunks.size) | ||
| 73 | + assertEquals(CHUNK_SIZE, destination.writtenChunks[0].size) | ||
| 74 | + assertEquals(bytes.size - CHUNK_SIZE, destination.writtenChunks[1].size) | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + fun createInfo(): ByteStreamInfo = ByteStreamInfo(id = "stream_id", topic = "topic", timestampMs = 0, totalSize = null, attributes = mapOf(), mimeType = "", name = null) | ||
| 78 | +} |
| 1 | +/* | ||
| 2 | + * Copyright 2025 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.room.datastream.outgoing | ||
| 18 | + | ||
| 19 | +import io.livekit.android.room.datastream.TextStreamInfo | ||
| 20 | +import io.livekit.android.test.BaseTest | ||
| 21 | +import io.livekit.android.test.mock.room.datastream.outgoing.MockStreamDestination | ||
| 22 | +import kotlinx.coroutines.launch | ||
| 23 | +import org.junit.Assert.assertEquals | ||
| 24 | +import org.junit.Assert.assertFalse | ||
| 25 | +import org.junit.Assert.assertNotEquals | ||
| 26 | +import org.junit.Test | ||
| 27 | + | ||
| 28 | +class TextStreamSenderTest : BaseTest() { | ||
| 29 | + | ||
| 30 | + companion object { | ||
| 31 | + val CHUNK_SIZE = 20 | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @Test | ||
| 35 | + fun sendsSingle() = runTest { | ||
| 36 | + val destination = MockStreamDestination<String>(CHUNK_SIZE) | ||
| 37 | + val sender = TextStreamSender( | ||
| 38 | + info = createInfo(), | ||
| 39 | + destination = destination, | ||
| 40 | + ) | ||
| 41 | + | ||
| 42 | + val text = "abcdefghi" | ||
| 43 | + val job = launch { | ||
| 44 | + sender.write(text) | ||
| 45 | + sender.close() | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + job.join() | ||
| 49 | + | ||
| 50 | + assertFalse(destination.isOpen) | ||
| 51 | + assertEquals(1, destination.writtenChunks.size) | ||
| 52 | + assertEquals(text, destination.writtenChunks[0].decodeToString()) | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @Test | ||
| 56 | + fun sendsChunks() = runTest { | ||
| 57 | + val destination = MockStreamDestination<String>(CHUNK_SIZE) | ||
| 58 | + val sender = TextStreamSender( | ||
| 59 | + info = createInfo(), | ||
| 60 | + destination = destination, | ||
| 61 | + ) | ||
| 62 | + | ||
| 63 | + val text = with(StringBuilder()) { | ||
| 64 | + for (i in 1..CHUNK_SIZE) { | ||
| 65 | + append("abcdefghi") | ||
| 66 | + } | ||
| 67 | + toString() | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + val job = launch { | ||
| 71 | + sender.write(text) | ||
| 72 | + sender.close() | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + job.join() | ||
| 76 | + | ||
| 77 | + assertFalse(destination.isOpen) | ||
| 78 | + assertNotEquals(1, destination.writtenChunks.size) | ||
| 79 | + | ||
| 80 | + val writtenString = with(StringBuilder()) { | ||
| 81 | + for (chunk in destination.writtenChunks) { | ||
| 82 | + append(chunk.decodeToString()) | ||
| 83 | + } | ||
| 84 | + toString() | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + assertEquals(text, writtenString) | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + fun createInfo(): TextStreamInfo = TextStreamInfo( | ||
| 91 | + id = "stream_id", | ||
| 92 | + topic = "topic", | ||
| 93 | + timestampMs = 0, | ||
| 94 | + totalSize = null, | ||
| 95 | + attributes = mapOf(), | ||
| 96 | + operationType = TextStreamInfo.OperationType.CREATE, | ||
| 97 | + version = 0, | ||
| 98 | + replyToStreamId = null, | ||
| 99 | + attachedStreamIds = listOf(), | ||
| 100 | + generated = false, | ||
| 101 | + ) | ||
| 102 | +} |
| 1 | /* | 1 | /* |
| 2 | - * Copyright 2023-2024 LiveKit, Inc. | 2 | + * Copyright 2023-2025 LiveKit, Inc. |
| 3 | * | 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); | 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. | 5 | * you may not use this file except in compliance with the License. |
| @@ -58,7 +58,7 @@ class ParticipantTest { | @@ -58,7 +58,7 @@ class ParticipantTest { | ||
| 58 | assertEquals(INFO.name, participant.name) | 58 | assertEquals(INFO.name, participant.name) |
| 59 | assertEquals(Participant.Kind.fromProto(INFO.kind), participant.kind) | 59 | assertEquals(Participant.Kind.fromProto(INFO.kind), participant.kind) |
| 60 | assertEquals(INFO.attributesMap, participant.attributes) | 60 | assertEquals(INFO.attributesMap, participant.attributes) |
| 61 | - | 61 | + assertEquals(Participant.State.fromProto(INFO.state), participant.state) |
| 62 | assertEquals(INFO, participant.participantInfo) | 62 | assertEquals(INFO, participant.participantInfo) |
| 63 | } | 63 | } |
| 64 | 64 | ||
| @@ -151,6 +151,23 @@ class ParticipantTest { | @@ -151,6 +151,23 @@ class ParticipantTest { | ||
| 151 | } | 151 | } |
| 152 | 152 | ||
| 153 | @Test | 153 | @Test |
| 154 | + fun setStateChangedEvent() = runTest { | ||
| 155 | + val eventCollector = EventCollector(participant.events, coroutineRule.scope) | ||
| 156 | + participant.state = Participant.State.JOINED | ||
| 157 | + | ||
| 158 | + val events = eventCollector.stopCollecting() | ||
| 159 | + | ||
| 160 | + assertEquals(1, events.size) | ||
| 161 | + assertEquals(true, events[0] is ParticipantEvent.StateChanged) | ||
| 162 | + | ||
| 163 | + val event = events[0] as ParticipantEvent.StateChanged | ||
| 164 | + | ||
| 165 | + assertEquals(participant, event.participant) | ||
| 166 | + assertEquals(Participant.State.JOINED, event.newState) | ||
| 167 | + assertEquals(Participant.State.UNKNOWN, event.oldState) | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + @Test | ||
| 154 | fun addTrackPublication() = runTest { | 171 | fun addTrackPublication() = runTest { |
| 155 | val audioPublication = TrackPublication(TRACK_INFO, null, participant) | 172 | val audioPublication = TrackPublication(TRACK_INFO, null, participant) |
| 156 | participant.addTrackPublication(audioPublication) | 173 | participant.addTrackPublication(audioPublication) |
| @@ -197,6 +214,7 @@ class ParticipantTest { | @@ -197,6 +214,7 @@ class ParticipantTest { | ||
| 197 | .setName("name") | 214 | .setName("name") |
| 198 | .setKind(LivekitModels.ParticipantInfo.Kind.STANDARD) | 215 | .setKind(LivekitModels.ParticipantInfo.Kind.STANDARD) |
| 199 | .putAttributes("attribute", "value") | 216 | .putAttributes("attribute", "value") |
| 217 | + .setState(LivekitModels.ParticipantInfo.State.JOINED) | ||
| 200 | .build() | 218 | .build() |
| 201 | 219 | ||
| 202 | val TRACK_INFO = LivekitModels.TrackInfo.newBuilder() | 220 | val TRACK_INFO = LivekitModels.TrackInfo.newBuilder() |
-
请 注册 或 登录 后发表评论