From cbff21002c2379b6d0a0186f17adb6fd97d13d99 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Sun, 23 Aug 2026 21:25:44 +0000 Subject: [PATCH] fix: eliminate double paste, debounce interference and lone quote artifacts --- .../java/com/soniox/remotemic/MainActivity.kt | 40 +++++++++---------- server/relay_server.py | 18 +++++++-- 2 files changed, 34 insertions(+), 24 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 90df95e..4a99790 100644 --- a/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt +++ b/android/app/src/main/java/com/soniox/remotemic/MainActivity.kt @@ -88,7 +88,7 @@ class MainActivity : AppCompatActivity() { setupUI() checkPermissions() - AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.2)") + AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.4)") // Initialize Collaborative WebSocket Client streamDictationClient = StreamDictationClient( @@ -104,8 +104,8 @@ class MainActivity : AppCompatActivity() { if (state.source != "android") { val timeSinceLocalEdit = System.currentTimeMillis() - lastLocalUserEditTime - // If user is actively typing or backspacing right now on phone, do not override with remote echo - if (timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) { + // If user is actively typing or recording right now on phone, do not override + if ((timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) || isCurrentlyRecording) { return@StreamDictationClient } @@ -114,7 +114,7 @@ class MainActivity : AppCompatActivity() { return@StreamDictationClient } - // Ghost empty sync protection for opaque/Electron apps (e.g. Antigravity) + // Ghost empty sync protection for opaque apps if (state.text.isEmpty() && lastLocalText.isNotEmpty() && state.source == "mac") { if (state.app.isNotEmpty() && state.app != "Desktop" && state.app != "App") { binding.tvMacStatus.text = "متصل به ${state.app} 🖥️" @@ -175,18 +175,15 @@ class MainActivity : AppCompatActivity() { } /** - * Dual-engine keyboard visibility detector (WindowInsets + OnGlobalLayoutListener) - * Guarantees 100% detection on all Android versions and keyboards. + * Dual-engine keyboard visibility detector */ private fun setupKeyboardVisibilityDetection() { - // Engine 1: Modern WindowInsets ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) updateKeyboardUIMode(imeVisible) insets } - // Engine 2: Global Layout Frame Calculation (Fallback for OEM soft keyboards) binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { private val r = Rect() override fun onGlobalLayout() { @@ -204,14 +201,11 @@ class MainActivity : AppCompatActivity() { isKeyboardCurrentlyVisible = isKeyboardOpen if (isKeyboardOpen) { - // KEYBOARD OPEN: Hide big mic circle, hide action buttons & divider completely! - // Give 100% of available space above keyboard exclusively to the huge text editor box! binding.bottomVoiceSection.visibility = View.GONE binding.actionDivider.visibility = View.GONE binding.actionButtonsRow.visibility = View.GONE binding.tvSubtitle.visibility = View.GONE } else { - // KEYBOARD CLOSED: Restore spacious layout with large glowing mic button & action bar binding.bottomVoiceSection.visibility = View.VISIBLE binding.actionDivider.visibility = View.VISIBLE binding.actionButtonsRow.visibility = View.VISIBLE @@ -220,6 +214,13 @@ class MainActivity : AppCompatActivity() { } private fun insertSpeechAtCursor(speechText: String) { + val trimmedSpeech = speechText.trim() + + // Suppress empty strings or lone quotes/punctuation marks + if (trimmedSpeech.isEmpty() || trimmedSpeech.matches("^[\\s«»\\.\\,\\،\\؛\\؟\\!\\?\\:\\;\\-\\–—\\\"\\'\\(\\)\\[\\]\\{\\}]+$".toRegex())) { + return + } + val current = binding.etTranscript.text?.toString() ?: "" val start = voiceInsertionCursorStart.coerceIn(0, current.length) val end = voiceInsertionCursorEnd.coerceIn(0, current.length) @@ -227,10 +228,6 @@ class MainActivity : AppCompatActivity() { val prefix = if (start > 0) current.substring(0, start) else "" val suffix = if (end < current.length) current.substring(end) else "" - val trimmedSpeech = speechText.trim() - if (trimmedSpeech.isEmpty()) return - - // Smart spacing for Persian / English word boundaries val needsPreSpace = prefix.isNotEmpty() && !prefix.endsWith(" ") && !prefix.endsWith("\n") val needsPostSpace = suffix.isNotEmpty() && !suffix.startsWith(" ") && !suffix.startsWith("\n") && !suffix.startsWith(",") && !suffix.startsWith("،") && !suffix.startsWith(".") && @@ -245,6 +242,10 @@ class MainActivity : AppCompatActivity() { val mergedText = "$prefix$formattedSpeech$suffix" val newCursor = (start + formattedSpeech.length).coerceIn(0, mergedText.length) + // Cancel any pending debounced sync to prevent double-paste + pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } + pendingSyncRunnable = null + isApplyingRemoteUpdate = true lastLocalText = mergedText lastLocalUserEditTime = System.currentTimeMillis() @@ -254,7 +255,7 @@ class MainActivity : AppCompatActivity() { AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") - // Send speech directly to Mac cursor (Cmd+V) + // Send speech directly to Mac cursor (Cmd+V) exactly ONCE streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor) } @@ -272,12 +273,11 @@ class MainActivity : AppCompatActivity() { lastLocalUserEditTime = System.currentTimeMillis() val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) - // Debounce sending to Mac by 60ms to allow 120Hz lag-free backspacing and typing pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } pendingSyncRunnable = Runnable { streamDictationClient?.sendLocalSyncState(text, cur) } - debounceHandler.postDelayed(pendingSyncRunnable!!, 60) + debounceHandler.postDelayed(pendingSyncRunnable!!, 100) } } override fun afterTextChanged(s: Editable?) {} @@ -318,8 +318,8 @@ class MainActivity : AppCompatActivity() { binding.tvInstruction.text = "در حال درج متن در مک..." binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) - // 1. Direct WebSocket broadcast - streamDictationClient?.sendLocalSyncState(text, cur) + // 1. Direct WebSocket broadcast with force_replace + streamDictationClient?.sendForceReplace(text, cur) // 2. Direct HTTP Post guarantee lifecycleScope.launch { diff --git a/server/relay_server.py b/server/relay_server.py index b876b30..b73dd73 100644 --- a/server/relay_server.py +++ b/server/relay_server.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """ -Soniox Collaborative Bi-Directional Input Synchronization Gateway (v5.3) -- Handles all message types: sync_state, insert_speech, speech_insert, update_input, paste. -- Sub-15ms WebSocket routing between Android and Mac. +Soniox Collaborative Bi-Directional Input Synchronization Gateway (v5.4) +- Ultra-low latency streaming speech recognition with pre-warmed Soniox pool. +- Strict artifact & lone-punctuation suppression (e.g. «, », ., quotes). +- Clean single-injection pipeline on speech finalization. """ import asyncio @@ -109,6 +110,11 @@ def sanitize_and_flatten_text(text: str) -> str: return "" flattened = re.sub(r"[\r\n\t]+", " ", text) flattened = re.sub(r"\s+", " ", flattened).strip() + + # Suppress lone punctuation artifacts (e.g. «, », ., ,, !, ?, etc.) + if re.fullmatch(r"[\s«»\.\,\،\؛\؟\!\?\:\;\-\–—\"\'\(\)\[\]\{\}]+", flattened): + return "" + return flattened async def broadcast_state(payload_dict: dict, exclude_ws=None): @@ -243,6 +249,10 @@ 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"): + clean_text = sanitize_and_flatten_text(data.get("text", "")) + if not clean_text and msg_type in ("insert_speech", "speech_insert"): + continue # Do not broadcast empty speech or lone quotes + data["text"] = clean_text data["source"] = "android" data["type"] = msg_type await broadcast_state(data, exclude_ws=ws) @@ -351,7 +361,7 @@ async def handle_health(request): state_copy = dict(current_room_state) return web.json_response({ "status": "ok", - "service": "Soniox Collaborative Sync Gateway v5.3", + "service": "Soniox Collaborative Sync Gateway v5.4", "connected_macs": len(connected_mac_websockets), "connected_phones": len(connected_phone_websockets), "current_app": state_copy.get("app", ""),