|
|
|
@ -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<String, String>, 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, String>): 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<String, String>() |
|
|
|
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)) |
|
|
|
} |
|
|
|
} |
|
|
|
|