Browse Source

feat(sync): real-time live bi-directional text mirroring with clean full replacement and instant backspace/clear sync

main
Ali Alavi 22 hours ago
parent
commit
1373f81406
  1. 23
      android/app/src/main/java/com/soniox/remotemic/MainActivity.kt
  2. 31
      android/app/src/main/java/com/soniox/remotemic/StreamDictationClient.kt
  3. 90
      mac/src/FocusedInputSync.swift
  4. 40
      mac/src/RelayClient.swift

23
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.4)")
AppLogger.log("Main", "اپلیکیشن همگام‌سازی صوتی سانی‌اوکس راه‌اندازی شد (نسخه بهینه‌شده فوق‌سریع v5.5)")
// Initialize Collaborative WebSocket Client // Initialize Collaborative WebSocket Client
streamDictationClient = StreamDictationClient( streamDictationClient = StreamDictationClient(
@ -160,7 +160,7 @@ class MainActivity : AppCompatActivity() {
vibrate(100) vibrate(100)
if (finalText.isNotEmpty()) { if (finalText.isNotEmpty()) {
insertSpeechAtCursor(finalText) insertSpeechAtCursor(finalText)
binding.tvInstruction.text = "✨ گفتار در نشانگر درج و با مک همگام شد"
binding.tvInstruction.text = "✨ گفتار با مک همگام شد"
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_green)) binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_green))
} else { } else {
binding.tvInstruction.text = "صدایی تشخیص داده نشد" binding.tvInstruction.text = "صدایی تشخیص داده نشد"
@ -242,7 +242,7 @@ 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
// Cancel any pending debounced sync
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) }
pendingSyncRunnable = null pendingSyncRunnable = null
@ -255,8 +255,8 @@ class MainActivity : AppCompatActivity() {
AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)")
// Send speech directly to Mac cursor (Cmd+V) exactly ONCE
streamDictationClient?.sendSpeechInsert(formattedSpeech, newCursor)
// Broadcast full mirrored text to Mac
streamDictationClient?.sendPhoneEdit(mergedText, newCursor)
} }
private fun setupUI() { private fun setupUI() {
@ -273,24 +273,29 @@ 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)
// 80ms debounce: sends clean replacement to Mac in real-time as user types/deletes
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) }
pendingSyncRunnable = Runnable { pendingSyncRunnable = Runnable {
streamDictationClient?.sendLocalSyncState(text, cur)
streamDictationClient?.sendPhoneEdit(text, cur)
} }
debounceHandler.postDelayed(pendingSyncRunnable!!, 100)
debounceHandler.postDelayed(pendingSyncRunnable!!, 80)
} }
} }
override fun afterTextChanged(s: Editable?) {} override fun afterTextChanged(s: Editable?) {}
}) })
// Clear Button
// Clear Button (🗑️ پاک‌کردن)
binding.btnClearText.setOnClickListener { binding.btnClearText.setOnClickListener {
isApplyingRemoteUpdate = true isApplyingRemoteUpdate = true
binding.etTranscript.setText("") binding.etTranscript.setText("")
lastLocalText = "" lastLocalText = ""
lastLocalUserEditTime = System.currentTimeMillis() lastLocalUserEditTime = System.currentTimeMillis()
isApplyingRemoteUpdate = false isApplyingRemoteUpdate = false
streamDictationClient?.sendLocalSyncState("", 0)
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) }
pendingSyncRunnable = null
streamDictationClient?.sendPhoneEdit("", 0)
binding.tvInstruction.text = getString(R.string.hold_to_speak) binding.tvInstruction.text = getString(R.string.hold_to_speak)
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)) binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.text_secondary))
} }

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

@ -74,7 +74,7 @@ class StreamDictationClient(
val req = Request.Builder() val req = Request.Builder()
.url(wsUrl) .url(wsUrl)
.header("User-Agent", "SonioxAndroidRemote/5.2")
.header("User-Agent", "SonioxAndroidRemote/5.4")
.build() .build()
activeWebSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { activeWebSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() {
@ -154,18 +154,19 @@ class StreamDictationClient(
} }
/** /**
* Sends speech chunk directly for cursor insertion on Mac (Cmd+V)
* Broadcasts phone's live text to Mac for immediate real-time mirroring
*/ */
fun sendSpeechInsert(speechText: String, cursor: Int) {
fun sendPhoneEdit(text: String, cursor: Int, selection: Int = 0) {
if (!isConnected.get() || activeWebSocket == null) { if (!isConnected.get() || activeWebSocket == null) {
connectWebSocket() connectWebSocket()
} }
val payload = JSONObject().apply { val payload = JSONObject().apply {
put("type", "insert_speech")
put("action", "insert_speech")
put("type", "phone_input_edit")
put("action", "phone_input_edit")
put("source", "android") put("source", "android")
put("text", speechText)
put("text", text)
put("cursor", cursor) put("cursor", cursor)
put("selection", selection)
put("timestamp", System.currentTimeMillis() / 1000.0) put("timestamp", System.currentTimeMillis() / 1000.0)
}.toString() }.toString()
activeWebSocket?.send(payload) activeWebSocket?.send(payload)
@ -190,24 +191,6 @@ class StreamDictationClient(
activeWebSocket?.send(payload) activeWebSocket?.send(payload)
} }
/**
* Broadcasts phone's updated text & cursor to Mac
*/
fun sendLocalSyncState(text: String, cursor: Int, selection: Int = 0) {
if (!isConnected.get() || activeWebSocket == null) {
connectWebSocket()
}
val payload = JSONObject().apply {
put("type", "sync_state")
put("source", "android")
put("text", text)
put("cursor", cursor)
put("selection", selection)
put("timestamp", System.currentTimeMillis() / 1000.0)
}.toString()
activeWebSocket?.send(payload)
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun startRecording(cursorPos: Int): String { fun startRecording(cursorPos: Int): String {
if (isRecording.get()) return currentSessionId if (isRecording.get()) return currentSessionId

90
mac/src/FocusedInputSync.swift

@ -114,7 +114,7 @@ public final class FocusedInputSync {
var targetElem: AXUIElement? var targetElem: AXUIElement?
// 1. Try system-wide focused element
// 1. System-wide focused element
var focusedObj: CFTypeRef? var focusedObj: CFTypeRef?
if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
let obj = focusedObj { let obj = focusedObj {
@ -127,7 +127,7 @@ public final class FocusedInputSync {
} }
} }
// 2. Try App focused element
// 2. App focused element
if targetElem == nil { if targetElem == nil {
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
let obj = focusedObj { let obj = focusedObj {
@ -141,7 +141,7 @@ public final class FocusedInputSync {
} }
} }
// 3. Try App focused window's focused element
// 3. App focused window element
if targetElem == nil { if targetElem == nil {
var focusedWinObj: CFTypeRef? var focusedWinObj: CFTypeRef?
if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &focusedWinObj) == .success, if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &focusedWinObj) == .success,
@ -162,7 +162,7 @@ public final class FocusedInputSync {
return (targetElem, appName) return (targetElem, appName)
} }
/// Inspects current focused element and returns state snapshot if changed on Mac
/// Inspects current focused element on Mac and returns state snapshot if user typed on Mac
public func inspectCurrentState() -> MacInputState? { public func inspectCurrentState() -> MacInputState? {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
if isApplyingRemoteChange || now < remoteChangeExpiryTime { if isApplyingRemoteChange || now < remoteChangeExpiryTime {
@ -243,19 +243,19 @@ public final class FocusedInputSync {
return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision) return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision)
} }
/// Applies updated full text or inserts speech directly into active Mac input
/// Perfectly mirrors the full text to the active Mac input box in real-time
@discardableResult @discardableResult
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = false) -> Bool {
public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool {
isApplyingRemoteChange = true isApplyingRemoteChange = true
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.60
remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.40
defer { defer {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.60) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.40) {
self.isApplyingRemoteChange = false self.isApplyingRemoteChange = false
} }
} }
// 1. Clean and normalize text
// Normalize text
var cleanText = text.components(separatedBy: .newlines).joined(separator: " ") var cleanText = text.components(separatedBy: .newlines).joined(separator: " ")
cleanText = cleanText.replacingOccurrences(of: "\t", with: " ") cleanText = cleanText.replacingOccurrences(of: "\t", with: " ")
cleanText = cleanText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) cleanText = cleanText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
@ -265,50 +265,89 @@ public final class FocusedInputSync {
lastObservedText = cleanText lastObservedText = cleanText
lastObservedCursor = targetCursor lastObservedCursor = targetCursor
let (_, appName) = getFocusedElement()
let (elemOpt, appName) = getFocusedElement()
lastObservedApp = appName lastObservedApp = appName
print("FocusedInputSync: 🚀 Injecting text into '\(appName)' via Clean Quartz CGEvent (replace: \(isFullReplace), len: \(cleanText.count))")
return pasteViaCleanKeystroke(cleanText, isFullReplace: isFullReplace)
// Try fast direct native AX write if supported (TextEdit, Notes, Finder)
if let elem = elemOpt {
let setErr = AXUIElementSetAttributeValue(elem, kAXValueAttribute as CFString, cleanText as CFTypeRef)
if setErr == .success {
print("FocusedInputSync: ✅ Updated native AXValue for \(appName)")
return true
}
}
// Universal Native Replace (Cmd+A -> Cmd+V / Backspace)
print("FocusedInputSync: 🔄 Syncing full text to '\(appName)' (len: \(cleanText.count))")
return executeCleanFullReplace(cleanText)
} }
/** /**
* Clean, Universal Quartz CGEvent Keystroke Engine
* Sets pasteboard, sends clean Cmd+A (if replace) and Cmd+V (virtualKey 9 with .maskCommand).
* Replaces the entire content of active input box cleanly in real-time.
* If text is empty: Cmd+A -> Backspace.
* If text is non-empty: Cmd+A -> Cmd+V.
*/ */
private func pasteViaCleanKeystroke(_ text: String, isFullReplace: Bool) -> Bool {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
private func executeCleanFullReplace(_ text: String) -> Bool {
let src = CGEventSource(stateID: .hidSystemState) let src = CGEventSource(stateID: .hidSystemState)
let kVK_ANSI_A: CGKeyCode = 0 let kVK_ANSI_A: CGKeyCode = 0
let kVK_ANSI_V: CGKeyCode = 9 let kVK_ANSI_V: CGKeyCode = 9
let kVK_Delete: CGKeyCode = 51
if isFullReplace {
if text.isEmpty {
// Select all: Cmd + A // Select all: Cmd + A
if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) { if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) {
aDown.flags = .maskCommand aDown.flags = .maskCommand
aDown.post(tap: .cghidEventTap) aDown.post(tap: .cghidEventTap)
aDown.post(tap: .cgSessionEventTap) aDown.post(tap: .cgSessionEventTap)
} }
usleep(12000)
usleep(8000)
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) { if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) {
aUp.flags = .maskCommand aUp.flags = .maskCommand
aUp.post(tap: .cghidEventTap) aUp.post(tap: .cghidEventTap)
aUp.post(tap: .cgSessionEventTap) aUp.post(tap: .cgSessionEventTap)
} }
usleep(25000) // 25ms settling delay for selection
usleep(15000)
// Backspace to clear
if let delDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: true) {
delDown.post(tap: .cghidEventTap)
delDown.post(tap: .cgSessionEventTap)
}
usleep(8000)
if let delUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: false) {
delUp.post(tap: .cghidEventTap)
delUp.post(tap: .cgSessionEventTap)
}
return true
}
// Set clipboard
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
// 1. Select all: Cmd + A
if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) {
aDown.flags = .maskCommand
aDown.post(tap: .cghidEventTap)
aDown.post(tap: .cgSessionEventTap)
} }
usleep(8000)
// Paste: Cmd + V
if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) {
aUp.flags = .maskCommand
aUp.post(tap: .cghidEventTap)
aUp.post(tap: .cgSessionEventTap)
}
usleep(18000) // 18ms for selection to settle
// 2. Paste: Cmd + V
if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) { if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) {
vDown.flags = .maskCommand vDown.flags = .maskCommand
vDown.post(tap: .cghidEventTap) vDown.post(tap: .cghidEventTap)
vDown.post(tap: .cgSessionEventTap) vDown.post(tap: .cgSessionEventTap)
} }
usleep(15000)
usleep(12000)
if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) { if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) {
vUp.flags = .maskCommand vUp.flags = .maskCommand
@ -316,7 +355,6 @@ public final class FocusedInputSync {
vUp.post(tap: .cgSessionEventTap) vUp.post(tap: .cgSessionEventTap)
} }
print("FocusedInputSync: ✅ Clean Cmd+V Posted successfully for text: '\(text.prefix(25))...'")
return true return true
} }
} }

40
mac/src/RelayClient.swift

@ -17,12 +17,9 @@ public final class RelayClient: NSObject, URLSessionWebSocketDelegate {
private var monitorTimerSource: DispatchSourceTimer? private var monitorTimerSource: DispatchSourceTimer?
private let monitorQueue = DispatchQueue(label: "com.soniox.macsync.monitor", qos: .userInteractive) private let monitorQueue = DispatchQueue(label: "com.soniox.macsync.monitor", qos: .userInteractive)
// Callback: (text, cursor, isFullReplace)
public var onRemoteUpdateReceived: ((String, Int?, Bool) -> Void)? public var onRemoteUpdateReceived: ((String, Int?, Bool) -> Void)?
// Dedup guard: prevent rapid duplicate keystrokes within 200ms
private var lastInjectedText: String = ""
private var lastInjectedTime: Double = 0
private var lastObservedInjectedText: String? = nil
override private init() { override private init() {
super.init() super.init()
@ -111,43 +108,18 @@ public final class RelayClient: NSObject, URLSessionWebSocketDelegate {
let updateText = json["text"] as? String ?? json["insert_text"] as? String ?? "" let updateText = json["text"] as? String ?? json["insert_text"] as? String ?? ""
let cursor = json["cursor"] as? Int ?? json["cursor_pos"] as? Int let cursor = json["cursor"] as? Int ?? json["cursor_pos"] as? Int
let now = Date().timeIntervalSince1970
// 1. Live Voice Dictation from Phone Mic: Paste ONLY new speech at active cursor (Cmd+V)
if action == "insert_speech" || action == "speech_insert" {
guard !updateText.isEmpty else { return }
// Dedup exact duplicate within 250ms
if updateText == lastInjectedText && (now - lastInjectedTime) < 0.25 {
if action == "sync_state" || action == "phone_input_edit" || action == "update_input" || action == "force_replace" || action == "insert_speech" || action == "paste" {
// Drop identical consecutive payloads
if updateText == lastObservedInjectedText && action != "force_replace" {
return return
} }
lastInjectedText = updateText
lastInjectedTime = now
print("RelayClient: 🎙️ Voice Dictation Paste: '\(updateText.prefix(30))...' (Cmd+V at cursor)")
DispatchQueue.main.async {
self.onRemoteUpdateReceived?(updateText, cursor, false)
}
}
// 2. Explicit "Send to Mac" Button on Phone: Full Replace (Cmd+A + Cmd+V)
else if action == "update_input" || action == "force_replace" || action == "paste" {
guard !updateText.isEmpty else { return }
lastInjectedText = updateText
lastInjectedTime = now
lastObservedInjectedText = updateText
print("RelayClient: 🚀 Force Replace All: '\(updateText.prefix(30))...' (Cmd+A + Cmd+V)")
print("RelayClient: ⚡ Applying live phone edit: '\(updateText.prefix(30))...' (len: \(updateText.count))")
DispatchQueue.main.async { DispatchQueue.main.async {
self.onRemoteUpdateReceived?(updateText, cursor, true) self.onRemoteUpdateReceived?(updateText, cursor, true)
} }
} }
// 3. Passive State Synchronization (typing on phone keyboard):
// Updates room state cache only DOES NOT trigger keystrokes on Mac!
else if action == "sync_state" || action == "phone_input_edit" {
// Passive sync only
}
} }
private func startInputMonitoring() { private func startInputMonitoring() {

Loading…
Cancel
Save