Browse Source

fix(sync): append speech at cursor with trailing space, relax Starlink ping timeout, and avoid private IP loop on Starlink

main
Ali Alavi 10 hours ago
parent
commit
65ab8f68b6
  1. 8
      android/app/src/main/java/com/soniox/remotemic/MainActivity.kt
  2. 27
      android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt
  3. 6
      mac/src/FocusedInputSync.swift
  4. 8
      server/relay_server.py

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

@ -89,7 +89,7 @@ class MainActivity : AppCompatActivity() {
setupUI() setupUI()
checkPermissions() checkPermissions()
AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.6)")
AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.7)")
// Initialize Collaborative WebSocket Client // Initialize Collaborative WebSocket Client
streamDictationClient = StreamDictationClient( streamDictationClient = StreamDictationClient(
@ -237,7 +237,7 @@ class MainActivity : AppCompatActivity() {
val formattedSpeech = buildString { val formattedSpeech = buildString {
if (needsPreSpace) append(" ") if (needsPreSpace) append(" ")
append(trimmedSpeech) append(trimmedSpeech)
if (needsPostSpace) append(" ")
append(" ") // ALWAYS guarantee a trailing space after inserted speech
} }
val mergedText = "$prefix$formattedSpeech$suffix" val mergedText = "$prefix$formattedSpeech$suffix"
@ -256,8 +256,8 @@ class MainActivity : AppCompatActivity() {
AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") 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() { private fun setupUI() {

27
android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt

@ -44,17 +44,14 @@ 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 candidateHosts: List<String> = 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<String> = listOf(host).filter { it.isNotBlank() }.distinct()
private var currentHostIndex = 0 private var currentHostIndex = 0
private val okHttpClient = OkHttpClient.Builder() private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(3500, TimeUnit.MILLISECONDS)
.connectTimeout(5000, TimeUnit.MILLISECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // Persistent WebSocket .readTimeout(0, TimeUnit.MILLISECONDS) // Persistent WebSocket
.writeTimeout(5000, TimeUnit.MILLISECONDS) .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) .retryOnConnectionFailure(true)
.build() .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 * Broadcasts phone's live text to Mac for immediate real-time mirroring
*/ */

6
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") 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 let targetCursor = cursor ?? cleanText.count
lastObservedText = cleanText lastObservedText = cleanText

8
server/relay_server.py

@ -105,9 +105,10 @@ class SonioxPool:
soniox_pool = 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: if not text:
return "" return ""
has_trailing_space = text.endswith(" ")
if allow_multiline: if allow_multiline:
normalized = text.replace("\r\n", "\n").replace("\r", "\n") normalized = text.replace("\r\n", "\n").replace("\r", "\n")
lines = [re.sub(r"[ \t]+", " ", line) for line in normalized.split("\n")] lines = [re.sub(r"[ \t]+", " ", line) for line in normalized.split("\n")]
@ -120,6 +121,9 @@ def sanitize_text(text: str, allow_multiline: bool = True) -> str:
if re.fullmatch(r"[\s«»\.\,\،\؛\؟\!\?\:\;\-\–—\"\'\(\)\[\]\{\}]+", cleaned): if re.fullmatch(r"[\s«»\.\,\،\؛\؟\!\?\:\;\-\–—\"\'\(\)\[\]\{\}]+", cleaned):
return "" return ""
if (preserve_trailing_space or has_trailing_space) and not cleaned.endswith(" "):
cleaned += " "
return cleaned return cleaned
def sanitize_and_flatten_text(text: str) -> str: def sanitize_and_flatten_text(text: str) -> str:
@ -258,7 +262,7 @@ 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"):
is_speech = msg_type in ("insert_speech", "speech_insert") 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: if not clean_text and is_speech:
continue # Do not broadcast empty speech or lone quotes continue # Do not broadcast empty speech or lone quotes
data["text"] = clean_text data["text"] = clean_text

Loading…
Cancel
Save