Browse Source

fix: eliminate double paste, debounce interference and lone quote artifacts

main
Ali Alavi 19 hours ago
parent
commit
cbff21002c
  1. 40
      android/app/src/main/java/com/soniox/remotemic/MainActivity.kt
  2. 18
      server/relay_server.py

40
android/app/src/main/java/com/soniox/remotemic/MainActivity.kt

@ -88,7 +88,7 @@ class MainActivity : AppCompatActivity() {
setupUI() setupUI()
checkPermissions() checkPermissions()
AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.2)")
AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.4)")
// Initialize Collaborative WebSocket Client // Initialize Collaborative WebSocket Client
streamDictationClient = StreamDictationClient( streamDictationClient = StreamDictationClient(
@ -104,8 +104,8 @@ class MainActivity : AppCompatActivity() {
if (state.source != "android") { if (state.source != "android") {
val timeSinceLocalEdit = System.currentTimeMillis() - lastLocalUserEditTime 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 return@StreamDictationClient
} }
@ -114,7 +114,7 @@ class MainActivity : AppCompatActivity() {
return@StreamDictationClient 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.text.isEmpty() && lastLocalText.isNotEmpty() && state.source == "mac") {
if (state.app.isNotEmpty() && state.app != "Desktop" && state.app != "App") { if (state.app.isNotEmpty() && state.app != "Desktop" && state.app != "App") {
binding.tvMacStatus.text = "متصل به ${state.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() { private fun setupKeyboardVisibilityDetection() {
// Engine 1: Modern WindowInsets
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
updateKeyboardUIMode(imeVisible) updateKeyboardUIMode(imeVisible)
insets insets
} }
// Engine 2: Global Layout Frame Calculation (Fallback for OEM soft keyboards)
binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
private val r = Rect() private val r = Rect()
override fun onGlobalLayout() { override fun onGlobalLayout() {
@ -204,14 +201,11 @@ class MainActivity : AppCompatActivity() {
isKeyboardCurrentlyVisible = isKeyboardOpen isKeyboardCurrentlyVisible = isKeyboardOpen
if (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.bottomVoiceSection.visibility = View.GONE
binding.actionDivider.visibility = View.GONE binding.actionDivider.visibility = View.GONE
binding.actionButtonsRow.visibility = View.GONE binding.actionButtonsRow.visibility = View.GONE
binding.tvSubtitle.visibility = View.GONE binding.tvSubtitle.visibility = View.GONE
} else { } else {
// KEYBOARD CLOSED: Restore spacious layout with large glowing mic button & action bar
binding.bottomVoiceSection.visibility = View.VISIBLE binding.bottomVoiceSection.visibility = View.VISIBLE
binding.actionDivider.visibility = View.VISIBLE binding.actionDivider.visibility = View.VISIBLE
binding.actionButtonsRow.visibility = View.VISIBLE binding.actionButtonsRow.visibility = View.VISIBLE
@ -220,6 +214,13 @@ class MainActivity : AppCompatActivity() {
} }
private fun insertSpeechAtCursor(speechText: String) { 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 current = binding.etTranscript.text?.toString() ?: ""
val start = voiceInsertionCursorStart.coerceIn(0, current.length) val start = voiceInsertionCursorStart.coerceIn(0, current.length)
val end = voiceInsertionCursorEnd.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 prefix = if (start > 0) current.substring(0, start) else ""
val suffix = if (end < current.length) current.substring(end) 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 needsPreSpace = prefix.isNotEmpty() && !prefix.endsWith(" ") && !prefix.endsWith("\n")
val needsPostSpace = suffix.isNotEmpty() && !suffix.startsWith(" ") && !suffix.startsWith("\n") && val needsPostSpace = suffix.isNotEmpty() && !suffix.startsWith(" ") && !suffix.startsWith("\n") &&
!suffix.startsWith(",") && !suffix.startsWith("،") && !suffix.startsWith(".") && !suffix.startsWith(",") && !suffix.startsWith("،") && !suffix.startsWith(".") &&
@ -245,6 +242,10 @@ class MainActivity : AppCompatActivity() {
val mergedText = "$prefix$formattedSpeech$suffix" val mergedText = "$prefix$formattedSpeech$suffix"
val newCursor = (start + formattedSpeech.length).coerceIn(0, mergedText.length) 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 isApplyingRemoteUpdate = true
lastLocalText = mergedText lastLocalText = mergedText
lastLocalUserEditTime = System.currentTimeMillis() lastLocalUserEditTime = System.currentTimeMillis()
@ -254,7 +255,7 @@ class MainActivity : AppCompatActivity() {
AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") 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) streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor)
} }
@ -272,12 +273,11 @@ class MainActivity : AppCompatActivity() {
lastLocalUserEditTime = System.currentTimeMillis() lastLocalUserEditTime = System.currentTimeMillis()
val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) 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?.let { debounceHandler.removeCallbacks(it) }
pendingSyncRunnable = Runnable { pendingSyncRunnable = Runnable {
streamDictationClient?.sendLocalSyncState(text, cur) streamDictationClient?.sendLocalSyncState(text, cur)
} }
debounceHandler.postDelayed(pendingSyncRunnable!!, 60)
debounceHandler.postDelayed(pendingSyncRunnable!!, 100)
} }
} }
override fun afterTextChanged(s: Editable?) {} override fun afterTextChanged(s: Editable?) {}
@ -318,8 +318,8 @@ class MainActivity : AppCompatActivity() {
binding.tvInstruction.text = "در حال درج متن در مک..." binding.tvInstruction.text = "در حال درج متن در مک..."
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) 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 // 2. Direct HTTP Post guarantee
lifecycleScope.launch { lifecycleScope.launch {

18
server/relay_server.py

@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/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 import asyncio
@ -109,6 +110,11 @@ def sanitize_and_flatten_text(text: str) -> str:
return "" return ""
flattened = re.sub(r"[\r\n\t]+", " ", text) flattened = re.sub(r"[\r\n\t]+", " ", text)
flattened = re.sub(r"\s+", " ", flattened).strip() flattened = re.sub(r"\s+", " ", flattened).strip()
# Suppress lone punctuation artifacts (e.g. «, », ., ,, !, ?, etc.)
if re.fullmatch(r"[\s«»\.\,\،\؛\؟\!\?\:\;\-\–—\"\'\(\)\[\]\{\}]+", flattened):
return ""
return flattened return flattened
async def broadcast_state(payload_dict: dict, exclude_ws=None): 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! # 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"): 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["source"] = "android"
data["type"] = msg_type data["type"] = msg_type
await broadcast_state(data, exclude_ws=ws) await broadcast_state(data, exclude_ws=ws)
@ -351,7 +361,7 @@ async def handle_health(request):
state_copy = dict(current_room_state) state_copy = dict(current_room_state)
return web.json_response({ return web.json_response({
"status": "ok", "status": "ok",
"service": "Soniox Collaborative Sync Gateway v5.3",
"service": "Soniox Collaborative Sync Gateway v5.4",
"connected_macs": len(connected_mac_websockets), "connected_macs": len(connected_mac_websockets),
"connected_phones": len(connected_phone_websockets), "connected_phones": len(connected_phone_websockets),
"current_app": state_copy.get("app", ""), "current_app": state_copy.get("app", ""),

Loading…
Cancel
Save