|
|
@ -8,9 +8,13 @@ import android.os.Handler |
|
|
import android.os.Looper |
|
|
import android.os.Looper |
|
|
import android.util.Log |
|
|
import android.util.Log |
|
|
import okhttp3.* |
|
|
import okhttp3.* |
|
|
|
|
|
import okhttp3.MediaType.Companion.toMediaType |
|
|
|
|
|
import okhttp3.RequestBody.Companion.toRequestBody |
|
|
import okio.ByteString.Companion.toByteString |
|
|
import okio.ByteString.Companion.toByteString |
|
|
import org.json.JSONObject |
|
|
import org.json.JSONObject |
|
|
|
|
|
import java.io.ByteArrayOutputStream |
|
|
import java.util.UUID |
|
|
import java.util.UUID |
|
|
|
|
|
import java.util.concurrent.ConcurrentLinkedQueue |
|
|
import java.util.concurrent.TimeUnit |
|
|
import java.util.concurrent.TimeUnit |
|
|
import java.util.concurrent.atomic.AtomicBoolean |
|
|
import java.util.concurrent.atomic.AtomicBoolean |
|
|
import kotlin.math.sqrt |
|
|
import kotlin.math.sqrt |
|
|
@ -44,6 +48,10 @@ class StreamDictationClient( |
|
|
private val isRecording = AtomicBoolean(false) |
|
|
private val isRecording = AtomicBoolean(false) |
|
|
private val mainHandler = Handler(Looper.getMainLooper()) |
|
|
private val mainHandler = Handler(Looper.getMainLooper()) |
|
|
|
|
|
|
|
|
|
|
|
// Resilient Audio Ring Buffer (Holds chunks during temporary disconnects) |
|
|
|
|
|
private val audioChunkQueue = ConcurrentLinkedQueue<ByteArray>() |
|
|
|
|
|
private val fullAudioSessionBuffer = ByteArrayOutputStream() |
|
|
|
|
|
|
|
|
private val candidateHosts: List<String> = listOf(host).filter { it.isNotBlank() }.distinct() |
|
|
private val candidateHosts: List<String> = listOf(host).filter { it.isNotBlank() }.distinct() |
|
|
private var currentHostIndex = 0 |
|
|
private var currentHostIndex = 0 |
|
|
|
|
|
|
|
|
@ -60,6 +68,7 @@ class StreamDictationClient( |
|
|
private val isConnecting = AtomicBoolean(false) |
|
|
private val isConnecting = AtomicBoolean(false) |
|
|
private var currentSessionId: String = "" |
|
|
private var currentSessionId: String = "" |
|
|
private var isSessionActive = AtomicBoolean(false) |
|
|
private var isSessionActive = AtomicBoolean(false) |
|
|
|
|
|
private var startFrameSentForSession = AtomicBoolean(false) |
|
|
private var retryAttempt = 0 |
|
|
private var retryAttempt = 0 |
|
|
|
|
|
|
|
|
init { |
|
|
init { |
|
|
@ -78,7 +87,7 @@ class StreamDictationClient( |
|
|
|
|
|
|
|
|
val req = Request.Builder() |
|
|
val req = Request.Builder() |
|
|
.url(wsUrl) |
|
|
.url(wsUrl) |
|
|
.header("User-Agent", "SonioxAndroidRemote/5.6") |
|
|
|
|
|
|
|
|
.header("User-Agent", "SonioxAndroidRemote/5.9") |
|
|
.build() |
|
|
.build() |
|
|
|
|
|
|
|
|
activeWebSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { |
|
|
activeWebSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { |
|
|
@ -89,6 +98,9 @@ class StreamDictationClient( |
|
|
isConnecting.set(false) |
|
|
isConnecting.set(false) |
|
|
retryAttempt = 0 |
|
|
retryAttempt = 0 |
|
|
mainHandler.post { onConnectionStateChanged(true) } |
|
|
mainHandler.post { onConnectionStateChanged(true) } |
|
|
|
|
|
|
|
|
|
|
|
// Flush buffered audio chunks if recording was in progress during reconnect |
|
|
|
|
|
drainAudioQueue(webSocket) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
override fun onMessage(webSocket: WebSocket, text: String) { |
|
|
override fun onMessage(webSocket: WebSocket, text: String) { |
|
|
@ -121,7 +133,7 @@ class StreamDictationClient( |
|
|
"final" -> { |
|
|
"final" -> { |
|
|
val finalText = json.optString("text") |
|
|
val finalText = json.optString("text") |
|
|
isSessionActive.set(false) |
|
|
isSessionActive.set(false) |
|
|
AppLogger.log(tag, "⚡ صوت پردازش شد: '$finalText'") |
|
|
|
|
|
|
|
|
AppLogger.log(tag, "⚡ صوت پردازش و در مک درج شد: '$finalText'") |
|
|
mainHandler.post { onSpeechCompleted(finalText) } |
|
|
mainHandler.post { onSpeechCompleted(finalText) } |
|
|
} |
|
|
} |
|
|
"error" -> { |
|
|
"error" -> { |
|
|
@ -151,7 +163,7 @@ class StreamDictationClient( |
|
|
activeWebSocket = null |
|
|
activeWebSocket = null |
|
|
mainHandler.post { onConnectionStateChanged(false) } |
|
|
mainHandler.post { onConnectionStateChanged(false) } |
|
|
|
|
|
|
|
|
// Fast failover between LAN and WAN |
|
|
|
|
|
|
|
|
// Fast failover retry |
|
|
currentHostIndex++ |
|
|
currentHostIndex++ |
|
|
retryAttempt++ |
|
|
retryAttempt++ |
|
|
val delayMs = if (retryAttempt <= 2) 350L else minOf(500L * (1L shl minOf(retryAttempt, 2)), 2000L) |
|
|
val delayMs = if (retryAttempt <= 2) 350L else minOf(500L * (1L shl minOf(retryAttempt, 2)), 2000L) |
|
|
@ -160,6 +172,22 @@ class StreamDictationClient( |
|
|
}) |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun drainAudioQueue(ws: WebSocket) { |
|
|
|
|
|
if (isSessionActive.get() && !startFrameSentForSession.get()) { |
|
|
|
|
|
val startFrame = JSONObject().apply { |
|
|
|
|
|
put("type", "start") |
|
|
|
|
|
put("session_id", currentSessionId) |
|
|
|
|
|
}.toString() |
|
|
|
|
|
ws.send(startFrame) |
|
|
|
|
|
startFrameSentForSession.set(true) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
while (!audioChunkQueue.isEmpty()) { |
|
|
|
|
|
val chunk = audioChunkQueue.poll() ?: break |
|
|
|
|
|
ws.send(chunk.toByteString()) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* Sends speech chunk directly for pure cursor append on Mac (Cmd+V, NO Cmd+A) |
|
|
* Sends speech chunk directly for pure cursor append on Mac (Cmd+V, NO Cmd+A) |
|
|
*/ |
|
|
*/ |
|
|
@ -223,17 +251,21 @@ class StreamDictationClient( |
|
|
currentSessionId = "sess_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(6)}" |
|
|
currentSessionId = "sess_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(6)}" |
|
|
isRecording.set(true) |
|
|
isRecording.set(true) |
|
|
isSessionActive.set(true) |
|
|
isSessionActive.set(true) |
|
|
|
|
|
startFrameSentForSession.set(false) |
|
|
|
|
|
audioChunkQueue.clear() |
|
|
|
|
|
synchronized(fullAudioSessionBuffer) { fullAudioSessionBuffer.reset() } |
|
|
|
|
|
|
|
|
if (!isConnected.get() || activeWebSocket == null) { |
|
|
if (!isConnected.get() || activeWebSocket == null) { |
|
|
connectWebSocket() |
|
|
connectWebSocket() |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} else { |
|
|
val startFrame = JSONObject().apply { |
|
|
val startFrame = JSONObject().apply { |
|
|
put("type", "start") |
|
|
put("type", "start") |
|
|
put("session_id", currentSessionId) |
|
|
put("session_id", currentSessionId) |
|
|
put("cursor_pos", cursorPos) |
|
|
put("cursor_pos", cursorPos) |
|
|
}.toString() |
|
|
}.toString() |
|
|
activeWebSocket?.send(startFrame) |
|
|
activeWebSocket?.send(startFrame) |
|
|
|
|
|
startFrameSentForSession.set(true) |
|
|
|
|
|
} |
|
|
AppLogger.log(tag, "🎙️ شروع استریم گفتار در موقعیت نشانگر $cursorPos...") |
|
|
AppLogger.log(tag, "🎙️ شروع استریم گفتار در موقعیت نشانگر $cursorPos...") |
|
|
|
|
|
|
|
|
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) |
|
|
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) |
|
|
@ -266,7 +298,16 @@ class StreamDictationClient( |
|
|
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1 |
|
|
val bytesRead = audioRecord?.read(chunk, 0, chunk.size) ?: -1 |
|
|
if (bytesRead > 0) { |
|
|
if (bytesRead > 0) { |
|
|
val slice = if (bytesRead == chunk.size) chunk.clone() else chunk.copyOf(bytesRead) |
|
|
val slice = if (bytesRead == chunk.size) chunk.clone() else chunk.copyOf(bytesRead) |
|
|
activeWebSocket?.send(slice.toByteString()) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Buffer in memory for zero loss |
|
|
|
|
|
audioChunkQueue.offer(slice) |
|
|
|
|
|
synchronized(fullAudioSessionBuffer) { fullAudioSessionBuffer.write(slice) } |
|
|
|
|
|
|
|
|
|
|
|
// Drain live over active WebSocket |
|
|
|
|
|
val ws = activeWebSocket |
|
|
|
|
|
if (ws != null && isConnected.get()) { |
|
|
|
|
|
drainAudioQueue(ws) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
var sum = 0.0 |
|
|
var sum = 0.0 |
|
|
val samplesCount = bytesRead / 2 |
|
|
val samplesCount = bytesRead / 2 |
|
|
@ -309,13 +350,58 @@ class StreamDictationClient( |
|
|
Log.e(tag, "Error releasing audio hardware", e) |
|
|
Log.e(tag, "Error releasing audio hardware", e) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
val ws = activeWebSocket |
|
|
|
|
|
if (ws != null && isConnected.get()) { |
|
|
|
|
|
drainAudioQueue(ws) |
|
|
val stopFrame = JSONObject().apply { |
|
|
val stopFrame = JSONObject().apply { |
|
|
put("type", "stop") |
|
|
put("type", "stop") |
|
|
put("session_id", currentSessionId) |
|
|
put("session_id", currentSessionId) |
|
|
put("cursor_pos", cursorPos) |
|
|
put("cursor_pos", cursorPos) |
|
|
}.toString() |
|
|
}.toString() |
|
|
activeWebSocket?.send(stopFrame) |
|
|
|
|
|
AppLogger.log(tag, "⏹️ پایان ضبط. دریافت متن نهایی...") |
|
|
|
|
|
|
|
|
ws.send(stopFrame) |
|
|
|
|
|
AppLogger.log(tag, "⏹️ پایان ضبط. استریم مستقیم به مک...") |
|
|
|
|
|
} else { |
|
|
|
|
|
// High-Speed HTTP Dictate Fallback |
|
|
|
|
|
val pcmData = synchronized(fullAudioSessionBuffer) { fullAudioSessionBuffer.toByteArray() } |
|
|
|
|
|
if (pcmData.size >= 3200) { |
|
|
|
|
|
AppLogger.log(tag, "⚠️ سوکت متصل نبود - ارسال سریع از طریق HTTP Fallback (${pcmData.size} بایت)...") |
|
|
|
|
|
sendHttpDictateFallback(pcmData, cursorPos) |
|
|
|
|
|
} else { |
|
|
|
|
|
AppLogger.log(tag, "صدا خیلی کوتاه بود.") |
|
|
|
|
|
mainHandler.post { onSpeechCompleted("") } |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun sendHttpDictateFallback(pcmData: ByteArray, cursorPos: Int) { |
|
|
|
|
|
Thread { |
|
|
|
|
|
val targetHost = candidateHosts[currentHostIndex % candidateHosts.size] |
|
|
|
|
|
val cleanHost = targetHost.removePrefix("http://").removePrefix("https://").removePrefix("ws://").removePrefix("wss://") |
|
|
|
|
|
val url = "http://$cleanHost/dictate" |
|
|
|
|
|
try { |
|
|
|
|
|
val mediaType = "application/octet-stream".toMediaType() |
|
|
|
|
|
val body = pcmData.toRequestBody(mediaType) |
|
|
|
|
|
val req = Request.Builder() |
|
|
|
|
|
.url(url) |
|
|
|
|
|
.post(body) |
|
|
|
|
|
.build() |
|
|
|
|
|
okHttpClient.newCall(req).execute().use { resp -> |
|
|
|
|
|
val respStr = resp.body?.string() ?: "" |
|
|
|
|
|
if (resp.isSuccessful) { |
|
|
|
|
|
val json = JSONObject(respStr) |
|
|
|
|
|
val text = json.optString("text", "").trim() |
|
|
|
|
|
AppLogger.log(tag, "⚡ صوت از طریق HTTP Fallback پردازش و در مک درج شد: '$text'") |
|
|
|
|
|
mainHandler.post { onSpeechCompleted(text) } |
|
|
|
|
|
} else { |
|
|
|
|
|
AppLogger.log(tag, "❌ خطای HTTP Fallback: ${resp.code}") |
|
|
|
|
|
mainHandler.post { onError("خطای پردازش سرور") } |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
|
AppLogger.log(tag, "❌ خطای ارسال HTTP Fallback: ${e.message}") |
|
|
|
|
|
mainHandler.post { onError("عدم امکان ارتباط با سرور") } |
|
|
|
|
|
} |
|
|
|
|
|
}.start() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
fun release() { |
|
|
fun release() { |
|
|
|