Skip to content

Commit 4bf585e

Browse files
authored
Session API changes (#802)
* Expose server version * expose semver * agent type helpers * Allow for multiple listeners on audio switch handler * Fix crash when cleaning up local participant * Deprecate Room.withPreconnectAudio * Change TokenSource.fetch methods to return Result<TokenSourceResponse> to explicitly handle exceptions * add test utilities * Fix transcription conversion error * spotless
1 parent 1fed518 commit 4bf585e

28 files changed

+535
-62
lines changed

.changeset/brown-cheetahs-argue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": minor
3+
---
4+
5+
Change TokenSource.fetch methods to return Result<TokenSourceResponse> to explicitly handle exceptions

.changeset/cold-days-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": minor
3+
---
4+
5+
Add support for multiple listeners on AudioSwitchHandler

.changeset/cool-meals-fetch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": minor
3+
---
4+
5+
Rename AgentState to AgentSdkState

.changeset/curvy-otters-move.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"client-sdk-android": minor
3+
---
4+
5+
Deprecate Room.withPreconnectAudio method.
6+
7+
- Set AudioTrackPublishDefaults.preconnect = true on the RoomOptions instead to use the preconnect buffer.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": patch
3+
---
4+
5+
Fix crash when cleaning up local participant

.changeset/spicy-planes-sip.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": minor
3+
---
4+
5+
Expose agentAttributes as a value on Participant

.changeset/spotty-fishes-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"client-sdk-android": minor
3+
---
4+
5+
Expose the server info of the currently connected server on Room

livekit-android-sdk/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ dependencies {
161161
kapt libs.dagger.compiler
162162

163163
implementation libs.timber
164-
implementation libs.semver4j
164+
api libs.semver4j
165165

166166
lintChecks project(':livekit-lint')
167167
lintPublish project(':livekit-lint')

livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.twilio.audioswitch.AudioSwitch
3030
import com.twilio.audioswitch.LegacyAudioSwitch
3131
import io.livekit.android.room.Room
3232
import io.livekit.android.util.LKLog
33+
import java.util.Collections
3334
import javax.inject.Inject
3435
import javax.inject.Singleton
3536

@@ -54,15 +55,47 @@ constructor(private val context: Context) : AudioHandler {
5455
*
5556
* @see AudioDeviceChangeListener
5657
*/
58+
@Deprecated("Use registerAudioDeviceChangeListener.")
5759
var audioDeviceChangeListener: AudioDeviceChangeListener? = null
5860

61+
private val audioDeviceChangeListeners = Collections.synchronizedSet(mutableSetOf<AudioDeviceChangeListener>())
62+
63+
private val audioDeviceChangeDispatcher by lazy(LazyThreadSafetyMode.NONE) {
64+
object : AudioDeviceChangeListener {
65+
override fun invoke(audioDevices: List<AudioDevice>, selectedAudioDevice: AudioDevice?) {
66+
@Suppress("DEPRECATION")
67+
audioDeviceChangeListener?.invoke(audioDevices, selectedAudioDevice)
68+
synchronized(audioDeviceChangeListeners) {
69+
for (listener in audioDeviceChangeListeners) {
70+
listener.invoke(audioDevices, selectedAudioDevice)
71+
}
72+
}
73+
}
74+
}
75+
}
76+
5977
/**
6078
* Listen to changes in audio focus.
6179
*
6280
* @see AudioManager.OnAudioFocusChangeListener
6381
*/
82+
@Deprecated("Use registerOnAudioFocusChangeListener.")
6483
var onAudioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
6584

85+
private val onAudioFocusChangeListeners = Collections.synchronizedSet(mutableSetOf<AudioManager.OnAudioFocusChangeListener>())
86+
87+
private val onAudioFocusChangeDispatcher by lazy(LazyThreadSafetyMode.NONE) {
88+
AudioManager.OnAudioFocusChangeListener { focusChange ->
89+
@Suppress("DEPRECATION")
90+
onAudioFocusChangeListener?.onAudioFocusChange(focusChange)
91+
synchronized(onAudioFocusChangeListeners) {
92+
for (listener in onAudioFocusChangeListeners) {
93+
listener.onAudioFocusChange(focusChange)
94+
}
95+
}
96+
}
97+
}
98+
6699
/**
67100
* The preferred priority of audio devices to use. The first available audio device will be used.
68101
*
@@ -170,14 +203,14 @@ constructor(private val context: Context) : AudioHandler {
170203
AudioSwitch(
171204
context = context,
172205
loggingEnabled = loggingEnabled,
173-
audioFocusChangeListener = onAudioFocusChangeListener ?: defaultOnAudioFocusChangeListener,
206+
audioFocusChangeListener = onAudioFocusChangeDispatcher,
174207
preferredDeviceList = preferredDeviceList ?: defaultPreferredDeviceList,
175208
)
176209
} else {
177210
LegacyAudioSwitch(
178211
context = context,
179212
loggingEnabled = loggingEnabled,
180-
audioFocusChangeListener = onAudioFocusChangeListener ?: defaultOnAudioFocusChangeListener,
213+
audioFocusChangeListener = onAudioFocusChangeDispatcher,
181214
preferredDeviceList = preferredDeviceList ?: defaultPreferredDeviceList,
182215
)
183216
}
@@ -190,7 +223,7 @@ constructor(private val context: Context) : AudioHandler {
190223
switch.forceHandleAudioRouting = forceHandleAudioRouting
191224

192225
audioSwitch = switch
193-
switch.start(audioDeviceChangeListener ?: defaultAudioDeviceChangeListener)
226+
switch.start(audioDeviceChangeDispatcher)
194227
switch.activate()
195228
}
196229
}
@@ -235,16 +268,43 @@ constructor(private val context: Context) : AudioHandler {
235268
}
236269
}
237270

271+
/**
272+
* Listen to changes in the available and active audio devices.
273+
* @see unregisterAudioDeviceChangeListener
274+
*/
275+
fun registerAudioDeviceChangeListener(listener: AudioDeviceChangeListener) {
276+
audioDeviceChangeListeners.add(listener)
277+
}
278+
279+
/**
280+
* Remove a previously registered audio device change listener.
281+
* @see registerAudioDeviceChangeListener
282+
*/
283+
fun unregisterAudioDeviceChangeListener(listener: AudioDeviceChangeListener) {
284+
audioDeviceChangeListeners.remove(listener)
285+
}
286+
287+
/**
288+
* Listen to changes in audio focus.
289+
*
290+
* @see AudioManager.OnAudioFocusChangeListener
291+
* @see unregisterOnAudioFocusChangeListener
292+
*/
293+
fun registerOnAudioFocusChangeListener(listener: AudioManager.OnAudioFocusChangeListener) {
294+
onAudioFocusChangeListeners.add(listener)
295+
}
296+
297+
/**
298+
* Remove a previously registered focus change listener.
299+
*
300+
* @see AudioManager.OnAudioFocusChangeListener
301+
* @see registerOnAudioFocusChangeListener
302+
*/
303+
fun unregisterOnAudioFocusChangeListener(listener: AudioManager.OnAudioFocusChangeListener) {
304+
onAudioFocusChangeListeners.remove(listener)
305+
}
306+
238307
companion object {
239-
private val defaultOnAudioFocusChangeListener by lazy(LazyThreadSafetyMode.NONE) {
240-
AudioManager.OnAudioFocusChangeListener { }
241-
}
242-
private val defaultAudioDeviceChangeListener by lazy(LazyThreadSafetyMode.NONE) {
243-
object : AudioDeviceChangeListener {
244-
override fun invoke(audioDevices: List<AudioDevice>, selectedAudioDevice: AudioDevice?) {
245-
}
246-
}
247-
}
248308
private val defaultPreferredDeviceList by lazy(LazyThreadSafetyMode.NONE) {
249309
listOf(
250310
AudioDevice.BluetoothHeadset::class.java,

livekit-android-sdk/src/main/java/io/livekit/android/audio/PreconnectAudioBuffer.kt

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@ import io.livekit.android.room.datastream.StreamBytesOptions
2727
import io.livekit.android.room.participant.Participant
2828
import io.livekit.android.util.LKLog
2929
import io.livekit.android.util.flow
30+
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.Job
3032
import kotlinx.coroutines.cancel
3133
import kotlinx.coroutines.coroutineScope
3234
import kotlinx.coroutines.delay
35+
import kotlinx.coroutines.ensureActive
3336
import kotlinx.coroutines.flow.collect
3437
import kotlinx.coroutines.flow.takeWhile
3538
import kotlinx.coroutines.launch
@@ -195,8 +198,8 @@ internal constructor(timeout: Duration) : AudioTrackSink {
195198
* use with LiveKit Agents.
196199
* @param onError The error handler to call when an error occurs while sending the audio buffer.
197200
* @param operation The connection lambda to call with the pre-connect audio.
198-
*
199201
*/
202+
@Deprecated("Set AudioTrackPublishDefaults.preconnect = true on the RoomOptions instead.")
200203
suspend fun <T> Room.withPreconnectAudio(
201204
timeout: Duration = TIMEOUT,
202205
topic: String = DEFAULT_TOPIC,
@@ -298,3 +301,104 @@ suspend fun <T> Room.withPreconnectAudio(
298301

299302
return@coroutineScope retValue
300303
}
304+
305+
internal suspend fun Room.startPreconnectAudioJob(
306+
roomScope: CoroutineScope,
307+
timeout: Duration = TIMEOUT,
308+
topic: String = DEFAULT_TOPIC
309+
): () -> Unit {
310+
isPrerecording = true
311+
val audioTrack = localParticipant.getOrCreateDefaultAudioTrack()
312+
val preconnectAudioBuffer = PreconnectAudioBuffer(timeout)
313+
314+
LKLog.v { "Starting preconnect audio buffer" }
315+
preconnectAudioBuffer.startRecording()
316+
audioTrack.addSink(preconnectAudioBuffer)
317+
audioTrack.prewarm()
318+
319+
val jobs = mutableListOf<Job>()
320+
fun stopRecording() {
321+
if (!isPrerecording) {
322+
return
323+
}
324+
325+
LKLog.v { "Stopping preconnect audio buffer" }
326+
audioTrack.removeSink(preconnectAudioBuffer)
327+
preconnectAudioBuffer.stopRecording()
328+
isPrerecording = false
329+
}
330+
331+
// Clear the preconnect audio buffer after the timeout to free memory.
332+
roomScope.launch {
333+
delay(TIMEOUT)
334+
preconnectAudioBuffer.clear()
335+
}
336+
337+
val sentIdentities = mutableSetOf<Participant.Identity>()
338+
roomScope.launch {
339+
suspend fun handleSendIfNeeded(participant: Participant) {
340+
coroutineScope inner@{
341+
engine::connectionState.flow
342+
.takeWhile { it != ConnectionState.CONNECTED }
343+
.collect()
344+
345+
ensureActive()
346+
val kind = participant.kind
347+
val state = participant.state
348+
val identity = participant.identity
349+
if (sentIdentities.contains(identity) || kind != Participant.Kind.AGENT || state != Participant.State.ACTIVE || identity == null) {
350+
return@inner
351+
}
352+
353+
stopRecording()
354+
launch {
355+
try {
356+
preconnectAudioBuffer.sendAudioData(
357+
room = this@startPreconnectAudioJob,
358+
trackSid = audioTrack.sid,
359+
agentIdentities = listOf(identity),
360+
topic = topic,
361+
)
362+
sentIdentities.add(identity)
363+
} catch (e: Exception) {
364+
LKLog.w(e) { "Error occurred while sending the audio preconnect data." }
365+
}
366+
}
367+
}
368+
}
369+
370+
events.collect { event ->
371+
when (event) {
372+
is RoomEvent.LocalTrackSubscribed -> {
373+
LKLog.i { "Local audio track has been subscribed to, stopping preconnect audio recording." }
374+
stopRecording()
375+
}
376+
377+
is RoomEvent.ParticipantConnected -> {
378+
// agents may connect with ACTIVE state and not trigger a participant state changed.
379+
handleSendIfNeeded(event.participant)
380+
}
381+
382+
is RoomEvent.ParticipantStateChanged -> {
383+
handleSendIfNeeded(event.participant)
384+
}
385+
386+
is RoomEvent.Disconnected -> {
387+
cancel()
388+
}
389+
390+
else -> {
391+
// Intentionally blank.
392+
}
393+
}
394+
}
395+
}
396+
397+
return cancelPrerecord@{
398+
if (!isPrerecording) {
399+
return@cancelPrerecord
400+
}
401+
jobs.forEach { it.cancel() }
402+
preconnectAudioBuffer.clear()
403+
}
404+
}

0 commit comments

Comments
 (0)