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
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) {}
|
|
}
|
|
}
|