From 0beab4c8f3874cac2b1fbead6d9301cea384155f Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Fri, 26 Sep 2025 18:31:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=9A=E8=AF=9D?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8B=EF=BC=8C=E6=92=AD=E6=8A=A5tts?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E5=A5=94=E6=BA=83=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/azure_speech/AzureTtsHelper.swift | 391 +++++++++++++----- .../Sources/tools/SimpleAudioReceiver.swift | 91 +++- 2 files changed, 363 insertions(+), 119 deletions(-) diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift index a5857fe4e..32d0df348 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift @@ -4,88 +4,93 @@ import MicrosoftCognitiveServicesSpeech import os.log // 自定义语音处理组件,提供音频流处理等功能 import speech + public class AzureTtsHelper: NSObject, ITtsService { private let tag = "AzureTtsHelper" private let log = OSLog(subsystem: "com.azure.speech", category: "AzureTtsHelper") - + private static let DEFAULT_LANGUAGE = "zh-CN" private static let DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural" - + // Azure语音服务配置 private var speechConfig: SPXSpeechConfiguration? private var synthesizer: SPXSpeechSynthesizer? private var isInitialized = false private var speaking = false - private var sessionid = "" //语音播报任务idsetAudioOutputRoute - + private var sessionid = "" //语音播报任务id + // 当前配置 private var currentVoice = DEFAULT_VOICE private var currentLanguage = DEFAULT_LANGUAGE private var currentRate = "+0%" private var currentPitch = "+0%" private var currentVolume = "100%" - + // 事件监听器列表 private var eventListeners = NSHashTable.weakObjects() private var audioDataListeners = NSHashTable.weakObjects() - + // 流式文本处理的缓冲区 private var streamBuffer = "" private var lastSpeakTime: TimeInterval = 0 - + // 添加文本计数统计变量 private var totalTextCount = 0 private var currentSessionTextCount = 0 private var pendingTextCount = 0 - + // 内部异步处理队列 private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated) private var isProcessing = false // 添加处理状态标志 private let synthesisGroup = DispatchGroup() private var pendingTasks: [() -> Void] = [] private let taskLock = NSLock() - + // 自定义音频输出流 private var customAudioOutputStream: SPXPushAudioOutputStream? private var useInternalPlayer = true private var micCapture: MicrophoneCapture! // 记录最后播放的文本 private var lastSpokenText: String? + /** * 初始化语音合成服务 */ public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool { do { - micCapture = MicrophoneCapture.shared + micCapture = MicrophoneCapture.shared // 创建语音配置 speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) speechConfig?.speechSynthesisLanguage = language currentLanguage = language - + // 设置音频输出格式 - 与Android一致 - speechConfig?.setPropertyTo("riff-16khz-16bit-mono-pcm", + speechConfig?.setPropertyTo("riff-16khz-16bit-mono-pcm", byName: "SpeechServiceConnection_SynthOutputFormat") - + // 设置低延迟属性 speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_InitialSilenceTimeoutMs") speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_EndSilenceTimeoutMs") - + // 创建合成器 recreateSynthesizer() - + // 设置初始化完成 isInitialized = true - + + // 新增:监听音频会话中断和路由变化,避免通话时崩溃 + setupAudioSessionObservers() + // 预热TTS引擎 warmupSynthesizer() - + return true } catch { os_log("语音合成服务初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) return false } } - + /** * 设置自定义音频输出流 */ @@ -95,13 +100,13 @@ public class AzureTtsHelper: NSObject, ITtsService { recreateSynthesizer() } } - + /** * 设置语音角色 */ public func setVoice(_ voiceName: String) -> Bool { if !isInitialized { return false } - + do { currentVoice = voiceName speechConfig?.speechSynthesisVoiceName = voiceName @@ -116,48 +121,29 @@ public class AzureTtsHelper: NSObject, ITtsService { return false } } - + /** * 开始语音合成会话 * @param sessionid 会话ID * @return 是否成功启动 */ - public func startspeak(sessionid sessionid: String)-> Bool { + public func startspeak(sessionid sessionid: String) -> Bool { self.sessionid = sessionid print("startspeak sessionid \(sessionid)") // 重置当前会话的文本计数 self.currentSessionTextCount = 0 self.pendingTextCount = 0 - - do { - // 新增:使用 AVAudioSession.sharedInstance 配置为“通话模式 + 扬声器外放” - // - 使用 .playAndRecord 以保证通话上下行链路 - // - 使用 .voiceChat 获得通话处理(AEC 等) - // - 使用 .defaultToSpeaker 默认走外放 - // - 通过 overrideOutputAudioPort(.speaker) 强制切到扬声器 - let audioSession = AVAudioSession.sharedInstance() - - //try audioSession.setActive(false) - try audioSession.setCategory(.playAndRecord, - mode: .voiceChat, - options: [.defaultToSpeaker, - .mixWithOthers, - .defaultToSpeaker, - .allowBluetoothA2DP, - .allowBluetooth]) - try audioSession.overrideOutputAudioPort(.none) - try audioSession.setActive(true) - } catch { - print("音频会话配置失败: \(error.localizedDescription)") - } - + + // 使用安全的音频会话配置,兼容通话场景 + safeConfigureAudioSessionForTTS() + return true } - + /** * 单次合成并播放 */ - public func speakOnce(sessionid sessionid: String,_ text: String) -> Bool { + public func speakOnce(sessionid sessionid: String, _ text: String) -> Bool { if !isInitialized { os_log("语音合成未初始化", log: log, type: .error) notifyEvent(eventType: .error, params: [ @@ -166,30 +152,29 @@ public class AzureTtsHelper: NSObject, ITtsService { ]) return false } - + if (self.sessionid != sessionid){ return false } - - + // 清理文本 let cleanedText = cleanTextForTTS(text) if cleanedText.isEmpty { return false } - + // 异步处理 enqueueSynthesisTask { self.performSynthesis(sessionid: sessionid, text: cleanedText) } - + return true } - + /** * 流式合成文本 */ - public func speakStream(sessionid sessionid: String,_ text: String) -> Bool { + public func speakStream(sessionid sessionid: String, _ text: String) -> Bool { if !isInitialized { notifyEvent(eventType: .error, params: [ "errorCode": "NOT_INITIALIZED", @@ -201,33 +186,31 @@ public class AzureTtsHelper: NSObject, ITtsService { return false } self.sessionid = sessionid - + if text.isEmpty { return true } - + // 增加文本计数统计 totalTextCount += text.count currentSessionTextCount += text.count - - //print("流式合成文本\(text.count),总计文本数量:\(totalTextCount),当前会话:\(currentSessionTextCount)") - + // 清理文本并添加到缓冲区 let cleanedText = cleanTextForTTS(text) streamBuffer.append(cleanedText) - + // 防抖逻辑 (150ms) let currentTime = Date().timeIntervalSince1970 if currentTime - lastSpeakTime < 0.15 { return true } lastSpeakTime = currentTime - + let currentText = streamBuffer - + // 标点符号列表 (与Android一致) - let punctuationMarks: [Character] = [".", "。", "!", "!","*", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] - + let punctuationMarks: [Character] = [".", "。", "!", "!", "*", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] + // 从后往前查找最后一个标点符号 var lastPunctuationIndex = -1 for (i, char) in currentText.enumerated().reversed() { @@ -236,21 +219,21 @@ public class AzureTtsHelper: NSObject, ITtsService { break } } - + // 播放到找到的标点符号 if lastPunctuationIndex >= 0 { let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)) let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1) streamBuffer = String(currentText[startIndex...]) - + if !textToSpeak.isEmpty { return speakOnce(sessionid: sessionid, textToSpeak) } } - + return true } - + /** * 刷新并播放流式文本 */ @@ -262,17 +245,17 @@ public class AzureTtsHelper: NSObject, ITtsService { ]) return false } - + let remainingText = streamBuffer streamBuffer = "" - + if remainingText.isEmpty { return true } - - return speakOnce(sessionid:sessionid,remainingText) + + return speakOnce(sessionid: sessionid, remainingText) } - + /** * 停止语音合成 */ @@ -286,12 +269,12 @@ public class AzureTtsHelper: NSObject, ITtsService { taskLock.lock() pendingTasks.removeAll() taskLock.unlock() - + do { print("liwei-------------停止语音合成播放") try self.synthesizer?.stopSpeaking() self.notifyEvent(eventType: .synthesisCanceled) - + if !micCapture.isCapturing{ // 停止时设置音频会话为非活跃状态 // try AVAudioSession.sharedInstance().setActive(false) @@ -305,7 +288,7 @@ public class AzureTtsHelper: NSObject, ITtsService { "errorMessage": error.localizedDescription ]) } - + synthesisQueue.async { do { self.synthesizer = nil @@ -314,28 +297,10 @@ public class AzureTtsHelper: NSObject, ITtsService { os_log("重置语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) } } - + return true } - /** - * 释放资源 - */ - public func dispose() { - _ = stop() - synthesisGroup.wait() - - synthesisQueue.sync { - synthesizer = nil - speechConfig = nil - customAudioOutputStream = nil - isInitialized = false - speaking = false - - taskLock.lock() - pendingTasks.removeAll() - taskLock.unlock() - } - } + /** * 添加事件监听器 @@ -343,28 +308,28 @@ public class AzureTtsHelper: NSObject, ITtsService { public func addListener(_ listener: TtsEventListener) { eventListeners.add(listener as AnyObject) } - + /** * 移除事件监听器 */ public func removeListener(_ listener: TtsEventListener) { eventListeners.remove(listener as AnyObject) } - + /** * 添加音频数据监听器 */ public func addAudioDataListener(_ listener: AudioDataListener) { audioDataListeners.add(listener as AnyObject) } - + /** * 移除音频数据监听器 */ public func removeAudioDataListener(_ listener: AudioDataListener) { audioDataListeners.remove(listener as AnyObject) } - + /** * 设置是否使用内部播放器 */ @@ -374,6 +339,7 @@ public class AzureTtsHelper: NSObject, ITtsService { recreateSynthesizer() } } + /** * 设置音频输出设备 */ @@ -383,6 +349,12 @@ public class AzureTtsHelper: NSObject, ITtsService { if wasSpeaking { _ = stop() } + + // 新增:在通话期间不进行音频设备切换,避免崩溃 + if isTelephonyActive() { + os_log("检测到通话进行中,跳过音频设备切换以避免崩溃", log: log, type: .info) + return true + } // 重新创建合成器以应用新设备 if isInitialized { @@ -397,7 +369,7 @@ public class AzureTtsHelper: NSObject, ITtsService { return true } - + // MARK: - 私有方法 /** @@ -411,6 +383,7 @@ public class AzureTtsHelper: NSObject, ITtsService { // 确保任务被处理 processTasksIfNeeded() } + /** * 处理任务队列 */ @@ -460,7 +433,7 @@ public class AzureTtsHelper: NSObject, ITtsService { self.processNextTask() } } - + /** * 执行语音合成 */ @@ -672,7 +645,7 @@ public class AzureTtsHelper: NSObject, ITtsService { currentVolume = "\(min(max(volume, 0), 100))%" return true } - + /** * 是否正在播放 */ @@ -704,5 +677,215 @@ public func resetTextCount() { } + +/** + * 音频会话并发防护和状态 + */ +private let audioSessionQueue = DispatchQueue(label: "com.azure.tts.audioSession") +private var audioInterrupted = false +private var audioSessionObserversAdded = false + // 新增:路由变化的去抖与配置重入保护标记 + private var routeChangeDebounceWorkItem: DispatchWorkItem? + private var isApplyingAudioSessionConfig = false + +/** + * 监听并处理音频中断与路由变化(通话/Siri/蓝牙切换等) + * - 目的:在通话期间或路由变化时安全地停止/重配,避免 AVAudioSession 断言导致崩溃 + */ +private func setupAudioSessionObservers() { + if audioSessionObserversAdded { return } + audioSessionObserversAdded = true + + NotificationCenter.default.addObserver(self, + selector: #selector(handleAudioInterruption(_:)), + name: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance()) + NotificationCenter.default.addObserver(self, + selector: #selector(handleRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, + object: AVAudioSession.sharedInstance()) +} + +/** + * 判断当前是否处于通话相关音频占用(电话或蓝牙 HFP) + * - 使用 secondaryAudioShouldBeSilencedHint 与蓝牙 HFP 路由作为判断依据 + */ +private func isTelephonyActive() -> Bool { + let session = AVAudioSession.sharedInstance() + if session.secondaryAudioShouldBeSilencedHint { return true } + // 检查是否使用蓝牙 HFP(通话用的 Hands-Free Profile) + for out in session.currentRoute.outputs { + if out.portType == .bluetoothHFP { + return true + } + } + return false +} + +/** + * 安全配置音频会话用于 TTS,兼容通话场景 + * - 通话中:使用 .playback + .duckOthers,避免 .voiceChat/.allowBluetoothA2DP 导致断言 + * - 非通话:使用 .playAndRecord + .voiceChat + .allowBluetooth(不使用 A2DP) + */ + + /// 为 TTS 安全配置音频会话: + /// - 使用异步队列,避免在同一串行队列上 sync 导致死锁 + /// - 根据当前音频路由(蓝牙 A2DP / 蓝牙 HFP / 有线耳机)选择更合适的类别与模式 + /// - 仅当类别或模式发生变化时才 setCategory,降低 AVAudioSessionRouteChangeReason.categoryChange 的触发概率 + private func safeConfigureAudioSessionForTTS() { + // 使用异步,避免串行队列重入自锁 + audioSessionQueue.async { [weak self] in + guard let self = self else { return } + + // 防重入:配置中不重复进入 + if self.isApplyingAudioSessionConfig { return } + self.isApplyingAudioSessionConfig = true + defer { self.isApplyingAudioSessionConfig = false } + + let audioSession = AVAudioSession.sharedInstance() + + // 显式指定 outputs 的类型,确保闭包参数类型可推断 + let outputs: [AVAudioSessionPortDescription] = audioSession.currentRoute.outputs + + // 使用 contains(where:) 明确谓词闭包。 + // 保持使用 audioSessionQueue.async,防止在串行队列上自锁。 + // 仅在类别或模式发生变化时调用 setCategory,减少 categoryChange 的回调风暴。 + let hasBluetoothHFP = outputs.contains(where: { $0.portType == .bluetoothHFP }) + let hasBluetoothA2DP = outputs.contains(where: { $0.portType == .bluetoothA2DP }) + let hasWiredHeadphones = outputs.contains(where: { $0.portType == .headphones || $0.portType == .headsetMic }) + + // 计算目标配置 + let desiredCategory: AVAudioSession.Category + let desiredMode: AVAudioSession.Mode + var desiredOptions: AVAudioSession.CategoryOptions = [.mixWithOthers] + + if self.isTelephonyActive() || hasBluetoothHFP { + // 通话活跃或存在 HFP(语音)链路:使用语音聊天模式 + desiredCategory = .playAndRecord + desiredMode = .voiceChat + desiredOptions.insert(.allowBluetooth) // HFP 需 allowBluetooth + } else if hasBluetoothA2DP || hasWiredHeadphones { + // 纯播放链路耳机(A2DP/有线耳机):避免强制切语音链路 + desiredCategory = .playback + desiredMode = .default + desiredOptions.insert(.allowBluetoothA2DP) + } else { + // 默认:本机扬声器的语音链路 + desiredCategory = .playAndRecord + desiredMode = .voiceChat + desiredOptions.insert(.defaultToSpeaker) + desiredOptions.insert(.allowBluetooth) // 允许外接麦/耳机(非 HFP 也不冲突) + } + + do { + // 仅当类别或模式变化时设置,减少无谓的 categoryChange + if audioSession.category != desiredCategory || audioSession.mode != desiredMode { + try audioSession.setCategory(desiredCategory, mode: desiredMode, options: desiredOptions) + } + // 激活会话(通常不会引发死锁),失败时仅记录错误 + try audioSession.setActive(true) + } catch { + os_log("TTS 音频会话配置失败: %{public}@", log: self.log, type: .error, error.localizedDescription) + } + } + } + + +/** + * 处理系统音频中断(来电/通话开始、Siri等) + * - began:标记中断并停止当前合成播放 + * - ended:清除标记并按场景重配音频会话 + */ +@objc private func handleAudioInterruption(_ notification: Notification) { + guard let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } + + switch type { + case .began: + audioInterrupted = true + _ = stop() + os_log("音频中断开始(可能为通话/Siri),停止TTS", log: log, type: .info) + case .ended: + audioInterrupted = false + os_log("音频中断结束,重新配置会话", log: log, type: .info) + safeConfigureAudioSessionForTTS() + @unknown default: + break + } +} + +/** + * 处理音频路由变化(设备插拔、蓝牙切换、扬声器/听筒切换等) + * - 忽略 .categoryChange/.routeConfigurationChange 等由我们设置类别引发的变化,避免死循环 + * - 配置进行中(isApplyingAudioSessionConfig)直接忽略,防止重入 + * - 加入 300ms 去抖,汇聚瞬时多次变化后再重配 + * - 配置过程中(isApplyingAudioSessionConfig = true)直接忽略 + */ +@objc private func handleRouteChange(_ notification: Notification) { + guard let info = notification.userInfo, + let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } + + os_log("音频路由变化: %{public}@", log: log, type: .info, String(describing: reason)) + + // 配置中触发的路由变化,直接忽略,避免重入导致死循环 + if isApplyingAudioSessionConfig { + os_log("配置进行中,忽略本次路由变化", log: log, type: .debug) + return + } + + // 忽略会造成循环的原因:categoryChange(你的日志 reason=3),以及 routeConfigurationChange 等 + switch reason { + case .categoryChange, .routeConfigurationChange, .wakeFromSleep, .noSuitableRouteForCategory: + os_log("忽略路由变化原因: %{public}@", log: log, type: .debug, String(describing: reason)) + return + default: + break + } + + // 通话期间避免重配,避免与电话路由冲突 + if isTelephonyActive() { + os_log("通话期间检测到路由变化,跳过重配", log: log, type: .info) + return + } + + // 去抖处理:300ms 内只执行最后一次重配 + // 忽略 categoryChange 等 + // 去抖:在 audioSessionQueue 上延迟调用 + routeChangeDebounceWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.safeConfigureAudioSessionForTTS() + } + routeChangeDebounceWorkItem = work + audioSessionQueue.asyncAfter(deadline: .now() + 0.3, execute: work) +} + +/** + * 释放资源 + */ +public func dispose() { + _ = stop() + synthesisGroup.wait() + + synthesisQueue.sync { + synthesizer = nil + speechConfig = nil + customAudioOutputStream = nil + isInitialized = false + speaking = false + + taskLock.lock() + pendingTasks.removeAll() + taskLock.unlock() + } + + // 新增:移除音频会话观察者,防止泄漏 + if audioSessionObserversAdded { + NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) + NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance()) + audioSessionObserversAdded = false + } +} } - \ No newline at end of file + diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift index 8d3c4f774..29360a1ed 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift @@ -2,6 +2,7 @@ import Foundation import AVFoundation import MicrosoftCognitiveServicesSpeech import os.log +import CallKit /** * 简单音频接收器类,用于处理音频录制和流传输 @@ -15,6 +16,9 @@ public class SimpleAudioReceiver: NSObject { private let tag = "SimpleAudioReceiver" private let log = OSLog(subsystem: "com.azure.speech", category: "SimpleAudioReceiver") + // 用于检测系统电话通话状态(iOS10+) + private let callObserver = CXCallObserver() + /** * 麦克风捕获实例 * 使用 MicrophoneCapture 的单例模式 @@ -187,30 +191,27 @@ public class SimpleAudioReceiver: NSObject { /** * 运行麦克风捕获 + * 针对通话/非通话场景做安全的音频会话配置,避免因会话类别/模式不兼容导致启动录音失败 */ private func runMicrophoneCapture() { do { // 确保正在写入状态 guard self._isWriting else { return } - // 设置音频会话参数 - try audioSession.setCategory(.playAndRecord, - mode: .videoChat, - options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路 - .mixWithOthers, - .defaultToSpeaker]) // 添加音频优先级控制 - try audioSession.setActive(true) - try micCapture.startCapture() - try micCapture.audioDataHandler = { data in + // 根据通话状态进行安全的音频会话配置 + try safeConfigureAudioSessionForMic() + + // 启动麦克风采集 + try micCapture.startCapture() + + // 捕获到的数据放入写入队列(后续处理) + try micCapture.audioDataHandler = { data in self.writeQueue.put(data) } - // // 将数据放入写入队列(后续处理) - // self.writeQueue.put(data) - - + } catch { print("麦克风启动失败: \(error)") - // 【新增】添加详细错误处理 + // 详细错误处理 if let nsError = error as NSError? { print("错误域: \(nsError.domain), 错误代码: \(nsError.code)") print("错误描述: \(nsError.localizedDescription)") @@ -241,8 +242,68 @@ public class SimpleAudioReceiver: NSObject { } } /** - * 继续麦克风捕获 + * 在通话/非通话场景下安全配置音频会话用于麦克风采集 + * - 通话中:使用 .record + .voiceChat + [.allowBluetooth],避免 A2DP 和强制外放,兼容电话路由 + * - 非通话:使用 .playAndRecord + .videoChat + [.allowBluetooth, .mixWithOthers, .defaultToSpeaker] + * - 失败兜底:退回到最简单的 .record + .default,确保录音尽可能可用 + */ + private func safeConfigureAudioSessionForMic() throws { + let audioSession = self.audioSession + do { + // 计算目标配置 + let desiredCategory: AVAudioSession.Category + let desiredMode: AVAudioSession.Mode + let desiredOptions: AVAudioSession.CategoryOptions + + if isTelephonyActive() { + // 通话中:录音优先,避免 A2DP(仅播放),改用 HFP(.allowBluetooth) + desiredCategory = .record + desiredMode = .voiceChat + desiredOptions = [.allowBluetooth] + } else { + // 非通话:保持原有逻辑,但使用 .allowBluetooth 以支持录音(而不是 A2DP) + desiredCategory = .playAndRecord + desiredMode = .videoChat + //desiredOptions = [.allowBluetooth, .mixWithOthers, .defaultToSpeaker] + desiredOptions = [.mixWithOthers, .defaultToSpeaker] + } + + // 仅在类别或模式变化时才调用 setCategory,避免触发不必要的 categoryChange + let needsSetCategory = (audioSession.category != desiredCategory) || (audioSession.mode != desiredMode) + if needsSetCategory { + try audioSession.setCategory(desiredCategory, + mode: desiredMode, + options: desiredOptions) + } else { + // 类别与模式一致时,也尽量避免重复 setCategory;如需确保 options,可在确有必要时再设置 + // 如果你确实需要校正 options,可取消下一行注释,但这可能再次触发 categoryChange + // try audioSession.setCategory(desiredCategory, mode: desiredMode, options: desiredOptions) + } + + // 保持激活;这里重复 setActive(true) 一般安全,但如果你观察到重复激活也触发路由回调,可在外层控制频率 + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + // 兜底:尽量保证能开始录音 + print("音频会话配置失败,尝试兜底配置: \(error)") + try audioSession.setCategory(.record, mode: .default, options: []) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } + } + + /** + * 检测是否存在正在进行的系统电话通话 + * - iOS10+:使用 CallKit 的 CXCallObserver + * - 旧系统:使用 secondaryAudioShouldBeSilencedHint 近似判断 */ + private func isTelephonyActive() -> Bool { + if #available(iOS 10.0, *) { + return callObserver.calls.contains { !$0.hasEnded } + } else { + // 非精确,但可作为兜底判断:若需要静音则可能有电话或系统占用 + return AVAudioSession.sharedInstance().secondaryAudioShouldBeSilencedHint + } + } + /** * 继续麦克风捕获 */ From cbdb3766fd1776dffab03cfab1a70effb557bca2 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 26 Sep 2025 19:43:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E5=92=8C=E9=9F=B3=E4=B9=90=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/controllers/agent_controller.dart | 7 +- lib/modules/agent/views/message_bubble.dart | 4 +- .../agent_service/AgentService.kt | 155 +++++++++++++++--- .../chat_api/QQMusicSystemFunctionHandler.kt | 84 +++++----- .../qq_music/QQMusicSingleton.kt | 63 +++++-- 5 files changed, 226 insertions(+), 87 deletions(-) diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 2852e4f69..d6563ce10 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -795,12 +795,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { orElse: () => "", ); if (musicKey.isNotEmpty) { - // final qqmusic_search_songs = - // metaResult['qqmusic_searchandplay_songs'] - // as Map; - // final data = qqmusic_search_songs['data']; - // if (data != null && data is List) { - Future.delayed(Duration(seconds: 2), () { + Future.delayed(Duration(seconds: 1), () { _qqmusicManager.syncCurrentPlayInfo(isOpenPlay: true); }); // } diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index 63615d5e0..8922a0cb4 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -236,9 +236,9 @@ class MessageBubble extends GetView { //音乐播放列表 return buildMusicContent(context, playlist); } - } else if (meta!.keys.contains('qqmusic_searchandplay_songs')) { + } else if (meta!.keys.contains('qqmusic_search_songs')) { final qqmusic_search_songs = - meta!['qqmusic_searchandplay_songs'] as Map; + meta!['qqmusic_search_songs'] as Map; final data = qqmusic_search_songs['data']; if (data != null && data is List) { //qq音乐 diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 702ee4098..8ea45fded 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -36,6 +36,11 @@ import java.io.IOException import java.text.SimpleDateFormat import java.util.Locale import java.util.Date +// 添加后台启动相关导入 +import android.app.PendingIntent +import android.app.NotificationManager +import android.app.NotificationChannel +import androidx.core.app.NotificationCompat /** @@ -1015,7 +1020,7 @@ object AgentService : CoroutineScope { if (reply) { sendEvent("assistant_response", responseData) } - + Log.d(TAG, "liwei--------------- AI Call onComplete:$response") if (response.isNotEmpty()) { // 添加AI回复到历史记录 addToHistoryMessages(createAssistantMessage(response)) @@ -1081,19 +1086,23 @@ object AgentService : CoroutineScope { audioPlayer?.stopAudio() val name = functionCall.get("name") as String; val resultmap = autoHandleFcunCallResult(name, functionCallResult); - aiMetadata = resultmap["meta"] as String; broadcast = resultmap["broadcast"] as Boolean; reply = !(resultmap["notreply"] as Boolean); iscard = resultmap["iscard"] as Boolean; if (iscard){ - sendEvent( - "function_call_result", mapOf( - "function_call" to functionCall.toString(), - "result" to functionCallResult.toString(), - "meta" to aiMetadata, - ) - ) + aiMetadata = resultmap["meta"] as String; } + sendEvent( + "function_call_result", mapOf( + "function_call" to functionCall.toString(), + "result" to functionCallResult.toString(), + "meta" to aiMetadata, + ) + ) + Log.e( + TAG, + "liwei--------------- AI Call onFunctionCallResult broadcast:$broadcast reply:$reply iscard:$iscard" + ) } catch (e: Exception) { Log.e( TAG, @@ -1859,13 +1868,13 @@ object AgentService : CoroutineScope { toolname: String, functionCallResult: JSONObject ): Map { - println("协议工具返回数据 检查是否存在卡片! functionCallResult: ${functionCallResult}") + // println("协议工具返回数据 检查是否存在卡片! functionCallResult: ${functionCallResult}") try { val contentStr = functionCallResult.optString("context") - println("协议工具返回数据 检查是否存在卡片! context: ${contentStr}") + // println("协议工具返回数据 检查是否存在卡片! context: ${contentStr}") val content = JSONObject(contentStr) val textStr = content.optString("text") - println("协议工具返回数据 检查是否存在卡片! textStr: ${textStr}") + // println("协议工具返回数据 检查是否存在卡片! textStr: ${textStr}") val metadata: MutableMap = mutableMapOf() var broadcast = true; var notreply = false; @@ -2170,12 +2179,25 @@ object AgentService : CoroutineScope { "src=$src") val intent = Intent(Intent.ACTION_VIEW, uri).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + // 添加包名确保启动正确的应用 + setPackage("com.baidu.BaiduMap") } if (isAppInstalled("com.baidu.BaiduMap")) { - context.startActivity(intent) - Log.i(TAG, "已启动百度地图导航") + try { + context.startActivity(intent) + Log.i(TAG, "已启动百度地图导航") + } catch (e: SecurityException) { + // Android 10+ 后台启动限制,尝试使用 PendingIntent + Log.w(TAG, "后台启动受限,尝试使用通知启动百度地图: ${e.message}") + startActivityWithPendingIntent(intent, "百度地图导航") + } catch (e: Exception) { + Log.e(TAG, "启动百度地图失败: ${e.message}", e) + throw e + } } else { Log.w(TAG, "百度地图未安装") throw Exception("百度地图未安装") @@ -2216,12 +2238,25 @@ object AgentService : CoroutineScope { val uri = android.net.Uri.parse(uriBuilder.toString()) val intent = Intent(Intent.ACTION_VIEW, uri).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + // 添加包名确保启动正确的应用 + setPackage("com.autonavi.minimap") } if (isAppInstalled("com.autonavi.minimap")) { - context.startActivity(intent) - Log.i(TAG, "已启动高德地图导航") + try { + context.startActivity(intent) + Log.i(TAG, "已启动高德地图导航") + } catch (e: SecurityException) { + // Android 10+ 后台启动限制,尝试使用 PendingIntent + Log.w(TAG, "后台启动受限,尝试使用通知启动高德地图: ${e.message}") + startActivityWithPendingIntent(intent, "高德地图导航") + } catch (e: Exception) { + Log.e(TAG, "启动高德地图失败: ${e.message}", e) + throw e + } } else { Log.w(TAG, "高德地图未安装") throw Exception("高德地图未安装") @@ -2249,20 +2284,44 @@ object AgentService : CoroutineScope { // 尝试使用Google Maps应用 val appUri = android.net.Uri.parse("comgooglemaps://?saddr=Current+Location&daddr=$destination&directionsmode=$googleMode") val appIntent = Intent(Intent.ACTION_VIEW, appUri).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + // 添加包名确保启动正确的应用 + setPackage("com.google.android.apps.maps") } if (isAppInstalled("com.google.android.apps.maps")) { - context.startActivity(appIntent) - Log.i(TAG, "已启动谷歌地图导航") + try { + context.startActivity(appIntent) + Log.i(TAG, "已启动谷歌地图导航") + } catch (e: SecurityException) { + // Android 10+ 后台启动限制,尝试使用 PendingIntent + Log.w(TAG, "后台启动受限,尝试使用通知启动谷歌地图: ${e.message}") + startActivityWithPendingIntent(appIntent, "谷歌地图导航") + } catch (e: Exception) { + Log.e(TAG, "启动谷歌地图失败: ${e.message}", e) + throw e + } } else { // 回退到网页版 val webUri = android.net.Uri.parse("https://www.google.com/maps/dir/?api=1&origin=Current+Location&destination=$destination&travelmode=$googleMode") val webIntent = Intent(Intent.ACTION_VIEW, webUri).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + } + try { + context.startActivity(webIntent) + Log.i(TAG, "已启动谷歌地图网页版导航") + } catch (e: SecurityException) { + // Android 10+ 后台启动限制,尝试使用 PendingIntent + Log.w(TAG, "后台启动受限,尝试使用通知启动谷歌地图网页版: ${e.message}") + startActivityWithPendingIntent(webIntent, "谷歌地图网页版导航") + } catch (e: Exception) { + Log.e(TAG, "启动谷歌地图网页版失败: ${e.message}", e) + throw e } - context.startActivity(webIntent) - Log.i(TAG, "已启动谷歌地图网页版导航") } } catch (e: Exception) { Log.e(TAG, "启动谷歌地图失败: ${e.message}", e) @@ -2305,6 +2364,54 @@ object AgentService : CoroutineScope { return true } + /** + * 使用 PendingIntent 启动 Activity(适用于后台启动场景) + */ + private fun startActivityWithPendingIntent(intent: android.content.Intent, description: String) { + try { + val pendingIntent = PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), // 使用时间戳作为唯一ID + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // 创建通知渠道(Android 8.0+) + val channelId = "navigation_channel" + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + "导航服务", + NotificationManager.IMPORTANCE_HIGH + ).apply { + this.description = "用于启动导航应用" + enableVibration(true) + enableLights(true) + } + notificationManager.createNotificationChannel(channel) + } + + // 创建通知 + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_map) + .setContentTitle("启动导航") + .setContentText("点击启动$description") + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .build() + + notificationManager.notify(1001, notification) + Log.i(TAG, "已通过通知启动$description") + + } catch (e: Exception) { + Log.e(TAG, "PendingIntent 启动失败: ${e.message}", e) + } + } + /** * 检查应用是否已安装 */ diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt index a820c23d3..79ac37e95 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt @@ -27,16 +27,16 @@ class QQMusicSystemFunctionHandler(private val context: Context? = null) { */ fun registerAllFunctions(client: MCPClient) { try { - // Only register QQ Music search function + // 注册QQ音乐搜索功能(仅搜索,不播放) client.registerLocalFunction( - "qqmusic_searchandplay_songs", - "在QQ音乐中搜索并播放歌曲,支持歌曲、专辑、歌手等搜索类型", + "qqmusic_search_songs", + "在QQ音乐中搜索歌曲,支持歌曲、专辑、歌手等搜索类型", mapOf( "type" to "object", "properties" to mapOf( "keyword" to mapOf( "type" to "string", - "description" to "搜索关键词,歌曲名、专辑名或歌手名" + "description" to "搜索关键词,歌曲名、专辑名或歌手名或者类型" ), "searchType" to mapOf( "type" to "integer", @@ -46,7 +46,30 @@ class QQMusicSystemFunctionHandler(private val context: Context? = null) { ), "required" to listOf("keyword") ), - QQMusicSearchHandler(context) + QQMusicSearchOnlyHandler(context) + ) + + // 注册QQ音乐播放歌曲列表功能 + client.registerLocalFunction( + "qqmusic_play_songs", + "播放指定的QQ音乐歌曲列表", + mapOf( + "type" to "object", + "properties" to mapOf( + "songMids" to mapOf( + "type" to "array", + "items" to mapOf("type" to "string"), + "description" to "歌曲MID列表" + ), + "index" to mapOf( + "type" to "integer", + "description" to "开始播放的歌曲索引,默认为0", + "default" to 0 + ) + ), + "required" to listOf("songMids") + ), + QQMusicPlaySongsHandler(context) ) client.registerLocalFunction( @@ -111,8 +134,8 @@ class QQMusicSystemFunctionHandler(private val context: Context? = null) { } } - -private class QQMusicSearchHandler(private val context: Context?) : FunctionHandler { +// 新的仅搜索处理器(不包含播放功能) +private class QQMusicSearchOnlyHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { return try { val keyword = arguments["keyword"] as? String ?: return "{\"success\": false, \"message\": \"缺少搜索关键词\"}" @@ -124,25 +147,6 @@ private class QQMusicSearchHandler(private val context: Context?) : FunctionHand qqMusicSingleton.searchSongs(keyword) { searchResult -> val result = if (searchResult.isSuccess) { val songs = searchResult.getOrNull() ?: emptyList() - var playError: Throwable? = null - if (songs.isNotEmpty()) { - // 从搜索结果中提取歌曲MID列表 - val songMids = songs.mapNotNull { song -> - val songMap = song as Map - songMap["mid"] as? String - }.filter { it.isNotEmpty() } - - if (songMids.isNotEmpty()) { - // 播放搜索到的歌曲列表,从第一首开始播放 - qqMusicSingleton.playSongs(songMids, 0) { playResult -> - Log.d("QQMusicSearchHandler", "播放结果: ${playResult.isSuccess}") - if (!playResult.isSuccess) { - playError = playResult.exceptionOrNull() -// Log.e("QQMusicSearchHandler", "播放失败: ${playError?.message}") - } - } - } - } // 根据搜索类型处理结果 val processedSongs = when (searchType) { @@ -157,7 +161,7 @@ private class QQMusicSearchHandler(private val context: Context?) : FunctionHand val songsJson = processedSongs.joinToString(",") { song -> val songMap = song as Map // 添加调试日志 - Log.d("QQMusicSearchHandler", "原始数据: $songMap") + Log.d("QQMusicSearchOnlyHandler", "原始数据: $songMap") // 修正字段名映射 val mid = songMap["mid"] ?: "unknown" val name = songMap["name"] ?: "unknown" @@ -169,13 +173,7 @@ private class QQMusicSearchHandler(private val context: Context?) : FunctionHand } if (songs.isNotEmpty()) { - if (playError == null){ - "{\"success\": true, \"iscard\": true, \"broadcast\": false, \"message\": \"搜索成功,正在播放\", \"data\": [$songsJson]}" - }else{ - Log.e("QQMusicSearchHandler", "播放失败: ${playError?.message}") - "{\"success\": false, \"iscard\": true, \"broadcast\": true, \"message\": \"${playError?.message}\", \"data\": [$songsJson]}" - } - + "{\"success\": true, \"message\": \"搜索成功\",\"iscard\": true,\"broadcast\": false, \"data\": [$songsJson]}" } else { "{\"success\": false, \"message\": \"未找到相关歌曲\"}" } @@ -188,7 +186,7 @@ private class QQMusicSearchHandler(private val context: Context?) : FunctionHand } } } catch (e: Exception) { - Log.e("QQMusicSearchHandler", "QQ音乐搜索失败", e) + Log.e("QQMusicSearchOnlyHandler", "QQ音乐搜索失败", e) "{\"success\": false, \"message\": \"搜索异常: ${e.message}\"}" } } @@ -205,16 +203,16 @@ private class QQMusicPlaySongsHandler(private val context: Context?) : FunctionH qqMusicSingleton.playSongs(songMids, index) { playResult -> result = if (playResult.isSuccess) { - "{\"nocard\": true, \"success\": true, \"message\": \"开始播放歌曲列表\"}" + "{\"broadcast\": false, \"success\": true, \"message\": \"开始播放歌曲列表\"}" } else { val error = playResult.exceptionOrNull() - "{\"nocard\": true, \"success\": false, \"message\": \"播放失败: ${error?.message}\"}" + "{\"success\": false, \"message\": \"播放失败: ${error?.message}\"}" } } - // 等待回调完成 - delay(3000) - result.ifEmpty { "{\"nocard\": true, \"success\": false, \"message\": \"播放超时\"}" } + // 等待回调完成 十秒 + delay(10000) + result.ifEmpty { "{\"nocard\": false,\"broadcast\": true, \"success\": false, \"message\": \"播放超时\"}" } } catch (e: Exception) { Log.e("QQMusicPlaySongsHandler", "QQ音乐播放歌曲失败", e) "{\"nocard\": true, \"success\": false, \"message\": \"播放异常: ${e.message}\"}" @@ -230,14 +228,14 @@ private class QQMusicPlayHandler(private val context: Context?) : FunctionHandle qqMusicSingleton.playMusic { playResult -> result = if (playResult.isSuccess) { - "{\"success\": true, \"message\": \"开始播放\"}" + "{\"broadcast\": false, \"success\": true, \"message\": \"开始播放\"}" } else { val error = playResult.exceptionOrNull() "{\"success\": false, \"message\": \"播放失败: ${error?.message}\"}" } } - - delay(2000) + + delay(10000) result.ifEmpty { "{\"success\": false, \"message\": \"播放超时\"}" } } catch (e: Exception) { Log.e("QQMusicPlayHandler", "QQ音乐播放失败", e) diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt index c69c781cd..f0fee9144 100644 --- a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt @@ -567,18 +567,9 @@ class QQMusicSingleton private constructor() { if (allagain > 0 && curragain < allagain){ //重试机制 executePlaybackAction(actionName,curragain+1,allagain,action)(callback) }else{ - handler.post { - Log.e(TAG, "${actionName}失败: $errorCode $errorMessage") - if (errorCode == -1){ - musicManager?.bindQQMusicApiService() - callback(Result.failure(Exception("播放失败: $errorMessage"))) - } else if (errorCode == 1030) { - callback(Result.failure(Exception("播放失败: $errorMessage"))) -// callback(Result.success(Unit)) - }else{ - callback(Result.failure(Exception("播放失败: $errorMessage"))) - } - } + //播放失败时,检查当前播放状态 + Log.d(TAG, "${actionName}失败,检查当前播放状态: $errorCode $errorMessage") + checkPlaybackStateOnError(actionName, errorCode, errorMessage, callback) } } }) @@ -592,7 +583,55 @@ class QQMusicSingleton private constructor() { } } }) + } + + /** + * 播放失败时检查播放状态 + */ + private fun checkPlaybackStateOnError( + actionName: String, + errorCode: Int, + errorMessage: String, + callback: (Result) -> Unit + ) { + musicManager?.getPlaybackState(object : QQMusicManager.InfoCallback { + override fun onSuccess(data: Map) { + val playState = data["state"] as? Int ?: 0 + val isPlaying = when (playState) { + PlayState.STARTED -> true + PlayState.PREPARED -> true + PlayState.BUFFERING -> true + PlayState.PAUSED -> false + PlayState.STOPPED -> false + PlayState.ERROR -> false + else -> false + } + + handler.post { + if (isPlaying) { + Log.d(TAG, "${actionName}虽然返回失败,但检查到正在播放,返回成功") + callback(Result.success(Unit)) + } else { + Log.e(TAG, "${actionName}失败且未在播放: $errorCode $errorMessage") + if (errorCode == -1){ + musicManager?.bindQQMusicApiService() + } + callback(Result.failure(Exception("播放失败: $errorMessage"))) + } + } + } + override fun onError(errorCode: Int, errorMessage: String) { + // 获取播放状态失败,直接返回原始错误 + handler.post { + Log.e(TAG, "${actionName}失败且未在播放: $errorCode $errorMessage") + if (errorCode == -1){ + musicManager?.bindQQMusicApiService() + } + callback(Result.failure(Exception("播放失败: $errorMessage"))) + } + } + }) }