Soniox Mobile to Mac - Real-time Voice Dictation & Remote Input Control
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

320 lines
12 KiB

package com.soniox.remotemic
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.util.Log
import okhttp3.*
import okio.ByteString.Companion.toByteString
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.sqrt
data class SyncStatePayload(
val source: String,
val app: String,
val text: String,
val cursor: Int,
val selection: Int,
val revision: Long,
val timestamp: Double
)
class StreamDictationClient(
private val host: String,
private val onConnectionStateChanged: (Boolean) -> Unit,
private val onSyncStateReceived: (SyncStatePayload) -> Unit,
private val onPartialSpeechText: (String) -> Unit,
private val onAudioLevel: (Float) -> Unit,
private val onSpeechCompleted: (String) -> Unit,
private val onError: (String) -> Unit
) {
private val tag = "StreamDictationClient"
private val sampleRate = 16000
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
private var audioRecord: AudioRecord? = null
private var recordingThread: Thread? = null
private val isRecording = AtomicBoolean(false)
private val mainHandler = Handler(Looper.getMainLooper())
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(12, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // Persistent WebSocket
.writeTimeout(8, TimeUnit.SECONDS)
.pingInterval(8, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
private var activeWebSocket: WebSocket? = null
private val isConnected = AtomicBoolean(false)
private val isConnecting = AtomicBoolean(false)
private var currentSessionId: String = ""
private var isSessionActive = AtomicBoolean(false)
private var retryAttempt = 0
init {
connectWebSocket()
}
@Synchronized
fun connectWebSocket() {
if (isConnected.get() && activeWebSocket != null) return
if (isConnecting.getAndSet(true)) return
val cleanHost = host.removePrefix("http://").removePrefix("https://").removePrefix("ws://").removePrefix("wss://")
val wsUrl = "ws://$cleanHost/ws/stream"
AppLogger.log(tag, "اتصال به سوکت همگام‌سازی بلادرنگ: $wsUrl")
val req = Request.Builder()
.url(wsUrl)
.header("User-Agent", "SonioxAndroidRemote/5.2")
.build()
activeWebSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
AppLogger.log(tag, "🟢 سوکت همگام‌سازی بلادرنگ متصل شد")
isConnected.set(true)
isConnecting.set(false)
retryAttempt = 0
mainHandler.post { onConnectionStateChanged(true) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val json = JSONObject(text)
val type = json.optString("type")
val source = json.optString("source", "")
if (type == "sync_state" || type == "mac_input_state") {
val app = json.optString("app", "Mac")
val txt = json.optString("text", "")
val cursor = json.optInt("cursor", txt.length)
val sel = json.optInt("selection", 0)
val rev = json.optLong("revision", 0L)
val ts = json.optDouble("timestamp", System.currentTimeMillis() / 1000.0)
val payload = SyncStatePayload(source, app, txt, cursor, sel, rev, ts)
mainHandler.post { onSyncStateReceived(payload) }
return
}
val sid = json.optString("session_id")
when (type) {
"live", "partial" -> {
if (sid.isEmpty() || sid == currentSessionId || !isSessionActive.get()) {
val liveText = json.optString("text")
mainHandler.post { onPartialSpeechText(liveText) }
}
}
"final" -> {
val finalText = json.optString("text")
isSessionActive.set(false)
AppLogger.log(tag, "⚡ صوت پردازش شد: '$finalText'")
mainHandler.post { onSpeechCompleted(finalText) }
}
"error" -> {
val msg = json.optString("message", "خطای سرور")
AppLogger.log(tag, "❌ خطای سرور: $msg")
mainHandler.post { onError(msg) }
}
}
} catch (e: Exception) {
Log.e(tag, "Message parse error", e)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
AppLogger.log(tag, "🔴 قطع سوکت: ${t.message}. تلاش مجدد...")
handleDisconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
AppLogger.log(tag, "سوکت بسته شد ($code). تلاش مجدد...")
handleDisconnect()
}
private fun handleDisconnect() {
isConnected.set(false)
isConnecting.set(false)
activeWebSocket = null
mainHandler.post { onConnectionStateChanged(false) }
retryAttempt++
val delayMs = minOf(800L * (1L shl minOf(retryAttempt, 3)), 5000L)
mainHandler.postDelayed({ connectWebSocket() }, delayMs)
}
})
}
/**
* Sends speech chunk directly for cursor insertion on Mac (Cmd+V)
*/
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 full updated text & cursor to Mac for replace (Cmd+A + Cmd+V)
*/
fun sendForceReplace(text: String, cursor: Int, selection: Int = 0) {
if (!isConnected.get() || activeWebSocket == null) {
connectWebSocket()
}
val payload = JSONObject().apply {
put("type", "update_input")
put("action", "update_input")
put("source", "android")
put("text", text)
put("cursor", cursor)
put("selection", selection)
put("timestamp", System.currentTimeMillis() / 1000.0)
}.toString()
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")
fun startRecording(cursorPos: Int): String {
if (isRecording.get()) return currentSessionId
currentSessionId = "sess_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(6)}"
isRecording.set(true)
isSessionActive.set(true)
if (!isConnected.get() || activeWebSocket == null) {
connectWebSocket()
}
val startFrame = JSONObject().apply {
put("type", "start")
put("session_id", currentSessionId)
put("cursor_pos", cursorPos)
}.toString()
activeWebSocket?.send(startFrame)
AppLogger.log(tag, "🎙️ شروع استریم گفتار در موقعیت نشانگر $cursorPos...")
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
val bufferSize = maxOf(minBufferSize, 2048)
try {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize
)
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
AppLogger.log(tag, "❌ سخت‌افزار میکروفون راه‌اندازی نشد")
onError("خطا در راه‌اندازی سخت‌افزار میکروفون")
isRecording.set(false)
isSessionActive.set(false)
return currentSessionId
}
audioRecord?.startRecording()
recordingThread = Thread {
val chunk = ByteArray(2048)
val shortBuffer = ShortArray(1024)
while (isRecording.get()) {
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1
if (bytesRead > 0) {
val slice = if (bytesRead == chunk.size) chunk.clone() else chunk.copyOf(bytesRead)
activeWebSocket?.send(slice.toByteString())
var sum = 0.0
val samplesCount = bytesRead / 2
for (i in 0 until samplesCount) {
val sample = (slice[i * 2].toInt() and 0xFF) or (slice[i * 2 + 1].toInt() shl 8)
shortBuffer[i] = sample.toShort()
sum += (shortBuffer[i] * shortBuffer[i]).toDouble()
}
val rms = sqrt(sum / samplesCount) / 32768.0
val level = minOf(maxOf(rms * 4.5, 0.0), 1.0).toFloat()
mainHandler.post { onAudioLevel(level) }
}
}
}.apply {
priority = Thread.MAX_PRIORITY
start()
}
} catch (e: Exception) {
AppLogger.log(tag, "خطای ضبط: ${e.message}")
onError("خطای میکروفون: ${e.localizedMessage}")
isRecording.set(false)
isSessionActive.set(false)
}
return currentSessionId
}
fun stopRecording(cursorPos: Int) {
if (!isRecording.get()) return
isRecording.set(false)
try {
audioRecord?.stop()
audioRecord?.release()
audioRecord = null
recordingThread?.join(150)
recordingThread = null
} catch (e: Exception) {
Log.e(tag, "Error releasing audio hardware", e)
}
val stopFrame = JSONObject().apply {
put("type", "stop")
put("session_id", currentSessionId)
put("cursor_pos", cursorPos)
}.toString()
activeWebSocket?.send(stopFrame)
AppLogger.log(tag, "⏹️ پایان ضبط. دریافت متن نهایی...")
}
fun release() {
isRecording.set(false)
try {
audioRecord?.release()
activeWebSocket?.close(1000, "Client Shutdown")
} catch (e: Exception) {}
}
}