13 changed files with 1885 additions and 959 deletions
File diff suppressed because it is too large
@ -0,0 +1,783 @@ |
|||||
|
package com.yunqiinnovation.agent_service |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.MediaPlayer |
||||
|
import android.media.MediaRecorder |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.util.Log |
||||
|
import com.yunqiinnovation.chat_storage.ChatStorageHelper |
||||
|
import kotlinx.coroutines.* |
||||
|
import okhttp3.* |
||||
|
import okhttp3.MediaType.Companion.toMediaType |
||||
|
import okhttp3.RequestBody.Companion.toRequestBody |
||||
|
import okio.ByteString.Companion.toByteString |
||||
|
import org.json.JSONArray |
||||
|
import org.json.JSONObject |
||||
|
import java.io.IOException |
||||
|
import java.security.MessageDigest |
||||
|
import java.security.SecureRandom |
||||
|
import java.security.cert.X509Certificate |
||||
|
import java.util.UUID |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
import javax.net.ssl.SSLContext |
||||
|
import javax.net.ssl.TrustManager |
||||
|
import javax.net.ssl.X509TrustManager |
||||
|
import kotlin.coroutines.resumeWithException |
||||
|
import kotlin.math.sqrt |
||||
|
|
||||
|
/** |
||||
|
* STS 配置参数(移动精灵 A80 平台) |
||||
|
*/ |
||||
|
data class StsConfig( |
||||
|
val productId: String, |
||||
|
val productKey: String, |
||||
|
var deviceNo: String, |
||||
|
var deviceId: String, |
||||
|
var deviceSecret: String, |
||||
|
val agentId: String, |
||||
|
) |
||||
|
|
||||
|
/** |
||||
|
* StsAgent — 中国移动 A80 WS 一体化语音对话实现 |
||||
|
* |
||||
|
* 通过一条 WSS 连接完成 ASR + AI + TTS,事件以与标准 stt_llm_tts 相同的格式 |
||||
|
* 通过 [onEvent] 回调发出,供 AgentService 转发到 Flutter EventChannel。 |
||||
|
*/ |
||||
|
class StsAgent( |
||||
|
private val context: Context, |
||||
|
private var config: StsConfig, |
||||
|
private val onEvent: (eventName: String, data: Map<String, Any>) -> Unit, |
||||
|
private val onCredentialsFetched: ((deviceId: String, deviceSecret: String, deviceNo: String) -> Unit)? = null, |
||||
|
) : CoroutineScope { |
||||
|
|
||||
|
private val TAG = "StsAgent" |
||||
|
private val job = SupervisorJob() |
||||
|
override val coroutineContext = Dispatchers.IO + job |
||||
|
|
||||
|
init { |
||||
|
fetchCredentials() |
||||
|
} |
||||
|
|
||||
|
// ── WebSocket ───────────────────────────────────────────────────────────── |
||||
|
private val okHttpClient: OkHttpClient = buildTrustAllClient() |
||||
|
private var webSocket: WebSocket? = null |
||||
|
private var readyReceived = false |
||||
|
private var sessionEnded = true |
||||
|
private var dialogRequestId = "" |
||||
|
|
||||
|
// ── 录音 ────────────────────────────────────────────────────────────────── |
||||
|
private val SAMPLE_RATE = 16000 |
||||
|
private val FRAME_SIZE = 5120 // 160ms @ 16kHz 16bit mono |
||||
|
private val RECORD_CHUNK = FRAME_SIZE / 2 // 读取块大小 |
||||
|
private val RECORD_BUFFER = maxOf( |
||||
|
AudioRecord.getMinBufferSize( |
||||
|
SAMPLE_RATE, |
||||
|
AudioFormat.CHANNEL_IN_MONO, |
||||
|
AudioFormat.ENCODING_PCM_16BIT |
||||
|
), FRAME_SIZE * 2 |
||||
|
) |
||||
|
private var audioRecord: AudioRecord? = null |
||||
|
private var recordingJob: Job? = null |
||||
|
private var micChunkCount = 0 |
||||
|
|
||||
|
// ── 发送缓冲 ────────────────────────────────────────────────────────────── |
||||
|
private val sendBuffer = ArrayDeque<Byte>() |
||||
|
private var sendBufferOffset = 0 |
||||
|
|
||||
|
// ── 预缓冲(VAD 触发前 ~1.5s)──────────────────────────────────────────── |
||||
|
private val preBuffer = ArrayDeque<ByteArray>() |
||||
|
private val PRE_BUFFER_MAX_CHUNKS = 75 |
||||
|
|
||||
|
// ── 本地 VAD ────────────────────────────────────────────────────────────── |
||||
|
private var speechDetected = false |
||||
|
private val VAD_THRESHOLD = 800.0 |
||||
|
private val VAD_ONSET_CHUNKS = 3 |
||||
|
private var vadOnsetCount = 0 |
||||
|
|
||||
|
// ── 状态标记 ────────────────────────────────────────────────────────────── |
||||
|
private var forwardingAudio = false |
||||
|
private var wsConnecting = false |
||||
|
private var inCall = false |
||||
|
private var pttRecording = false |
||||
|
|
||||
|
// ── TTS 播放队列 ────────────────────────────────────────────────────────── |
||||
|
private val ttsQueue = ArrayDeque<String>() |
||||
|
private var ttsPlaying = false |
||||
|
private var ttsInterrupted = false |
||||
|
private var currentPlayer: MediaPlayer? = null |
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
/** 静音开关:设为 true 时停止当前播放、跳过后续 TTS,文本接收完后主动关闭连接 */ |
||||
|
var ttsMuted = false |
||||
|
set(value) { |
||||
|
field = value |
||||
|
if (value) { |
||||
|
// 立即停止当前播放 |
||||
|
ttsQueue.clear() |
||||
|
ttsPlaying = false |
||||
|
mainHandler.post { |
||||
|
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {} |
||||
|
currentPlayer = null |
||||
|
} |
||||
|
emit("tts_canceled", emptyMap()) |
||||
|
// 如果会话已结束(文本已全部接收),主动关闭连接 |
||||
|
if (sessionEnded) { |
||||
|
closeWs() |
||||
|
if (inCall) { resetVad(); startIdleTimer() } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ── 消息历史(WS 上下文) ────────────────────────────────────────────────── |
||||
|
private var messages = JSONArray() |
||||
|
private val chatStorageHelper = ChatStorageHelper(context) |
||||
|
private var currentSessionId = "" |
||||
|
private var pendingAnswer = "" // 累积最完整的回复文本,会话结束时统一发送 |
||||
|
|
||||
|
// ── 计时器 ──────────────────────────────────────────────────────────────── |
||||
|
private var micWatchdogJob: Job? = null |
||||
|
private var lastWatchdogChunkCount = 0 |
||||
|
private var idleTimerJob: Job? = null |
||||
|
private val IDLE_TIMEOUT_MS = 15_000L |
||||
|
|
||||
|
// ── 凭证就绪 ────────────────────────────────────────────────────────────── |
||||
|
var credentialReady = false |
||||
|
private set |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 凭证获取 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
fun fetchCredentials() { |
||||
|
if (config.deviceSecret.isNotEmpty()) { |
||||
|
credentialReady = true |
||||
|
return |
||||
|
} |
||||
|
launch { |
||||
|
try { |
||||
|
val body = JSONObject().apply { |
||||
|
put("deviceNoType", "SN") |
||||
|
put("deviceNo", config.deviceNo) |
||||
|
put("productId", config.productId) |
||||
|
put("productKey", config.productKey) |
||||
|
}.toString().toRequestBody("application/json".toMediaType()) |
||||
|
|
||||
|
val req = Request.Builder() |
||||
|
.url("https://ivs.chinamobiledevice.com:11443/v2/customer/device/secret/info") |
||||
|
.post(body) |
||||
|
.build() |
||||
|
|
||||
|
okHttpClient.newCall(req).execute().use { resp -> |
||||
|
val json = JSONObject(resp.body!!.string()) |
||||
|
if (json.getString("code") == "200" && json.getBoolean("success")) { |
||||
|
val d = json.getJSONObject("data") |
||||
|
config = config.copy( |
||||
|
deviceNo = d.optString("deviceNo", config.deviceNo), |
||||
|
deviceId = d.optString("deviceId", ""), |
||||
|
deviceSecret = d.optString("deviceSecret", ""), |
||||
|
) |
||||
|
credentialReady = config.deviceSecret.isNotEmpty() |
||||
|
if (credentialReady) { |
||||
|
onCredentialsFetched?.invoke(config.deviceId, config.deviceSecret, config.deviceNo) |
||||
|
} |
||||
|
} else { |
||||
|
Log.e(TAG, "凭证获取失败: ${json.optString("message")}") |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "凭证获取异常: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 公开接口 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
/** 通话模式:开启麦克风,VAD 自动触发 WS */ |
||||
|
fun startCall() { |
||||
|
if (inCall) return |
||||
|
if (ttsPlaying) interruptTts() |
||||
|
if (pttRecording) stopPtt(cancel = true) |
||||
|
if (!credentialReady) { emitError("移动精灵凭证未就绪,请稍后重试"); return } |
||||
|
openMic() |
||||
|
startMicWatchdog() |
||||
|
inCall = true |
||||
|
startIdleTimer() |
||||
|
} |
||||
|
|
||||
|
/** PTT 模式:立即连 WS 并开始录音 */ |
||||
|
fun startPtt() { |
||||
|
if (pttRecording) return |
||||
|
if (ttsPlaying) interruptTts() |
||||
|
if (!credentialReady) { emitError("移动精灵凭证未就绪"); return } |
||||
|
|
||||
|
pttRecording = true |
||||
|
openMic() |
||||
|
startMicWatchdog() |
||||
|
ttsInterrupted = false |
||||
|
closeWs() |
||||
|
sessionEnded = false |
||||
|
wsConnecting = true |
||||
|
dialogRequestId = UUID.randomUUID().toString() |
||||
|
clearSendBuffer() |
||||
|
pendingAnswer = "" |
||||
|
|
||||
|
launch { |
||||
|
try { |
||||
|
connectWs() |
||||
|
forwardingAudio = true |
||||
|
wsConnecting = false |
||||
|
emit("recognition_started", emptyMap()) |
||||
|
} catch (e: Exception) { |
||||
|
wsConnecting = false |
||||
|
pttRecording = false |
||||
|
stopMicWatchdog() |
||||
|
emitError("连接失败: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** PTT 松开:发送剩余音频 + finish */ |
||||
|
fun stopPtt(cancel: Boolean = false) { |
||||
|
pttRecording = false |
||||
|
stopMicWatchdog() |
||||
|
if (cancel) { |
||||
|
forwardingAudio = false |
||||
|
closeWs() |
||||
|
stopMicHard() |
||||
|
return |
||||
|
} |
||||
|
if (forwardingAudio) { |
||||
|
forwardingAudio = false |
||||
|
synchronized(sendBufferLock) { |
||||
|
val remain = sendBuffer.size - sendBufferOffset |
||||
|
if (remain > 0) { |
||||
|
sendAudioChunk(sendBuffer.toMutableList().subList(sendBufferOffset, sendBuffer.size).toByteArray()) |
||||
|
} |
||||
|
sendBuffer.clear(); sendBufferOffset = 0 |
||||
|
} |
||||
|
sendFinish() |
||||
|
emit("recognition_stopped", emptyMap()) |
||||
|
} |
||||
|
stopMicHard() |
||||
|
} |
||||
|
|
||||
|
/** 统一停止:根据当前状态自动选择 stopPtt 或 disconnect */ |
||||
|
fun stop() { |
||||
|
if (pttRecording) stopPtt() |
||||
|
else disconnect() |
||||
|
} |
||||
|
|
||||
|
/** 挂断通话 */ |
||||
|
fun disconnect() { |
||||
|
stopMicWatchdog() |
||||
|
cancelIdleTimer() |
||||
|
forwardingAudio = false |
||||
|
wsConnecting = false |
||||
|
speechDetected = false |
||||
|
stopMicHard() |
||||
|
ttsInterrupted = true |
||||
|
ttsQueue.clear() |
||||
|
ttsPlaying = false |
||||
|
mainHandler.post { |
||||
|
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {} |
||||
|
currentPlayer = null |
||||
|
} |
||||
|
closeWs() |
||||
|
preBuffer.clear() |
||||
|
clearSendBuffer() |
||||
|
flushPendingAnswer() |
||||
|
inCall = false |
||||
|
pttRecording = false |
||||
|
} |
||||
|
|
||||
|
/** 打断 AI 播报 */ |
||||
|
fun interruptSpeaking() { |
||||
|
interruptTts() |
||||
|
ttsInterrupted = false |
||||
|
if (inCall) resetVad() |
||||
|
} |
||||
|
|
||||
|
/** 清空消息历史 */ |
||||
|
fun clearHistory() { |
||||
|
messages = JSONArray() |
||||
|
} |
||||
|
|
||||
|
/** 释放资源 */ |
||||
|
fun dispose() { |
||||
|
disconnect() |
||||
|
job.cancel() |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 麦克风 & 本地 VAD |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun openMic() { |
||||
|
stopMicHard() |
||||
|
speechDetected = false; vadOnsetCount = 0; micChunkCount = 0 |
||||
|
try { |
||||
|
audioRecord = AudioRecord( |
||||
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
||||
|
SAMPLE_RATE, |
||||
|
AudioFormat.CHANNEL_IN_MONO, |
||||
|
AudioFormat.ENCODING_PCM_16BIT, |
||||
|
RECORD_BUFFER |
||||
|
) |
||||
|
audioRecord?.startRecording() |
||||
|
recordingJob = launch { |
||||
|
val buf = ByteArray(RECORD_CHUNK) |
||||
|
while (isActive && (inCall || pttRecording)) { |
||||
|
val read = audioRecord?.read(buf, 0, buf.size) ?: break |
||||
|
if (read > 0) { micChunkCount++; onMicAudio(buf.copyOf(read)) } |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "打开麦克风失败: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun stopMicHard() { |
||||
|
recordingJob?.cancel(); recordingJob = null |
||||
|
try { audioRecord?.stop(); audioRecord?.release() } catch (_: Exception) {} |
||||
|
audioRecord = null |
||||
|
} |
||||
|
|
||||
|
private fun startMicWatchdog() { |
||||
|
stopMicWatchdog() |
||||
|
lastWatchdogChunkCount = micChunkCount |
||||
|
micWatchdogJob = launch { |
||||
|
while (isActive && (inCall || pttRecording)) { |
||||
|
delay(1500) |
||||
|
if (micChunkCount == lastWatchdogChunkCount && !ttsPlaying) openMic() |
||||
|
lastWatchdogChunkCount = micChunkCount |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun stopMicWatchdog() { micWatchdogJob?.cancel(); micWatchdogJob = null } |
||||
|
|
||||
|
private fun onMicAudio(chunk: ByteArray) { |
||||
|
if (!inCall && !pttRecording) return |
||||
|
if (forwardingAudio && webSocket != null) { |
||||
|
if (micChunkCount % 3 == 0) emit("mic_rms", mapOf("rms" to calcRms(chunk))) |
||||
|
feedSendBuffer(chunk); return |
||||
|
} |
||||
|
if (wsConnecting) { addToPreBuffer(chunk); return } |
||||
|
if (ttsPlaying) return |
||||
|
|
||||
|
addToPreBuffer(chunk) |
||||
|
val rms = calcRms(chunk) |
||||
|
// 发送麦克风音量给 Dart 层 |
||||
|
if (micChunkCount % 3 == 0) { |
||||
|
emit("mic_rms", mapOf("rms" to rms)) |
||||
|
} |
||||
|
if (rms >= VAD_THRESHOLD) { |
||||
|
vadOnsetCount++ |
||||
|
if (vadOnsetCount >= VAD_ONSET_CHUNKS && !speechDetected) { |
||||
|
speechDetected = true |
||||
|
cancelIdleTimer() |
||||
|
launch { onSpeechDetected() } |
||||
|
} |
||||
|
} else { |
||||
|
vadOnsetCount = 0 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun calcRms(pcm: ByteArray): Double { |
||||
|
if (pcm.size < 2) return 0.0 |
||||
|
val samples = pcm.size / 2 |
||||
|
var sum = 0.0 |
||||
|
for (i in 0 until pcm.size - 1 step 2) { |
||||
|
var s = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8) |
||||
|
if (s >= 32768) s -= 65536 |
||||
|
sum += s.toLong() * s.toLong() |
||||
|
} |
||||
|
return sqrt(sum / samples) |
||||
|
} |
||||
|
|
||||
|
private fun addToPreBuffer(chunk: ByteArray) { |
||||
|
preBuffer.addLast(chunk.copyOf()) |
||||
|
while (preBuffer.size > PRE_BUFFER_MAX_CHUNKS) preBuffer.removeFirst() |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// VAD 触发 → WS 连接 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private suspend fun onSpeechDetected() { |
||||
|
if (wsConnecting) return |
||||
|
ttsInterrupted = false |
||||
|
closeWs() |
||||
|
sessionEnded = false |
||||
|
wsConnecting = true |
||||
|
dialogRequestId = UUID.randomUUID().toString() |
||||
|
clearSendBuffer() |
||||
|
pendingAnswer = "" |
||||
|
|
||||
|
try { |
||||
|
connectWs() |
||||
|
} catch (e: Exception) { |
||||
|
wsConnecting = false; speechDetected = false |
||||
|
emitError("连接失败: ${e.message}") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
forwardingAudio = true; wsConnecting = false |
||||
|
emit("recognition_started", emptyMap()) |
||||
|
|
||||
|
val cached = preBuffer.toList(); preBuffer.clear() |
||||
|
for (c in cached) feedSendBuffer(c) |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// WebSocket |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private suspend fun connectWs(): Unit = suspendCancellableCoroutine { cont -> |
||||
|
val ts = System.currentTimeMillis() |
||||
|
val sign = calcSign(config.deviceSecret, ts.toString()) |
||||
|
val sn = UUID.randomUUID().toString() |
||||
|
val url = "wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr" + |
||||
|
"?deviceNo=${config.deviceNo}&deviceId=${config.deviceId}" + |
||||
|
"&productId=${config.productId}&productKey=${config.productKey}" + |
||||
|
"&sign=$sign&sn=$sn&ts=$ts" |
||||
|
|
||||
|
val req = Request.Builder().url(url) |
||||
|
.header("Origin", "https://ivs.chinamobiledevice.com:11443") |
||||
|
.header("Host", "ivs.chinamobiledevice.com:11443") |
||||
|
.build() |
||||
|
|
||||
|
var resolved = false |
||||
|
|
||||
|
webSocket = okHttpClient.newWebSocket(req, object : WebSocketListener() { |
||||
|
override fun onOpen(ws: WebSocket, response: Response) { |
||||
|
sendStartFrame() |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(ws: WebSocket, text: String) { |
||||
|
try { |
||||
|
val msg = JSONObject(text) |
||||
|
if (msg.optString("type") == "ready") { |
||||
|
if (!resolved) { resolved = true; cont.resume(Unit) {} } |
||||
|
} else { |
||||
|
launch { handleMessage(msg) } |
||||
|
} |
||||
|
} catch (_: Exception) {} |
||||
|
} |
||||
|
|
||||
|
override fun onMessage(ws: WebSocket, bytes: okio.ByteString) {} |
||||
|
|
||||
|
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { |
||||
|
webSocket = null |
||||
|
if (!resolved) { resolved = true; cont.resumeWithException(t) } |
||||
|
else launch { handleWsDone() } |
||||
|
} |
||||
|
|
||||
|
override fun onClosing(ws: WebSocket, code: Int, reason: String) { ws.close(1000, null) } |
||||
|
|
||||
|
override fun onClosed(ws: WebSocket, code: Int, reason: String) { |
||||
|
webSocket = null |
||||
|
if (!resolved) { resolved = true; cont.resumeWithException(IOException("WS closed")) } |
||||
|
else launch { handleWsDone() } |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
cont.invokeOnCancellation { closeWs() } |
||||
|
|
||||
|
// 15s 超时 |
||||
|
launch { |
||||
|
delay(15_000) |
||||
|
if (!resolved) { |
||||
|
resolved = true; closeWs() |
||||
|
cont.resumeWithException(IOException("等待 ready 超时")) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun handleWsDone() { |
||||
|
webSocket = null; forwardingAudio = false; wsConnecting = false |
||||
|
when { |
||||
|
sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() } |
||||
|
sessionEnded -> { /* PTT 结束,等 TTS 播完 */ } |
||||
|
inCall -> resetVad() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun resetVad() { |
||||
|
speechDetected = false; vadOnsetCount = 0; preBuffer.clear(); openMic() |
||||
|
} |
||||
|
|
||||
|
private fun closeWs() { |
||||
|
try { webSocket?.cancel() } catch (_: Exception) {} |
||||
|
webSocket = null |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 发送帧 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun sendStartFrame() { |
||||
|
currentSessionId = UUID.randomUUID().toString() |
||||
|
val payload = JSONObject().apply { |
||||
|
put("type", "start") |
||||
|
put("data", JSONObject().apply { |
||||
|
put("cuid", config.deviceId) |
||||
|
put("format", "pcm") |
||||
|
put("sample", 16000) |
||||
|
put("support_dcs", 2) |
||||
|
put("chunk_size", 10240) |
||||
|
put("support_tts", true) |
||||
|
put("support_text2dcs", true) |
||||
|
put("user_agent", "voitrans/1.0") |
||||
|
put("dialog_request_id", dialogRequestId) |
||||
|
put("access_rc", true) |
||||
|
put("support_part_tts", true) |
||||
|
put("need_stoplisten", true) |
||||
|
put("need_dialogue_finish", true) |
||||
|
put("result_trans2directive", false) |
||||
|
put("rc_version", "5") |
||||
|
put("messages", messages) |
||||
|
put("client_context", JSONArray()) |
||||
|
}) |
||||
|
} |
||||
|
webSocket?.send(payload.toString()) |
||||
|
} |
||||
|
|
||||
|
private val sendBufferLock = Any() |
||||
|
|
||||
|
private fun clearSendBuffer() { |
||||
|
synchronized(sendBufferLock) { |
||||
|
sendBuffer.clear(); sendBufferOffset = 0 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun feedSendBuffer(chunk: ByteArray) { |
||||
|
synchronized(sendBufferLock) { |
||||
|
sendBuffer.addAll(chunk.toList()) |
||||
|
while (sendBuffer.size - sendBufferOffset >= FRAME_SIZE) { |
||||
|
val frame = ByteArray(FRAME_SIZE) { i -> sendBuffer[sendBufferOffset + i] } |
||||
|
sendBufferOffset += FRAME_SIZE |
||||
|
sendAudioChunk(frame) |
||||
|
} |
||||
|
// 收缩缓冲 |
||||
|
if (sendBufferOffset >= FRAME_SIZE * 8) { |
||||
|
val toRemove = sendBufferOffset |
||||
|
repeat(toRemove) { if (sendBuffer.isNotEmpty()) sendBuffer.removeFirst() } |
||||
|
sendBufferOffset = 0 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun sendAudioChunk(data: ByteArray) { |
||||
|
try { webSocket?.send(data.toByteString()) } catch (_: Exception) {} |
||||
|
} |
||||
|
|
||||
|
private fun sendFinish() { |
||||
|
try { webSocket?.send("""{"type":"finish"}""") } catch (_: Exception) {} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 消息处理 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private suspend fun handleMessage(msg: JSONObject) { |
||||
|
when (msg.optString("type")) { |
||||
|
"stoplisten" -> { |
||||
|
forwardingAudio = false; speechDetected = false; vadOnsetCount = 0 |
||||
|
synchronized(sendBufferLock) { |
||||
|
val remain = sendBuffer.size - sendBufferOffset |
||||
|
if (remain > 0) { |
||||
|
sendAudioChunk(ByteArray(remain) { i -> sendBuffer[sendBufferOffset + i] }) |
||||
|
} |
||||
|
sendBuffer.clear(); sendBufferOffset = 0 |
||||
|
} |
||||
|
sendFinish() |
||||
|
emit("recognition_stopped", emptyMap()) |
||||
|
} |
||||
|
"mid_result" -> { |
||||
|
val text = msg.optString("result", "") |
||||
|
if (text.isNotEmpty()) emit("recognizing", mapOf("text" to text)) |
||||
|
} |
||||
|
"fin_result" -> { |
||||
|
val result = msg.optString("result", "") |
||||
|
if (result.isNotEmpty()) { |
||||
|
emit("recognition_result", mapOf("text" to result)) |
||||
|
messages.put(JSONObject(mapOf("role" to "user", "content" to result))) |
||||
|
withContext(Dispatchers.IO) { |
||||
|
try { |
||||
|
chatStorageHelper.saveMessage(config.agentId, currentSessionId, result, "user") |
||||
|
} catch (_: Exception) {} |
||||
|
} |
||||
|
} |
||||
|
if (msg.optInt("end", 0) == 1) markSessionEnded() |
||||
|
} |
||||
|
"inside_rc" -> handleInsideRc(msg) |
||||
|
"dcs_decide" -> if (msg.optInt("end", 0) == 1) markSessionEnded() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private suspend fun handleInsideRc(msg: JSONObject) { |
||||
|
val data = msg.optJSONObject("data") ?: return |
||||
|
if (data.optInt("code", -1) != 0) return |
||||
|
|
||||
|
val rawAnswer = data.optString("assistant_answer", "") |
||||
|
val cleanAnswer = try { |
||||
|
JSONObject(rawAnswer).optString("content", rawAnswer) |
||||
|
} catch (_: Exception) { rawAnswer } |
||||
|
|
||||
|
var ttsUrl: String? = null |
||||
|
val items = data.optJSONArray("data") |
||||
|
if (items != null) { |
||||
|
for (i in 0 until items.length()) { |
||||
|
val item = items.optJSONObject(i) ?: continue |
||||
|
val h = item.optJSONObject("header") ?: continue |
||||
|
if (h.optString("namespace") == "ai.fxzsos.device_interface.voice_output" && |
||||
|
h.optString("name") == "Speak" |
||||
|
) { |
||||
|
ttsUrl = item.optJSONObject("payload")?.optString("url") |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 每次取更完整的回复文本,立即发送给 UI |
||||
|
if (cleanAnswer.isNotEmpty() && cleanAnswer.length > pendingAnswer.length) { |
||||
|
pendingAnswer = cleanAnswer |
||||
|
emit("assistant_response", mapOf("response" to cleanAnswer, "sessionid" to currentSessionId)) |
||||
|
} |
||||
|
ttsUrl?.takeIf { it.isNotEmpty() }?.let { enqueueTts(it) } |
||||
|
if (msg.optInt("end", 0) == 1) markSessionEnded() |
||||
|
} |
||||
|
|
||||
|
/** 将 pendingAnswer 保存到历史和存储,并清空 */ |
||||
|
private fun flushPendingAnswer() { |
||||
|
if (pendingAnswer.isEmpty()) return |
||||
|
val answer = pendingAnswer |
||||
|
val sid = currentSessionId |
||||
|
messages.put(JSONObject(mapOf("role" to "assistant", "content" to answer))) |
||||
|
launch(Dispatchers.IO) { |
||||
|
try { |
||||
|
chatStorageHelper.saveMessage(config.agentId, sid, answer, "assistant") |
||||
|
} catch (_: Exception) {} |
||||
|
} |
||||
|
pendingAnswer = "" |
||||
|
} |
||||
|
|
||||
|
private fun markSessionEnded() { |
||||
|
if (sessionEnded) return |
||||
|
sessionEnded = true; forwardingAudio = false; speechDetected = false; vadOnsetCount = 0 |
||||
|
clearSendBuffer() |
||||
|
flushPendingAnswer() |
||||
|
// 静音模式下文本接收完毕,发送完成事件并关闭连接 |
||||
|
if (ttsMuted) { |
||||
|
emit("tts_completed", emptyMap()) |
||||
|
closeWs() |
||||
|
if (inCall) { resetVad(); startIdleTimer() } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// TTS 播放队列 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun enqueueTts(url: String) { |
||||
|
if (ttsMuted) return // 静音模式跳过 TTS |
||||
|
ttsQueue.addLast(url) |
||||
|
if (!ttsPlaying) { |
||||
|
stopMicHard() |
||||
|
mainHandler.post { playNextTts() } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun playNextTts() { |
||||
|
if (ttsQueue.isEmpty() || ttsInterrupted) { |
||||
|
ttsPlaying = false |
||||
|
if (!ttsInterrupted) { |
||||
|
emit("tts_completed", emptyMap()) |
||||
|
if (inCall) { resetVad(); startIdleTimer() } |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
ttsPlaying = true |
||||
|
emit("tts_started", emptyMap()) |
||||
|
val url = ttsQueue.removeFirst() |
||||
|
try { |
||||
|
currentPlayer?.stop(); currentPlayer?.release() |
||||
|
currentPlayer = MediaPlayer().apply { |
||||
|
setDataSource(url) |
||||
|
setOnCompletionListener { |
||||
|
if (!ttsInterrupted) mainHandler.post { playNextTts() } |
||||
|
} |
||||
|
setOnErrorListener { _, _, _ -> |
||||
|
if (!ttsInterrupted) mainHandler.post { playNextTts() } |
||||
|
true |
||||
|
} |
||||
|
prepare() |
||||
|
start() |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "TTS 播放失败: ${e.message}") |
||||
|
if (!ttsInterrupted) mainHandler.post { playNextTts() } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun interruptTts() { |
||||
|
ttsInterrupted = true; ttsQueue.clear(); ttsPlaying = false |
||||
|
mainHandler.post { |
||||
|
try { currentPlayer?.stop(); currentPlayer?.release() } catch (_: Exception) {} |
||||
|
currentPlayer = null |
||||
|
} |
||||
|
emit("tts_canceled", emptyMap()) |
||||
|
} |
||||
|
|
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
// 超时 & 工具 |
||||
|
// ═════════════════════════════════════════════════════════════════════════ |
||||
|
|
||||
|
private fun startIdleTimer() { |
||||
|
cancelIdleTimer() |
||||
|
idleTimerJob = launch { |
||||
|
delay(IDLE_TIMEOUT_MS) |
||||
|
if (inCall && !speechDetected) disconnect() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun cancelIdleTimer() { idleTimerJob?.cancel(); idleTimerJob = null } |
||||
|
|
||||
|
private fun calcSign(secret: String, ts: String): String { |
||||
|
val digest = MessageDigest.getInstance("MD5").digest("$secret$ts".toByteArray()) |
||||
|
return digest.joinToString("") { "%02x".format(it) } |
||||
|
} |
||||
|
|
||||
|
private fun emit(eventName: String, data: Map<String, Any>) { |
||||
|
launch(Dispatchers.Main) { onEvent(eventName, data) } |
||||
|
} |
||||
|
|
||||
|
private fun emitError(msg: String) = emit("error", mapOf("message" to msg)) |
||||
|
|
||||
|
private fun buildTrustAllClient(): OkHttpClient { |
||||
|
return try { |
||||
|
val trustAll = arrayOf<TrustManager>(object : X509TrustManager { |
||||
|
override fun checkClientTrusted(c: Array<X509Certificate>, t: String) {} |
||||
|
override fun checkServerTrusted(c: Array<X509Certificate>, t: String) {} |
||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray() |
||||
|
}) |
||||
|
val sc = SSLContext.getInstance("TLS") |
||||
|
sc.init(null, trustAll, SecureRandom()) |
||||
|
OkHttpClient.Builder() |
||||
|
.sslSocketFactory(sc.socketFactory, trustAll[0] as X509TrustManager) |
||||
|
.hostnameVerifier { _, _ -> true } |
||||
|
.connectTimeout(15, TimeUnit.SECONDS) |
||||
|
.readTimeout(90, TimeUnit.SECONDS) |
||||
|
.writeTimeout(90, TimeUnit.SECONDS) |
||||
|
.build() |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "buildTrustAllClient 失败,使用默认客户端", e) |
||||
|
OkHttpClient() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,495 @@ |
|||||
|
import Foundation |
||||
|
import AVFoundation |
||||
|
import CryptoKit |
||||
|
import os.log |
||||
|
import chat_storage |
||||
|
|
||||
|
// MARK: - StsConfig |
||||
|
|
||||
|
struct StsConfig { |
||||
|
let productId: String |
||||
|
let productKey: String |
||||
|
var deviceNo: String |
||||
|
var deviceId: String |
||||
|
var deviceSecret: String |
||||
|
let agentId: String |
||||
|
} |
||||
|
|
||||
|
// MARK: - StsAgent |
||||
|
|
||||
|
/// StsAgent — 中国移动 A80 WS 一体化语音对话实现(iOS) |
||||
|
/// |
||||
|
/// 通过一条 WSS 连接完成 ASR + AI + TTS,事件格式与标准 stt_llm_tts 完全一致, |
||||
|
/// 通过 onEvent 回调发出,供 AgentServiceImpl 转发到 Flutter EventChannel。 |
||||
|
class StsAgent: NSObject { |
||||
|
|
||||
|
private let TAG = "StsAgent" |
||||
|
private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "StsAgent") |
||||
|
|
||||
|
private var config: StsConfig |
||||
|
private let onEvent: (String, [String: Any]) -> Void |
||||
|
private let onCredentialsFetched: ((String, String, String) -> Void)? |
||||
|
|
||||
|
// MARK: WebSocket |
||||
|
private var webSocketTask: URLSessionWebSocketTask? |
||||
|
private var urlSession: URLSession! |
||||
|
private var readyReceived = false |
||||
|
private var sessionEnded = true |
||||
|
private var dialogRequestId = "" |
||||
|
|
||||
|
// MARK: 录音 |
||||
|
private let SAMPLE_RATE: Double = 16000 |
||||
|
private let FRAME_SIZE = 5120 // 160ms @ 16kHz 16bit mono |
||||
|
private var audioEngine: AVAudioEngine? |
||||
|
private var inputNode: AVAudioInputNode? |
||||
|
private var recordingActive = false |
||||
|
private var sendBuffer = Data() |
||||
|
private var micChunkCount = 0 |
||||
|
|
||||
|
// MARK: VAD |
||||
|
private let VAD_THRESHOLD: Float = 800 |
||||
|
private let VAD_ONSET_CHUNKS = 3 |
||||
|
private var vadOnsetCount = 0 |
||||
|
private var vadActive = false |
||||
|
private var preBuffer: [[UInt8]] = [] |
||||
|
private let PRE_BUFFER_MAX = 75 |
||||
|
|
||||
|
// MARK: TTS |
||||
|
private var ttsQueue: [String] = [] |
||||
|
private var ttsPlayer: AVPlayer? |
||||
|
private var ttsPlaying = false |
||||
|
private var ttsPlayerObserver: Any? |
||||
|
|
||||
|
// MARK: 历史 |
||||
|
private var messages: [[String: String]] = [] |
||||
|
private var currentSessionId = "" |
||||
|
private let chatStorage = ChatStorageHelper.shared |
||||
|
|
||||
|
// MARK: 模式 |
||||
|
private var isPttMode = false |
||||
|
|
||||
|
// MARK: 凭证请求 |
||||
|
private var credentialsFetched = false |
||||
|
|
||||
|
init(config: StsConfig, |
||||
|
onEvent: @escaping (String, [String: Any]) -> Void, |
||||
|
onCredentialsFetched: ((String, String, String) -> Void)? = nil) { |
||||
|
self.config = config |
||||
|
self.onEvent = onEvent |
||||
|
self.onCredentialsFetched = onCredentialsFetched |
||||
|
super.init() |
||||
|
let sessionConfig = URLSessionConfiguration.default |
||||
|
urlSession = URLSession(configuration: sessionConfig, delegate: nil, delegateQueue: OperationQueue()) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 公共接口 |
||||
|
|
||||
|
func startCall() { |
||||
|
isPttMode = false |
||||
|
os_log("StsAgent startCall", log: logger, type: .info) |
||||
|
fetchCredentialsIfNeeded { [weak self] in |
||||
|
self?.connect() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func startPtt() { |
||||
|
isPttMode = true |
||||
|
os_log("StsAgent startPtt", log: logger, type: .info) |
||||
|
fetchCredentialsIfNeeded { [weak self] in |
||||
|
self?.connect() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func stopPtt() { |
||||
|
os_log("StsAgent stopPtt", log: logger, type: .info) |
||||
|
guard isPttMode else { return } |
||||
|
sendFinishFrame() |
||||
|
} |
||||
|
|
||||
|
/// 统一停止:根据当前模式自动选择 stopPtt 或 dispose |
||||
|
func stop() { |
||||
|
if isPttMode { |
||||
|
stopPtt() |
||||
|
} else { |
||||
|
dispose() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func interruptSpeaking() { |
||||
|
os_log("StsAgent interruptSpeaking", log: logger, type: .info) |
||||
|
stopTts() |
||||
|
emit("tts_canceled") |
||||
|
} |
||||
|
|
||||
|
func clearHistory() { |
||||
|
messages.removeAll() |
||||
|
DispatchQueue.global().async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
_ = self.chatStorage.deleteMessages(agentId: self.config.agentId, messageIds: nil as [Int]?) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func dispose() { |
||||
|
os_log("StsAgent dispose", log: logger, type: .info) |
||||
|
stopRecording() |
||||
|
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
||||
|
webSocketTask = nil |
||||
|
stopTts() |
||||
|
} |
||||
|
|
||||
|
// MARK: - 凭证获取 |
||||
|
|
||||
|
private func fetchCredentialsIfNeeded(completion: @escaping () -> Void) { |
||||
|
if credentialsFetched && !config.deviceId.isEmpty && !config.deviceSecret.isEmpty { |
||||
|
completion() |
||||
|
return |
||||
|
} |
||||
|
let ts = Int64(Date().timeIntervalSince1970 * 1000) |
||||
|
let sign = calcSign(secret: config.productKey, ts: ts) |
||||
|
let urlStr = "https://ivs.chinamobiledevice.com:11443/v2/customer/device/secret/info" |
||||
|
guard var components = URLComponents(string: urlStr) else { completion(); return } |
||||
|
components.queryItems = [ |
||||
|
URLQueryItem(name: "productId", value: config.productId), |
||||
|
URLQueryItem(name: "deviceNo", value: config.deviceNo), |
||||
|
URLQueryItem(name: "ts", value: "\(ts)"), |
||||
|
URLQueryItem(name: "sign", value: sign), |
||||
|
] |
||||
|
guard let url = components.url else { completion(); return } |
||||
|
var req = URLRequest(url: url) |
||||
|
req.timeoutInterval = 10 |
||||
|
URLSession.shared.dataTask(with: req) { [weak self] data, _, error in |
||||
|
guard let self = self else { return } |
||||
|
if let error = error { |
||||
|
os_log("凭证请求失败: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
||||
|
completion() |
||||
|
return |
||||
|
} |
||||
|
guard let data = data, |
||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
||||
|
let body = json["body"] as? [String: Any] else { |
||||
|
completion() |
||||
|
return |
||||
|
} |
||||
|
if let deviceId = body["deviceId"] as? String { self.config.deviceId = deviceId } |
||||
|
if let deviceSecret = body["deviceSecret"] as? String { self.config.deviceSecret = deviceSecret } |
||||
|
if let deviceNo = body["deviceNo"] as? String { self.config.deviceNo = deviceNo } |
||||
|
self.credentialsFetched = true |
||||
|
self.onCredentialsFetched?(self.config.deviceId, self.config.deviceSecret, self.config.deviceNo) |
||||
|
completion() |
||||
|
}.resume() |
||||
|
} |
||||
|
|
||||
|
// MARK: - WebSocket 连接 |
||||
|
|
||||
|
private func connect() { |
||||
|
let ts = Int64(Date().timeIntervalSince1970 * 1000) |
||||
|
let sign = calcSign(secret: config.deviceSecret, ts: ts) |
||||
|
let urlStr = "wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr" |
||||
|
guard var components = URLComponents(string: urlStr) else { return } |
||||
|
components.queryItems = [ |
||||
|
URLQueryItem(name: "productId", value: config.productId), |
||||
|
URLQueryItem(name: "deviceId", value: config.deviceId), |
||||
|
URLQueryItem(name: "ts", value: "\(ts)"), |
||||
|
URLQueryItem(name: "sign", value: sign), |
||||
|
] |
||||
|
guard let url = components.url else { return } |
||||
|
webSocketTask = urlSession.webSocketTask(with: url) |
||||
|
webSocketTask?.resume() |
||||
|
receiveMessages() |
||||
|
|
||||
|
// 发送 start 帧 |
||||
|
sendStartFrame() |
||||
|
} |
||||
|
|
||||
|
private func sendStartFrame() { |
||||
|
dialogRequestId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() |
||||
|
currentSessionId = dialogRequestId |
||||
|
let payload: [String: Any] = [ |
||||
|
"msgType": "start", |
||||
|
"dialogRequestId": dialogRequestId, |
||||
|
"params": [ |
||||
|
"asrParams": [ |
||||
|
"format": "pcm", |
||||
|
"sampleRate": 16000, |
||||
|
"lang": "zh-CN", |
||||
|
"vadEnable": false, |
||||
|
], |
||||
|
"contextParams": [ |
||||
|
"history": messages.map { ["role": $0["role"] ?? "", "content": $0["content"] ?? ""] } |
||||
|
] |
||||
|
] |
||||
|
] |
||||
|
sendJson(payload) |
||||
|
} |
||||
|
|
||||
|
private func receiveMessages() { |
||||
|
webSocketTask?.receive { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
switch result { |
||||
|
case .success(let msg): |
||||
|
switch msg { |
||||
|
case .string(let text): |
||||
|
self.handleMessage(text) |
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
self.receiveMessages() |
||||
|
case .failure(let err): |
||||
|
os_log("WS error: %{public}@", log: self.logger, type: .error, err.localizedDescription) |
||||
|
self.emit("error", data: ["message": err.localizedDescription]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 消息处理 |
||||
|
|
||||
|
private func handleMessage(_ text: String) { |
||||
|
guard let data = text.data(using: .utf8), |
||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } |
||||
|
let msgType = json["msgType"] as? String ?? "" |
||||
|
os_log("WS recv: %{public}@", log: logger, type: .debug, msgType) |
||||
|
|
||||
|
switch msgType { |
||||
|
case "ready": |
||||
|
readyReceived = true |
||||
|
sessionEnded = false |
||||
|
emit("recognition_started") |
||||
|
startRecording() |
||||
|
if isPttMode { |
||||
|
// PTT 模式等待 stopPtt() 调用发送 finish |
||||
|
} |
||||
|
|
||||
|
case "mid_result": |
||||
|
let result = (json["data"] as? [String: Any])?["result"] as? String ?? "" |
||||
|
if !result.isEmpty { emit("recognizing", data: ["text": result]) } |
||||
|
|
||||
|
case "fin_result": |
||||
|
let result = (json["data"] as? [String: Any])?["result"] as? String ?? "" |
||||
|
emit("recognition_result", data: ["text": result]) |
||||
|
emit("recognition_stopped") |
||||
|
stopRecording() |
||||
|
if !result.isEmpty { |
||||
|
messages.append(["role": "user", "content": result]) |
||||
|
saveToChatStorage(text: result, role: "user") |
||||
|
} |
||||
|
|
||||
|
case "stoplisten": |
||||
|
stopRecording() |
||||
|
emit("recognition_stopped") |
||||
|
|
||||
|
case "inside_rc": |
||||
|
handleInsideRc(json["data"] as? [String: Any]) |
||||
|
|
||||
|
case "dcs_decide": |
||||
|
// 语义决策,忽略 |
||||
|
break |
||||
|
|
||||
|
default: |
||||
|
os_log("未处理的消息类型: %{public}@", log: logger, type: .info, msgType) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func handleInsideRc(_ data: [String: Any]?) { |
||||
|
guard let data = data else { return } |
||||
|
guard let rcType = data["rcType"] as? String else { return } |
||||
|
if rcType == "assistant_answer" { |
||||
|
let answer = (data["answer"] as? [String: Any])?["text"] as? String ?? "" |
||||
|
let cleanAnswer = answer.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
let sid = data["sessionId"] as? String ?? currentSessionId |
||||
|
if !cleanAnswer.isEmpty { |
||||
|
messages.append(["role": "assistant", "content": cleanAnswer]) |
||||
|
saveToChatStorage(text: cleanAnswer, role: "assistant") |
||||
|
emit("assistant_response", data: ["response": cleanAnswer, "sessionid": sid]) |
||||
|
} |
||||
|
// TTS URLs |
||||
|
if let ttsData = data["tts"] as? [[String: Any]] { |
||||
|
for item in ttsData { |
||||
|
if let url = item["url"] as? String, !url.isEmpty { |
||||
|
ttsQueue.append(url) |
||||
|
} |
||||
|
} |
||||
|
if !ttsPlaying { playNextTts() } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - TTS 播放 |
||||
|
|
||||
|
private func playNextTts() { |
||||
|
guard !ttsQueue.isEmpty else { |
||||
|
ttsPlaying = false |
||||
|
emit("tts_completed") |
||||
|
return |
||||
|
} |
||||
|
let urlStr = ttsQueue.removeFirst() |
||||
|
guard let url = URL(string: urlStr) else { playNextTts(); return } |
||||
|
|
||||
|
ttsPlaying = true |
||||
|
if ttsQueue.isEmpty && !ttsPlaying { emit("tts_started") } |
||||
|
emit("tts_started") |
||||
|
|
||||
|
let item = AVPlayerItem(url: url) |
||||
|
ttsPlayer = AVPlayer(playerItem: item) |
||||
|
if let observer = ttsPlayerObserver { |
||||
|
NotificationCenter.default.removeObserver(observer) |
||||
|
} |
||||
|
ttsPlayerObserver = NotificationCenter.default.addObserver( |
||||
|
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main |
||||
|
) { [weak self] _ in |
||||
|
self?.playNextTts() |
||||
|
} |
||||
|
ttsPlayer?.play() |
||||
|
} |
||||
|
|
||||
|
private func stopTts() { |
||||
|
ttsPlayer?.pause() |
||||
|
ttsPlayer = nil |
||||
|
ttsQueue.removeAll() |
||||
|
ttsPlaying = false |
||||
|
if let observer = ttsPlayerObserver { |
||||
|
NotificationCenter.default.removeObserver(observer) |
||||
|
ttsPlayerObserver = nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 录音 |
||||
|
|
||||
|
private func startRecording() { |
||||
|
guard !recordingActive else { return } |
||||
|
let engine = AVAudioEngine() |
||||
|
audioEngine = engine |
||||
|
inputNode = engine.inputNode |
||||
|
let fmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)! |
||||
|
let hwFmt = inputNode!.outputFormat(forBus: 0) |
||||
|
let converterFmt = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: SAMPLE_RATE, channels: 1, interleaved: true)! |
||||
|
|
||||
|
inputNode!.installTap(onBus: 0, bufferSize: 4096, format: hwFmt) { [weak self] buffer, _ in |
||||
|
guard let self = self, self.recordingActive else { return } |
||||
|
// 转换为 16kHz PCM16 |
||||
|
guard let cvt = AVAudioConverter(from: hwFmt, to: converterFmt) else { return } |
||||
|
let frameCount = AVAudioFrameCount(Double(buffer.frameLength) * self.SAMPLE_RATE / hwFmt.sampleRate) |
||||
|
guard let outBuf = AVAudioPCMBuffer(pcmFormat: converterFmt, frameCapacity: frameCount) else { return } |
||||
|
var error: NSError? |
||||
|
cvt.convert(to: outBuf, error: &error) { _, outStatus in |
||||
|
outStatus.pointee = .haveData |
||||
|
return buffer |
||||
|
} |
||||
|
if error != nil { return } |
||||
|
let byteCount = Int(outBuf.frameLength) * 2 |
||||
|
let ptr = outBuf.int16ChannelData![0] |
||||
|
let data = Data(bytes: ptr, count: byteCount) |
||||
|
self.onMicAudio(data) |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) |
||||
|
try AVAudioSession.sharedInstance().setActive(true) |
||||
|
try engine.start() |
||||
|
recordingActive = true |
||||
|
} catch { |
||||
|
os_log("录音启动失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func stopRecording() { |
||||
|
guard recordingActive else { return } |
||||
|
recordingActive = false |
||||
|
inputNode?.removeTap(onBus: 0) |
||||
|
audioEngine?.stop() |
||||
|
audioEngine = nil |
||||
|
inputNode = nil |
||||
|
sendBuffer.removeAll() |
||||
|
micChunkCount = 0 |
||||
|
vadActive = false |
||||
|
vadOnsetCount = 0 |
||||
|
preBuffer.removeAll() |
||||
|
} |
||||
|
|
||||
|
private func onMicAudio(_ pcm: Data) { |
||||
|
micChunkCount += 1 |
||||
|
let rms = calcRms(pcm) |
||||
|
let chunk = [UInt8](pcm) |
||||
|
|
||||
|
if !vadActive { |
||||
|
if preBuffer.count >= PRE_BUFFER_MAX { preBuffer.removeFirst() } |
||||
|
preBuffer.append(chunk) |
||||
|
} |
||||
|
|
||||
|
if !vadActive && rms > VAD_THRESHOLD { |
||||
|
vadOnsetCount += 1 |
||||
|
if vadOnsetCount >= VAD_ONSET_CHUNKS { |
||||
|
vadActive = true |
||||
|
// 先发预缓存 |
||||
|
for c in preBuffer { feedSendBuffer(Data(c)) } |
||||
|
preBuffer.removeAll() |
||||
|
} |
||||
|
} else if vadActive { |
||||
|
feedSendBuffer(pcm) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func calcRms(_ pcm: Data) -> Float { |
||||
|
let count = pcm.count / 2 |
||||
|
guard count > 0 else { return 0 } |
||||
|
var sum: Float = 0 |
||||
|
pcm.withUnsafeBytes { ptr in |
||||
|
let samples = ptr.bindMemory(to: Int16.self) |
||||
|
for i in 0..<count { |
||||
|
let s = Float(samples[i]) |
||||
|
sum += s * s |
||||
|
} |
||||
|
} |
||||
|
return sqrtf(sum / Float(count)) |
||||
|
} |
||||
|
|
||||
|
private func feedSendBuffer(_ chunk: Data) { |
||||
|
sendBuffer.append(chunk) |
||||
|
while sendBuffer.count >= FRAME_SIZE { |
||||
|
let frame = sendBuffer.prefix(FRAME_SIZE) |
||||
|
sendBuffer.removeFirst(FRAME_SIZE) |
||||
|
webSocketTask?.send(.data(Data(frame))) { _ in } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func sendFinishFrame() { |
||||
|
let payload: [String: Any] = [ |
||||
|
"msgType": "finish", |
||||
|
"dialogRequestId": dialogRequestId, |
||||
|
] |
||||
|
sendJson(payload) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 工具 |
||||
|
|
||||
|
private func sendJson(_ dict: [String: Any]) { |
||||
|
guard let data = try? JSONSerialization.data(withJSONObject: dict), |
||||
|
let str = String(data: data, encoding: .utf8) else { return } |
||||
|
webSocketTask?.send(.string(str)) { _ in } |
||||
|
} |
||||
|
|
||||
|
private func calcSign(secret: String, ts: Int64) -> String { |
||||
|
let raw = "\(secret)\(ts)" |
||||
|
let data = Data(raw.utf8) |
||||
|
let digest = Insecure.MD5.hash(data: data) |
||||
|
return digest.map { String(format: "%02x", $0) }.joined() |
||||
|
} |
||||
|
|
||||
|
private func emit(_ name: String, data: [String: Any] = [:]) { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.onEvent(name, data) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func saveToChatStorage(text: String, role: String) { |
||||
|
DispatchQueue.global().async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
_ = self.chatStorage.saveMessage( |
||||
|
agentId: self.config.agentId, |
||||
|
sessionId: self.currentSessionId, |
||||
|
message: text, |
||||
|
sender: role, |
||||
|
metadata: nil |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue