From fd15d992c4ebfcc96b6bc066808d20896ce616df Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Sun, 23 Aug 2026 20:00:52 +0000 Subject: [PATCH] feat: resilient mobile websocket reconnection with exponential backoff and fast 350ms speech finalize --- .../soniox/remotemic/StreamDictationClient.kt | 33 ++++++++++++++----- server/relay_server.py | 18 +++++----- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt index 7cf0ede..beec0dc 100644 --- a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt +++ b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt @@ -45,17 +45,19 @@ class StreamDictationClient( private val mainHandler = Handler(Looper.getMainLooper()) private val okHttpClient = OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) + .connectTimeout(12, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) // Persistent WebSocket - .writeTimeout(5, TimeUnit.SECONDS) - .pingInterval(10, TimeUnit.SECONDS) + .writeTimeout(8, TimeUnit.SECONDS) + .pingInterval(8, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .build() private var activeWebSocket: WebSocket? = null private val isConnected = AtomicBoolean(false) + private val isConnecting = AtomicBoolean(false) private var currentSessionId: String = "" private var isSessionActive = AtomicBoolean(false) + private var retryAttempt = 0 init { connectWebSocket() @@ -64,16 +66,23 @@ class StreamDictationClient( @Synchronized fun connectWebSocket() { if (isConnected.get() && activeWebSocket != null) return + if (isConnecting.getAndSet(true)) return val cleanHost = host.removePrefix("http://").removePrefix("https://").removePrefix("ws://").removePrefix("wss://") val wsUrl = "ws://$cleanHost/ws/stream" AppLogger.log(tag, "اتصال به سوکت همگام‌سازی بلادرنگ: $wsUrl") - val req = Request.Builder().url(wsUrl).build() + val req = Request.Builder() + .url(wsUrl) + .header("User-Agent", "SonioxAndroidRemote/5.2") + .build() + activeWebSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { AppLogger.log(tag, "🟢 سوکت همگام‌سازی بلادرنگ متصل شد") isConnected.set(true) + isConnecting.set(false) + retryAttempt = 0 mainHandler.post { onConnectionStateChanged(true) } } @@ -123,18 +132,24 @@ class StreamDictationClient( override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { AppLogger.log(tag, "🔴 قطع سوکت: ${t.message}. تلاش مجدد...") - isConnected.set(false) - activeWebSocket = null - mainHandler.post { onConnectionStateChanged(false) } - mainHandler.postDelayed({ connectWebSocket() }, 2000) + handleDisconnect() } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { AppLogger.log(tag, "سوکت بسته شد ($code). تلاش مجدد...") + handleDisconnect() + } + + private fun handleDisconnect() { isConnected.set(false) + isConnecting.set(false) activeWebSocket = null mainHandler.post { onConnectionStateChanged(false) } - mainHandler.postDelayed({ connectWebSocket() }, 2000) + + // Exponential backoff: 800ms, 1600ms, 3200ms + retryAttempt++ + val delayMs = minOf(800L * (1L shl minOf(retryAttempt, 3)), 5000L) + mainHandler.postDelayed({ connectWebSocket() }, delayMs) } }) } diff --git a/server/relay_server.py b/server/relay_server.py index e160e22..f0e8c53 100644 --- a/server/relay_server.py +++ b/server/relay_server.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """ -Soniox Collaborative Bi-Directional Input Synchronization Gateway (v5.0) +Soniox Collaborative Bi-Directional Input Synchronization Gateway (v5.2) - Real-time Google Docs / Figma style collaborative mirroring between Mac and Android. +- Ultra-low latency streaming speech recognition with pre-warmed Soniox pool. - Monotonic revision counters, echo-loop suppression, and sub-15ms WebSocket routing. -- Cursor-aware voice insertion and instant text editing. +- Process-targeted cursor-aware voice insertion. """ import asyncio @@ -59,7 +60,7 @@ def is_ws_open(ws) -> bool: class SonioxPool: """Pre-warms upstream WebSockets to Soniox for 0ms speech start delay.""" - def __init__(self, size=3): + def __init__(self, size=4): self._pool = asyncio.Queue(maxsize=size) self._refilling = False @@ -83,7 +84,7 @@ class SonioxPool: SONIOX_WS_URL, additional_headers=SONIOX_HEADERS, open_timeout=3.5, - ping_interval=20, + ping_interval=15, ) except Exception as e: logger.error("Failed to connect to upstream Soniox: %s", e) @@ -156,7 +157,7 @@ async def broadcast_state(payload_dict: dict, exclude_ws=None): async def handle_phone_stream_ws(request): """Duplex WebSocket for Android client (Real-time Live Sync + Audio Dictation).""" - ws = web.WebSocketResponse(heartbeat=12.0) + ws = web.WebSocketResponse(heartbeat=10.0, autoping=True) await ws.prepare(request) client_ip = request.remote logger.info("📱 Android Client Connected: %s", client_ip) @@ -264,7 +265,8 @@ async def handle_phone_stream_ws(request): if active_soniox_ws and is_ws_open(active_soniox_ws): await active_soniox_ws.send(json.dumps({"type": "finalize"})) try: - await asyncio.wait_for(stop_event.wait(), timeout=0.55) + # 350ms fast timeout for finalize + await asyncio.wait_for(stop_event.wait(), timeout=0.35) except asyncio.TimeoutError: pass @@ -311,7 +313,7 @@ async def handle_phone_stream_ws(request): async def handle_mac_ws(request): """Persistent WebSocket for Mac Bridge (receives live Mac typing & pushes edits).""" - ws = web.WebSocketResponse(heartbeat=10.0) + ws = web.WebSocketResponse(heartbeat=10.0, autoping=True) await ws.prepare(request) client_ip = request.remote logger.info("🖥️ Mac client connected: %s", client_ip) @@ -348,7 +350,7 @@ async def handle_health(request): state_copy = dict(current_room_state) return web.json_response({ "status": "ok", - "service": "Soniox Collaborative Sync Gateway v5.0", + "service": "Soniox Collaborative Sync Gateway v5.2", "connected_macs": len(connected_mac_websockets), "connected_phones": len(connected_phone_websockets), "current_app": state_copy.get("app", ""),