feat: proper lk latency probing

This commit is contained in:
infi 2026-07-18 04:54:25 +02:00
parent acf83771e4
commit 847fcaa23b
1 changed files with 55 additions and 1 deletions

View File

@ -8,6 +8,7 @@ import chat.stoat.R
import chat.stoat.StoatApplication import chat.stoat.StoatApplication
import chat.stoat.api.StoatAPI import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.ChannelUtils import chat.stoat.api.internals.ChannelUtils
import chat.stoat.api.routes.misc.LiveKitNode
import chat.stoat.api.routes.misc.Root import chat.stoat.api.routes.misc.Root
import chat.stoat.api.routes.misc.getRootRoute import chat.stoat.api.routes.misc.getRootRoute
import chat.stoat.api.routes.voice.JoinCallResponse import chat.stoat.api.routes.voice.JoinCallResponse
@ -15,20 +16,30 @@ import chat.stoat.api.routes.voice.joinCall
import chat.stoat.composables.voice.VoiceSound import chat.stoat.composables.voice.VoiceSound
import chat.stoat.composables.voice.VoiceSoundPlayer import chat.stoat.composables.voice.VoiceSoundPlayer
import chat.stoat.services.OngoingCallService import chat.stoat.services.OngoingCallService
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.request.get
import io.ktor.http.isSuccess
import io.livekit.android.LiveKit import io.livekit.android.LiveKit
import io.livekit.android.events.RoomEvent import io.livekit.android.events.RoomEvent
import io.livekit.android.room.Room import io.livekit.android.room.Room
import io.livekit.android.room.track.RemoteAudioTrack import io.livekit.android.room.track.RemoteAudioTrack
import io.livekit.android.room.track.Track import io.livekit.android.room.track.Track
import io.livekit.android.util.flow import io.livekit.android.util.flow
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import logcat.LogPriority import logcat.LogPriority
import logcat.asLog import logcat.asLog
import logcat.logcat import logcat.logcat
import kotlin.time.Duration.Companion.seconds
/** /**
* Owns the lifecycle of the current voice call. * Owns the lifecycle of the current voice call.
@ -60,6 +71,8 @@ object VoiceCallManager {
isSheetVisible = true isSheetVisible = true
} }
private val probeClient by lazy { HttpClient(OkHttp) }
private var micWasOnBeforeDeafen = false private var micWasOnBeforeDeafen = false
private var soundPlayer: VoiceSoundPlayer? = null private var soundPlayer: VoiceSoundPlayer? = null
private var sessionJob: Job? = null private var sessionJob: Job? = null
@ -241,6 +254,41 @@ object VoiceCallManager {
} }
} }
private suspend fun fastestNode(nodes: List<LiveKitNode>): LiveKitNode? =
withTimeoutOrNull(5.seconds) {
try {
coroutineScope {
val winner = CompletableDeferred<LiveKitNode>()
val probes = nodes.map { node ->
launch {
try {
val probeUrl = node.publicUrl
.replaceFirst("wss://", "https://")
.replaceFirst("ws://", "http://")
if (probeClient.get(probeUrl).status.isSuccess()) {
winner.complete(node)
}
} catch (_: Exception) {
}
}
}
launch {
probes.joinAll()
winner.completeExceptionally(
IllegalStateException("No LiveKit node reachable")
)
}
try {
winner.await()
} finally {
coroutineContext.cancelChildren()
}
}
} catch (e: Exception) {
null
}
}
private suspend fun fetchVoiceToken(channelId: String): JoinCallResponse? { private suspend fun fetchVoiceToken(channelId: String): JoinCallResponse? {
val root: Root val root: Root
try { try {
@ -267,7 +315,13 @@ object VoiceCallManager {
return null return null
} }
val node = lk.nodes.random() val node = fastestNode(lk.nodes)
if (node == null) {
logcat(LogPriority.ERROR) { "No LiveKit node responded to probing!" }
errorResource = R.string.voice_error_no_nodes
return null
}
return try { return try {
joinCall(channelId, node.name) joinCall(channelId, node.name)
} catch (e: Exception) { } catch (e: Exception) {