diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt index 5cae21a89..51e15ad21 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/XunFeiAsrHelper.kt @@ -3,207 +3,162 @@ package com.yunqiinnovation.azure_speech import android.content.Context import android.util.Base64 import android.util.Log -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.WebSocket -import okhttp3.WebSocketListener +import okhttp3.* import okio.ByteString import org.json.JSONObject -import java.io.File -import java.io.FileInputStream import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.UUID -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean +import java.security.MessageDigest import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec class XunFeiAsrHelper(private val context: Context) { - private val tag = "XunFeiAsrHelper" - - private val appId = "43d87d22" - private val accessKeyId = "2bd01ce06ac35957fc0c082f9b6284ec" - - private val audioEncode = "pcm_s16le" - private val lang = "autodialect" - private val sampleRate = "16000" - private val audioFrameSize = 1280 - private val frameIntervalMs = 40 - private val baseWsUrl = "wss://office-api-ast-dx.iflyaisol.com/ast/communicate/v1" - private val client = OkHttpClient.Builder().retryOnConnectionFailure(true).build() - - private var webSocket: WebSocket? = null - private val isConnected = AtomicBoolean(false) - private val isSendingAudio = AtomicBoolean(false) - private var sessionId: String? = null - private var audioFileSize: Long = 0 - - private fun getAudioFileSize(audioPath: String): Long { - val file = File(audioPath) - return if (file.exists() && file.isFile) file.length() else 0L - } + private val tag = "XunFeiAsrHelper" - private fun getUtcTime(): String { - val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US) - sdf.timeZone = TimeZone.getTimeZone("GMT+8") - return sdf.format(Date()) - } + private val appId = "43d87d22" + private val accessKeyId = "2bd01ce06ac35957fc0c082f9b6284ec" - private fun calculateSignature(params: Map, accessKeySecret: String): String { - val baseStr = buildString { - var first = true - for ((key, value) in params.toSortedMap()) { - if (key == "signature") continue - if (value.isBlank()) continue - if (!first) append("&") - append(URLEncoder.encode(key, StandardCharsets.UTF_8.name())) - append("=") - append(URLEncoder.encode(value, StandardCharsets.UTF_8.name())) - first = false - } - } - val mac = Mac.getInstance("HmacSHA1") - val keySpec = SecretKeySpec(accessKeySecret.toByteArray(StandardCharsets.UTF_8), "HmacSHA1") - mac.init(keySpec) - val signBytes = mac.doFinal(baseStr.toByteArray(StandardCharsets.UTF_8)) - return Base64.encodeToString(signBytes, Base64.NO_WRAP) + private var webSocket: WebSocket? = null + private val client = OkHttpClient() + + // Status: "UNDEFINED", "CONNECTING", "OPEN", "CLOSING", "CLOSED" + private var btnStatus = "UNDEFINED" + + private var resultText = "" + + var onResult: ((String) -> Unit)? = null + var onError: ((String) -> Unit)? = null + var onStatusChange: ((String) -> Unit)? = null + + fun connectWebSocket() { + val url = getWebSocketUrl() + val request = Request.Builder().url(url).build() + + changeBtnStatus("CONNECTING") + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(tag, "WebSocket Opened") + changeBtnStatus("OPEN") + } + + override fun onMessage(webSocket: WebSocket, text: String) { + renderResult(text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.d(tag, "WebSocket Closing: $code / $reason") + changeBtnStatus("CLOSING") + webSocket.close(1000, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(tag, "WebSocket Closed: $code / $reason") + changeBtnStatus("CLOSED") + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(tag, "WebSocket Error", t) + changeBtnStatus("CLOSED") + onError?.invoke(t.message ?: "WebSocket connection failed") + } + }) + } + + fun sendAudio(frameBuffer: ByteArray) { + webSocket?.let { ws -> + ws.send(ByteString.of(frameBuffer, 0, frameBuffer.size)) } + } - private fun buildParamsString(params: Map): String { - val sb = StringBuilder() - var first = true - for ((key, value) in params) { - if (!first) sb.append("&") - sb.append(URLEncoder.encode(key, StandardCharsets.UTF_8.name())) - sb.append("=") - sb.append(URLEncoder.encode(value, StandardCharsets.UTF_8.name())) - first = false - } - return sb.toString() + fun close() { + webSocket?.let { ws -> + ws.send("{\"end\": true}") + changeBtnStatus("CLOSING") } + } - fun connect(appId: String, accessKeyId: String, accessKeySecret: String): Boolean { - return try { - val params = mutableMapOf() - params["audio_encode"] = audioEncode - params["lang"] = lang - params["samplerate"] = sampleRate - params["accessKeyId"] = accessKeyId - params["appId"] = appId - params["uuid"] = UUID.randomUUID().toString().replace("-", "") - params["utc"] = getUtcTime() - params["signature"] = calculateSignature(params, accessKeySecret) - val fullUrl = "$baseWsUrl?${buildParamsString(params)}" - - val request = Request.Builder().url(fullUrl).build() - val latch = CountDownLatch(1) - val listener = object : WebSocketListener() { - override fun onOpen(ws: WebSocket, response: okhttp3.Response) { - webSocket = ws - isConnected.set(true) - latch.countDown() - } - - override fun onMessage(ws: WebSocket, text: String) { - try { - val json = JSONObject(text) - if (json.optString("msg_type") == "action") { - val data = json.optJSONObject("data") - val sid = data?.optString("sessionId") - if (!sid.isNullOrEmpty()) { - sessionId = sid - } - } - } catch (e: Exception) { - Log.d(tag, "onMessage parse error: ${e.message}") - } - } - - override fun onClosed(ws: WebSocket, code: Int, reason: String) { - isConnected.set(false) - } - - override fun onFailure(ws: WebSocket, t: Throwable, response: okhttp3.Response?) { - isConnected.set(false) - Log.e(tag, "WebSocket failure: ${t.message}") - } - } - client.newWebSocket(request, listener) - latch.await(15, TimeUnit.SECONDS) - isConnected.get() - } catch (e: Exception) { - Log.e(tag, "connect failed: ${e.message}") - false - } + private fun changeBtnStatus(status: String) { + btnStatus = status + if (status == "CONNECTING") { + resultText = "" } + onStatusChange?.invoke(status) + } - fun sendAudio(audioPath: String): Boolean { - if (!isConnected.get() || webSocket == null || isSendingAudio.get()) { - Log.d(tag, "sendAudio invalid state") - return false - } - isSendingAudio.set(true) - audioFileSize = getAudioFileSize(audioPath) - return try { - FileInputStream(audioPath).use { fis -> - val buffer = ByteArray(audioFrameSize) - var frameIndex = 0 - var startTime: Long? = null - while (true) { - val bytesRead = fis.read(buffer) - if (bytesRead == -1) break - val frameData = if (bytesRead == audioFrameSize) buffer else buffer.copyOf(bytesRead) - if (startTime == null) startTime = System.currentTimeMillis() - val expectedSendTime = startTime!! + frameIndex * frameIntervalMs - val now = System.currentTimeMillis() - val diff = expectedSendTime - now - if (diff > 1) Thread.sleep(diff) - webSocket?.send(ByteString.of(frameData, 0, frameData.size)) - frameIndex++ - } - val endMsg = JSONObject() - endMsg.put("end", true) - sessionId?.let { endMsg.put("sessionId", it) } - webSocket?.send(endMsg.toString()) - true - } - } catch (e: Exception) { - Log.e(tag, "sendAudio error: ${e.message}") - false - } finally { - isSendingAudio.set(false) + private fun renderResult(resultData: String) { + try { + val jsonData = JSONObject(resultData) + val action = jsonData.optString("action") + + when (action) { + "started" -> { + Log.d(tag, "Handshake success") } - } - fun runDemo(appId: String, accessKeyId: String, accessKeySecret: String, audioPath: String) { - try { - if (!connect(appId, accessKeyId, accessKeySecret)) return - if (!sendAudio(audioPath)) return - val estimatedDurationSec = (((audioFileSize / audioFrameSize) * frameIntervalMs) / 1000).toInt() - val waitTime = estimatedDurationSec + 5 - for (i in 0..waitTime) { - if (!isConnected.get()) break - Thread.sleep(1000) + "result" -> { + val dataStr = jsonData.optString("data") + val data = JSONObject(dataStr) + + val cn = data.optJSONObject("cn") ?: return + val st = cn.optJSONObject("st") ?: return + val rt = st.optJSONArray("rt") ?: return + + var resultTextTemp = "" + + for (i in 0 until rt.length()) { + val j = rt.getJSONObject(i) + val ws = j.optJSONArray("ws") ?: continue + for (k in 0 until ws.length()) { + val kObj = ws.getJSONObject(k) + val cw = kObj.optJSONArray("cw") ?: continue + for (l in 0 until cw.length()) { + val lObj = cw.getJSONObject(l) + resultTextTemp += lObj.optString("w") + } } - } catch (_: InterruptedException) { - } finally { - close() + } + + if (st.optInt("type") == 0) { + resultText += resultTextTemp + onResult?.invoke(resultText) + } else { + onResult?.invoke(resultText + resultTextTemp) + } } - } - fun close() { - try { - isConnected.set(false) - webSocket?.close(1000, "normal") - } catch (_: Exception) { - } finally { - webSocket = null + "error" -> { + Log.e(tag, "Error: $resultData") + onError?.invoke(jsonData.toString()) } + } + } catch (e: Exception) { + Log.e(tag, "Parse error", e) } + } + + private fun getWebSocketUrl(): String { + val url = "wss://rtasr.xfyun.cn/v1/ws" + val ts = (System.currentTimeMillis() / 1000).toString() + val baseString = appId + ts + val signa = md5(baseString) + val signatureSha = hmacSha1(signa, accessKeyId) + val signature = Base64.encodeToString(signatureSha, Base64.NO_WRAP) + val encodedSignature = URLEncoder.encode(signature, "UTF-8") + + return "$url?appid=$appId&ts=$ts&signa=$encodedSignature" + } + + private fun md5(string: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(string.toByteArray(StandardCharsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } + + private fun hmacSha1(data: String, key: String): ByteArray { + val secretKeySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), "HmacSHA1") + val mac = Mac.getInstance("HmacSHA1") + mac.init(secretKeySpec) + return mac.doFinal(data.toByteArray(StandardCharsets.UTF_8)) + } }