|
|
@ -50,6 +50,11 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
private var audioChunkBuffer = Data() |
|
|
private var audioChunkBuffer = Data() |
|
|
|
|
|
|
|
|
private var isStarted = false |
|
|
private var isStarted = false |
|
|
|
|
|
private var openContinuation: CheckedContinuation<Bool, Never>? |
|
|
|
|
|
// isStarted 成立前的音频缓冲,didOpen 后再冲刷,避免首包被丢 |
|
|
|
|
|
private var pendingAudioChunks: [Data] = [] |
|
|
|
|
|
private let pendingLock = NSLock() |
|
|
|
|
|
private let maxPendingChunks = 50 |
|
|
|
|
|
|
|
|
deinit { |
|
|
deinit { |
|
|
os_log("AliyunBailianE2EHelper deinit", log: log, type: .info) |
|
|
os_log("AliyunBailianE2EHelper deinit", log: log, type: .info) |
|
|
@ -67,6 +72,15 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
urlSession = nil |
|
|
urlSession = nil |
|
|
webSocket = nil |
|
|
webSocket = nil |
|
|
isStarted = false |
|
|
isStarted = false |
|
|
|
|
|
// 抛弃上一次未完成的等待,避免泄漏 |
|
|
|
|
|
if let cont = openContinuation { |
|
|
|
|
|
openContinuation = nil |
|
|
|
|
|
cont.resume(returning: false) |
|
|
|
|
|
} |
|
|
|
|
|
// 清空上一会话遗留的待发音频 |
|
|
|
|
|
pendingLock.lock() |
|
|
|
|
|
pendingAudioChunks.removeAll() |
|
|
|
|
|
pendingLock.unlock() |
|
|
|
|
|
|
|
|
conf = config |
|
|
conf = config |
|
|
callback = cb |
|
|
callback = cb |
|
|
@ -77,7 +91,27 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
|
|
|
|
|
|
urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) |
|
|
urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) |
|
|
|
|
|
|
|
|
return startContinuousConversation() |
|
|
// 真正等到 WebSocket didOpen(isStarted=true)再返回,防止后续 pushAudioData 被丢 |
|
|
|
|
|
let kicked = startContinuousConversation() |
|
|
|
|
|
if !kicked { return false } |
|
|
|
|
|
let opened = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in |
|
|
|
|
|
// 如已 open(极少见),立即放行 |
|
|
|
|
|
if isStarted { |
|
|
|
|
|
cont.resume(returning: true) |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
openContinuation = cont |
|
|
|
|
|
// 超时兜底:10s 未 open 就放行避免 Flutter 侧死等 |
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in |
|
|
|
|
|
guard let self = self else { return } |
|
|
|
|
|
if let pending = self.openContinuation { |
|
|
|
|
|
self.openContinuation = nil |
|
|
|
|
|
os_log("initialize: WebSocket didOpen 超时,返回 false", log: self.log, type: .error) |
|
|
|
|
|
pending.resume(returning: false) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
return opened |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -317,8 +351,17 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
* 推送音频 PCM 数据(Base64 编码) |
|
|
* 推送音频 PCM 数据(Base64 编码) |
|
|
*/ |
|
|
*/ |
|
|
func pushAudioData(_ data: Data) -> Bool { |
|
|
func pushAudioData(_ data: Data) -> Bool { |
|
|
guard isStarted, let ws = webSocket else { |
|
|
// isStarted 还没就绪时,缓冲音频(最多 maxPendingChunks 包),didOpen 后冲刷 |
|
|
os_log("pushAudioData ignored: isStarted=%{public}d, ws is nil", log: log, type: .info, isStarted) |
|
|
if !isStarted || webSocket == nil { |
|
|
|
|
|
pendingLock.lock() |
|
|
|
|
|
if pendingAudioChunks.count >= maxPendingChunks { |
|
|
|
|
|
pendingAudioChunks.removeFirst() |
|
|
|
|
|
} |
|
|
|
|
|
pendingAudioChunks.append(data) |
|
|
|
|
|
pendingLock.unlock() |
|
|
|
|
|
return true |
|
|
|
|
|
} |
|
|
|
|
|
guard let ws = webSocket else { |
|
|
return false |
|
|
return false |
|
|
} |
|
|
} |
|
|
os_log("pushAudioData: %{public}d bytes", log: log, type: .info, data.count) |
|
|
os_log("pushAudioData: %{public}d bytes", log: log, type: .info, data.count) |
|
|
@ -350,7 +393,11 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
if let ws = webSocket { |
|
|
if let ws = webSocket { |
|
|
ws.cancel(with: .normalClosure, reason: "User stopped".data(using: .utf8)) |
|
|
ws.cancel(with: .normalClosure, reason: "User stopped".data(using: .utf8)) |
|
|
} |
|
|
} |
|
|
|
|
|
webSocket = nil |
|
|
isStarted = false |
|
|
isStarted = false |
|
|
|
|
|
pendingLock.lock() |
|
|
|
|
|
pendingAudioChunks.removeAll() |
|
|
|
|
|
pendingLock.unlock() |
|
|
return true |
|
|
return true |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -367,6 +414,13 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
isStarted = false |
|
|
isStarted = false |
|
|
urlSession?.invalidateAndCancel() |
|
|
urlSession?.invalidateAndCancel() |
|
|
urlSession = nil |
|
|
urlSession = nil |
|
|
|
|
|
if let cont = openContinuation { |
|
|
|
|
|
openContinuation = nil |
|
|
|
|
|
cont.resume(returning: false) |
|
|
|
|
|
} |
|
|
|
|
|
pendingLock.lock() |
|
|
|
|
|
pendingAudioChunks.removeAll() |
|
|
|
|
|
pendingLock.unlock() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -380,6 +434,22 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
isStarted = true // 在 session.update 发送后才允许推送音频,与 Android 行为对齐 |
|
|
isStarted = true // 在 session.update 发送后才允许推送音频,与 Android 行为对齐 |
|
|
callback?.onSessionStarted(sessionId: sessionId) |
|
|
callback?.onSessionStarted(sessionId: sessionId) |
|
|
receiveLoop() |
|
|
receiveLoop() |
|
|
|
|
|
// 冲刷缓冲的音频 |
|
|
|
|
|
pendingLock.lock() |
|
|
|
|
|
let pending = pendingAudioChunks |
|
|
|
|
|
pendingAudioChunks.removeAll() |
|
|
|
|
|
pendingLock.unlock() |
|
|
|
|
|
if !pending.isEmpty { |
|
|
|
|
|
os_log("Flushing %{public}d buffered audio chunks", log: log, type: .info, pending.count) |
|
|
|
|
|
for chunk in pending { |
|
|
|
|
|
_ = pushAudioData(chunk) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
// 唤醒 initialize 中等待 didOpen 的协程 |
|
|
|
|
|
if let cont = openContinuation { |
|
|
|
|
|
openContinuation = nil |
|
|
|
|
|
cont.resume(returning: true) |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -419,6 +489,11 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
let reasonStr = String(data: reason ?? Data(), encoding: .utf8) ?? "" |
|
|
let reasonStr = String(data: reason ?? Data(), encoding: .utf8) ?? "" |
|
|
os_log("WebSocket didClose code=%{public}d reason=%{public}@", log: log, type: .info, closeCode.rawValue, reasonStr) |
|
|
os_log("WebSocket didClose code=%{public}d reason=%{public}@", log: log, type: .info, closeCode.rawValue, reasonStr) |
|
|
isStarted = false |
|
|
isStarted = false |
|
|
|
|
|
// 如果 initialize 还在等 didOpen 就收到 close,直接返回 false |
|
|
|
|
|
if let cont = openContinuation { |
|
|
|
|
|
openContinuation = nil |
|
|
|
|
|
cont.resume(returning: false) |
|
|
|
|
|
} |
|
|
callback?.onSessionFinished(sessionId: sessionId, finalText: fullTextBuffer, finalAudio: fullAudioBuffer) |
|
|
callback?.onSessionFinished(sessionId: sessionId, finalText: fullTextBuffer, finalAudio: fullAudioBuffer) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -433,6 +508,11 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { |
|
|
callback?.onSessionError(sessionId: sessionId, code: 1012, message: e.localizedDescription) |
|
|
callback?.onSessionError(sessionId: sessionId, code: 1012, message: e.localizedDescription) |
|
|
} |
|
|
} |
|
|
isStarted = false |
|
|
isStarted = false |
|
|
|
|
|
// 如果 initialize 还在等 didOpen 就失败,立即返回 false |
|
|
|
|
|
if let cont = openContinuation { |
|
|
|
|
|
openContinuation = nil |
|
|
|
|
|
cont.resume(returning: false) |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|