From 65ab8f68b6eb8e7c20d916e9d6ec71d5786dc84e Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Mon, 24 Aug 2026 06:16:00 +0000 Subject: [PATCH] fix(sync): append speech at cursor with trailing space, relax Starlink ping timeout, and avoid private IP loop on Starlink --- .../java/com/soniox/remotemic/MainActivity.kt | 8 +++--- .../soniox/remotemic/StreamDictationClient.kt | 27 ++++++++++++++----- mac/src/FocusedInputSync.swift | 6 +++-- server/relay_server.py | 8 ++++-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt index 77ab98a..8597d62 100644 --- a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt +++ b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt @@ -89,7 +89,7 @@ class MainActivity : AppCompatActivity() { setupUI() checkPermissions() - AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.6)") + AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.7)") // Initialize Collaborative WebSocket Client streamDictationClient = StreamDictationClient( @@ -237,7 +237,7 @@ class MainActivity : AppCompatActivity() { val formattedSpeech = buildString { if (needsPreSpace) append(" ") append(trimmedSpeech) - if (needsPostSpace) append(" ") + append(" ") // ALWAYS guarantee a trailing space after inserted speech } val mergedText = "$prefix$formattedSpeech$suffix" @@ -256,8 +256,8 @@ class MainActivity : AppCompatActivity() { AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") - // Broadcast full mirrored text to Mac - streamDictationClient?.sendPhoneEdit(mergedText, newCursor) + // Send speech directly to Mac cursor (Cmd+V append, NO Cmd+A clobber!) + streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor) } private fun setupUI() { 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 6c9c80b..a0e5cd7 100644 --- a/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt +++ b/android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt @@ -44,17 +44,14 @@ class StreamDictationClient( private val isRecording = AtomicBoolean(false) private val mainHandler = Handler(Looper.getMainLooper()) - private val candidateHosts: List = listOf( - host, - if (host.contains("116.16.16.19")) "2.180.16.250:8999" else "116.16.16.19:8999" - ).filter { it.isNotBlank() }.distinct() + private val candidateHosts: List = listOf(host).filter { it.isNotBlank() }.distinct() private var currentHostIndex = 0 private val okHttpClient = OkHttpClient.Builder() - .connectTimeout(3500, TimeUnit.MILLISECONDS) + .connectTimeout(5000, TimeUnit.MILLISECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) // Persistent WebSocket .writeTimeout(5000, TimeUnit.MILLISECONDS) - .pingInterval(4, TimeUnit.SECONDS) // 4s active heartbeat to keep NAT tables alive + .pingInterval(15, TimeUnit.SECONDS) // Relaxed 15s ping interval (prevents premature drops over Starlink/satellite jitter) .retryOnConnectionFailure(true) .build() @@ -163,6 +160,24 @@ class StreamDictationClient( }) } + /** + * Sends speech chunk directly for pure cursor append on Mac (Cmd+V, NO Cmd+A) + */ + fun sendSpeechInsert(speechText: String, cursor: Int) { + if (!isConnected.get() || activeWebSocket == null) { + connectWebSocket() + } + val payload = JSONObject().apply { + put("type", "insert_speech") + put("action", "insert_speech") + put("source", "android") + put("text", speechText) + put("cursor", cursor) + put("timestamp", System.currentTimeMillis() / 1000.0) + }.toString() + activeWebSocket?.send(payload) + } + /** * Broadcasts phone's live text to Mac for immediate real-time mirroring */ diff --git a/mac/src/FocusedInputSync.swift b/mac/src/FocusedInputSync.swift index 294f47c..3d5d074 100644 --- a/mac/src/FocusedInputSync.swift +++ b/mac/src/FocusedInputSync.swift @@ -255,9 +255,11 @@ public final class FocusedInputSync { } } - // Normalize line endings and preserve intentional multiline text (\n) + // Normalize line endings and preserve intentional multiline text (\n) and trailing space for speech appends var cleanText = text.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") - cleanText = cleanText.trimmingCharacters(in: .whitespacesAndNewlines) + if isFullReplace { + cleanText = cleanText.trimmingCharacters(in: .whitespacesAndNewlines) + } let targetCursor = cursor ?? cleanText.count lastObservedText = cleanText diff --git a/server/relay_server.py b/server/relay_server.py index dea470e..2dc231e 100644 --- a/server/relay_server.py +++ b/server/relay_server.py @@ -105,9 +105,10 @@ class SonioxPool: soniox_pool = SonioxPool() -def sanitize_text(text: str, allow_multiline: bool = True) -> str: +def sanitize_text(text: str, allow_multiline: bool = True, preserve_trailing_space: bool = False) -> str: if not text: return "" + has_trailing_space = text.endswith(" ") if allow_multiline: normalized = text.replace("\r\n", "\n").replace("\r", "\n") lines = [re.sub(r"[ \t]+", " ", line) for line in normalized.split("\n")] @@ -119,6 +120,9 @@ def sanitize_text(text: str, allow_multiline: bool = True) -> str: # Suppress lone punctuation artifacts (e.g. «, », ., ,, !, ?, etc.) if re.fullmatch(r"[\s«»\.\,\،\؛\؟\!\?\:\;\-\–—\"\'\(\)\[\]\{\}]+", cleaned): return "" + + if (preserve_trailing_space or has_trailing_space) and not cleaned.endswith(" "): + cleaned += " " return cleaned @@ -258,7 +262,7 @@ async def handle_phone_stream_ws(request): # ALL text / sync / insert operations must be broadcast to Mac! if msg_type in ("sync_state", "insert_speech", "speech_insert", "phone_input_edit", "update_input", "paste"): is_speech = msg_type in ("insert_speech", "speech_insert") - clean_text = sanitize_text(data.get("text", ""), allow_multiline=(not is_speech)) + clean_text = sanitize_text(data.get("text", ""), allow_multiline=(not is_speech), preserve_trailing_space=is_speech) if not clean_text and is_speech: continue # Do not broadcast empty speech or lone quotes data["text"] = clean_text