Browse Source

fix(network): eliminate duplicate websocket connections and add dual-endpoint automatic failover

main^2
Ali Alavi 1 day ago
parent
commit
8424b45b12
  1. 91
      app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt

91
app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt

@ -44,20 +44,28 @@ class StreamDictationClient(
private val isRecording = AtomicBoolean(false) private val isRecording = AtomicBoolean(false)
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val endpoints = listOf(
host.removePrefix("http://").removePrefix("https://").removePrefix("ws://").removePrefix("wss://"),
"116.16.16.19:8999"
).distinct()
private var currentEndpointIndex = 0
private val okHttpClient = OkHttpClient.Builder() private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.connectTimeout(4, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // Persistent WebSocket .readTimeout(0, TimeUnit.MILLISECONDS) // Persistent WebSocket
.writeTimeout(15, TimeUnit.SECONDS)
.pingInterval(25, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.pingInterval(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(true) .retryOnConnectionFailure(true)
.build() .build()
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private val isConnected = AtomicBoolean(false) private val isConnected = AtomicBoolean(false)
private val isConnecting = AtomicBoolean(false)
private var currentSessionId: String = "" private var currentSessionId: String = ""
private var isSessionActive = AtomicBoolean(false) private var isSessionActive = AtomicBoolean(false)
private var lastPartialText: String = "" private var lastPartialText: String = ""
private var finalizeSafetyTimeoutRunnable: Runnable? = null private var finalizeSafetyTimeoutRunnable: Runnable? = null
private var reconnectRunnable: Runnable? = null
init { init {
connectWebSocket() connectWebSocket()
@ -65,17 +73,25 @@ class StreamDictationClient(
@Synchronized @Synchronized
fun connectWebSocket() { fun connectWebSocket() {
if (isConnected.get() && webSocket != null) return
if (isConnected.get() || isConnecting.get()) return
isConnecting.set(true)
// Cancel previous socket before new attempt
try {
webSocket?.cancel()
} catch (e: Exception) {}
webSocket = null
val cleanHost = host.removePrefix("http://").removePrefix("https://").removePrefix("ws://").removePrefix("wss://")
val wsUrl = "ws://$cleanHost/ws/stream"
val currentHost = endpoints[currentEndpointIndex % endpoints.size]
val wsUrl = "ws://$currentHost/ws/stream"
AppLogger.log(tag, "اتصال به سوکت همگام‌سازی بلادرنگ: $wsUrl") AppLogger.log(tag, "اتصال به سوکت همگام‌سازی بلادرنگ: $wsUrl")
val req = Request.Builder().url(wsUrl).build() val req = Request.Builder().url(wsUrl).build()
webSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { webSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) { override fun onOpen(ws: WebSocket, response: Response) {
AppLogger.log(tag, "🟢 سوکت همگام‌سازی بلادرنگ متصل شد")
isConnecting.set(false)
isConnected.set(true) isConnected.set(true)
AppLogger.log(tag, "🟢 سوکت همگام‌سازی بلادرنگ متصل شد ($currentHost)")
mainHandler.post { onConnectionStateChanged(true) } mainHandler.post { onConnectionStateChanged(true) }
} }
@ -136,9 +152,13 @@ class StreamDictationClient(
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
AppLogger.log(tag, "🔴 قطع سوکت: ${t.message}. تلاش مجدد...") AppLogger.log(tag, "🔴 قطع سوکت: ${t.message}. تلاش مجدد...")
isConnecting.set(false)
isConnected.set(false) isConnected.set(false)
webSocket = null webSocket = null
// Cycle endpoint on failure
currentEndpointIndex = (currentEndpointIndex + 1) % endpoints.size
// If a speech session was in-flight, salvage the latest live transcript // If a speech session was in-flight, salvage the latest live transcript
if (isSessionActive.get() && lastPartialText.isNotEmpty()) { if (isSessionActive.get() && lastPartialText.isNotEmpty()) {
isSessionActive.set(false) isSessionActive.set(false)
@ -148,24 +168,33 @@ class StreamDictationClient(
} }
mainHandler.post { onConnectionStateChanged(false) } mainHandler.post { onConnectionStateChanged(false) }
mainHandler.postDelayed({ connectWebSocket() }, 2000)
scheduleReconnect(1500)
} }
override fun onClosed(ws: WebSocket, code: Int, reason: String) { override fun onClosed(ws: WebSocket, code: Int, reason: String) {
AppLogger.log(tag, "سوکت بسته شد ($code). تلاش مجدد...") AppLogger.log(tag, "سوکت بسته شد ($code). تلاش مجدد...")
isConnecting.set(false)
isConnected.set(false) isConnected.set(false)
webSocket = null webSocket = null
mainHandler.post { onConnectionStateChanged(false) } mainHandler.post { onConnectionStateChanged(false) }
mainHandler.postDelayed({ connectWebSocket() }, 2000)
scheduleReconnect(1500)
} }
}) })
} }
private fun scheduleReconnect(delayMs: Long) {
reconnectRunnable?.let { mainHandler.removeCallbacks(it) }
reconnectRunnable = Runnable {
connectWebSocket()
}
mainHandler.postDelayed(reconnectRunnable!!, delayMs)
}
/** /**
* Broadcasts phone's updated text & cursor to Mac in sub-15ms * Broadcasts phone's updated text & cursor to Mac in sub-15ms
*/ */
fun sendLocalSyncState(text: String, cursor: Int, selection: Int = 0) { fun sendLocalSyncState(text: String, cursor: Int, selection: Int = 0) {
if (!isConnected.get() || webSocket == null) {
if (!isConnected.get() && !isConnecting.get()) {
connectWebSocket() connectWebSocket()
} }
val payload = JSONObject().apply { val payload = JSONObject().apply {
@ -187,37 +216,34 @@ class StreamDictationClient(
if (isRecording.get()) { if (isRecording.get()) {
isRecording.set(false) isRecording.set(false)
try { try {
audioRecord?.stop()
audioRecord?.release()
audioRecord = null
recordingThread?.join(150)
recordingThread = null
recordingThread?.join(200)
} catch (e: Exception) {} } catch (e: Exception) {}
} }
currentSessionId = "sess_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(6)}" currentSessionId = "sess_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(6)}"
lastPartialText = ""
isRecording.set(true)
isSessionActive.set(true) isSessionActive.set(true)
lastPartialText = ""
if (!isConnected.get() || webSocket == null) {
if (!isConnected.get()) {
connectWebSocket() connectWebSocket()
} }
// Send start control frame
val startFrame = JSONObject().apply { val startFrame = JSONObject().apply {
put("type", "start") put("type", "start")
put("session_id", currentSessionId) put("session_id", currentSessionId)
put("cursor_pos", cursorPos) put("cursor_pos", cursorPos)
}.toString() }.toString()
webSocket?.send(startFrame) webSocket?.send(startFrame)
AppLogger.log(tag, "🎙️ شروع استریم گفتار در موقعیت نشانگر $cursorPos...")
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
val bufferSize = maxOf(minBufferSize, 2048)
AppLogger.log(tag, "🎙️ شروع استریم گفتار در موقعیت نشانگر $cursorPos...")
try { try {
val minBufSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
val bufferSize = maxOf(minBufSize * 2, 4096)
audioRecord = AudioRecord( audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
MediaRecorder.AudioSource.VOICE_RECOGNITION,
sampleRate, sampleRate,
channelConfig, channelConfig,
audioFormat, audioFormat,
@ -226,17 +252,17 @@ class StreamDictationClient(
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
AppLogger.log(tag, "❌ سخت‌افزار میکروفون راه‌اندازی نشد") AppLogger.log(tag, "❌ سخت‌افزار میکروفون راه‌اندازی نشد")
onError("خطا در راه‌اندازی سخت‌افزار میکروفون")
isRecording.set(false)
onError("خطای سخت‌افزار میکروفون")
isSessionActive.set(false) isSessionActive.set(false)
return currentSessionId return currentSessionId
} }
audioRecord?.startRecording() audioRecord?.startRecording()
isRecording.set(true)
recordingThread = Thread { recordingThread = Thread {
val chunk = ByteArray(2048)
val shortBuffer = ShortArray(1024)
val chunk = ByteArray(1024)
val shortBuffer = ShortArray(512)
while (isRecording.get()) { while (isRecording.get()) {
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1 val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1
@ -330,11 +356,20 @@ class StreamDictationClient(
} }
fun release() { fun release() {
isRecording.set(false)
reconnectRunnable?.let { mainHandler.removeCallbacks(it) }
finalizeSafetyTimeoutRunnable?.let { mainHandler.removeCallbacks(it) } finalizeSafetyTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
isRecording.set(false)
try { try {
audioRecord?.stop()
audioRecord?.release() audioRecord?.release()
webSocket?.close(1000, "Client Shutdown")
audioRecord = null
} catch (e: Exception) {} } catch (e: Exception) {}
try {
webSocket?.close(1000, "Client released")
webSocket?.cancel()
} catch (e: Exception) {}
webSocket = null
isConnected.set(false)
isConnecting.set(false)
} }
} }
Loading…
Cancel
Save