From 80d5ff42e5380f320553dca9c8936828c57c35d3 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Sat, 30 Aug 2025 19:51:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8C=89=E4=BD=8F=E8=AF=B4?= =?UTF-8?q?=E8=AF=9D=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=B8=A6=E8=80=B3=E6=9C=BA?= =?UTF-8?q?=E5=A4=96=E6=94=BE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/views/agent_view.dart | 21 +- .../agent_service/AgentServiceImpl.swift | 84 +++++- .../agent_service/AgentServicePlugin.swift | 40 +++ .../agent_service/AudioSessionHub.swift | 24 +- .../agent_service/lib/agent_service.dart | 24 ++ .../ios/azure_speech/Package.swift | 5 +- .../Sources/azure_speech/AzureAsrHelper.swift | 257 +++++++++++++++--- .../azure_speech/AzureSpeechPlugin.swift | 40 +++ .../Sources/azure_speech/AzureTtsHelper.swift | 79 ++++++ .../Sources/tools/MicrophoneCapture.swift | 201 ++++++++++++-- .../Sources/tools/SimpleAudioReceiver.swift | 30 +- .../ios/Classes/ClassicBluetoothPlugin.h | 4 +- .../ios/Classes/ClassicBluetoothPlugin.m | 12 - .../ios/classic_bluetooth/Package.swift | 23 ++ .../ClassicBluetoothHelper.swift | 164 +++++++++-- .../SwiftClassicBluetoothPlugin.swift | 2 +- local_plugins/classic_bluetooth/pubspec.yaml | 2 +- 17 files changed, 850 insertions(+), 162 deletions(-) delete mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m create mode 100644 local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift rename local_plugins/classic_bluetooth/ios/{Classes => classic_bluetooth/Sources/classic_bluetooth}/ClassicBluetoothHelper.swift (61%) rename local_plugins/classic_bluetooth/ios/{Classes => classic_bluetooth/Sources/classic_bluetooth}/SwiftClassicBluetoothPlugin.swift (98%) diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 054a37ca0..b3a5846b1 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -613,31 +613,12 @@ class _AgentViewState extends State with WidgetsBindingObserver { // 语音输入模式 - 按住说话模式 return Listener( onPointerDown: (_) { - // 设置300ms延迟防止误触 - _holdTimer = Timer( - const Duration(milliseconds: 100), - () { - Haptics.vibrate(HapticsType.success); - controller.startPushToTalk(); - // if (activeSpeaker == 0) { - // if (speakerId == 1) { - // controller - // .restoreOriginalAudioState(); - // } else { - // controller - // .disableBluetoothAudio(); - // } - // controller - // .setActiveSpeaker(speakerId); - // } - }); + controller.startPushToTalk(); }, onPointerUp: (_) { - _holdTimer?.cancel(); controller.endPushToTalk(); }, onPointerCancel: (_) { - _holdTimer?.cancel(); controller.endPushToTalk(); }, // onLongPressStart: (_) { diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 06452fe1f..c2aa49785 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -32,6 +32,9 @@ class AgentServiceImpl: NSObject { private var azureSpeechKey: String = "" private var azureSpeechRegion: String = "" private var config: [String: Any] = [:] + + // 添加音乐播放状态记录 + private var wasMusicPlayingBeforeRecognition: Bool = false private var isSpeaking: Bool = false @@ -62,6 +65,11 @@ class AgentServiceImpl: NSObject { private var hasSpeechDetected: Bool = false internal var isAiStreaming: Bool = false + // 新增:识别"启动中"与"启动中收到停止请求"状态 + // 用途:解决 start 异步、stop 先于 onSessionStarted 到来时被忽略的问题 + private var isStartingRecognition: Bool = false + private var stopRequestedDuringStart: Bool = false + var currsessionId: String = "" var language: String = "" private var serverurl = "" @@ -69,7 +77,7 @@ class AgentServiceImpl: NSObject { private var idleTimer: DispatchSourceTimer? private let maxIdleSeconds: TimeInterval = 10 - + var micCapture: MicrophoneCapture! // 语音识别模式 private var currentRecognitionMode: String = "normal" @@ -81,8 +89,6 @@ class AgentServiceImpl: NSObject { private override init() { super.init() - audioPlayer = AudioPlayer() - azureAsrHelper = AzureAsrHelper() azureTtsHelper = AzureTtsHelper() @@ -92,6 +98,9 @@ class AgentServiceImpl: NSObject { UserPortrait.shared.initialize() BleService.shared.setDelegate(self) AudioSessionHub.initialize() + micCapture = MicrophoneCapture.shared + + audioPlayer = AudioPlayer(agentService: self) } func addListener(_ listener: AgentServiceListener) { @@ -383,7 +392,16 @@ class AgentServiceImpl: NSObject { if isRecognizing { stopRecognition() } - + + // 检查当前音乐播放状态并记录 + let currentPlayState = QPlayAutoManager.shared.currentPlayState + wasMusicPlayingBeforeRecognition = (currentPlayState == .playing) + + // 如果音乐正在播放,则暂停 + if wasMusicPlayingBeforeRecognition { + // QPlayAutoManager.shared.pause() + } + // 设置当前识别模式 currentRecognitionMode = mode os_log("设置语音识别模式: %{public}@", log: logger, type: .info, mode) @@ -395,16 +413,22 @@ class AgentServiceImpl: NSObject { } else { //AudioSessionHub.shared.begin(.voice) } + + // 新增:标记为"启动中",用于允许 stop 在启动未完成时也能生效 + isStartingRecognition = true + print("ai启动语音=\(audioSourceType)=\(useBle)") guard let success = azureAsrHelper?.startContinuousRecognition( // callback: self, audioSourceType: audioSourceType ), success else { + // 新增:启动失败时复位"启动中"状态 + isStartingRecognition = false sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") return false } - // 根据模式启动相应的空闲检测 + // 根据模式启动相应的空闲检测(此处 guard isRecognizing,会在 onSessionStarted 中再次启动) startIdleCheckForMode(mode: mode) return true @@ -421,22 +445,38 @@ class AgentServiceImpl: NSObject { } func stopRecognition() -> Bool { - if !isRecognizing { + /// 函数功能:停止语音识别 + /// 特性: + /// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止 + /// 2) 如果底层尚未完全进入可停止状态,记录一次待停止请求,待会话启动后立即停止 + if !(isRecognizing || isStartingRecognition) { + // 既不在识别中,也不在启动中,视为幂等处理 return true } os_log("停止语音识别,当前模式: %{public}@", log: logger, type: .info, currentRecognitionMode) - guard let success = azureAsrHelper?.stopContinuousRecognition(), success else { - return false + let success = azureAsrHelper?.stopContinuousRecognition() ?? false + + if !success && isStartingRecognition { + // 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop + stopRequestedDuringStart = true } stopIdleCheck() -BleService.shared.closeCodec() + + // 只有之前在播放时才恢复播放 + if wasMusicPlayingBeforeRecognition { + // QPlayAutoManager.shared.play() + wasMusicPlayingBeforeRecognition = false + } + + BleService.shared.closeCodec() // 重置识别模式 currentRecognitionMode = "normal" - return true + // 启动中但已请求停止,也视为已接受停止请求 + return success || isStartingRecognition } func disableBluetoothAudio() { azureAsrHelper?.disableBluetoothAudio() @@ -1493,8 +1533,15 @@ class AudioPlayer { private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AudioPlayer") private let audioQueue = DispatchQueue(label: "agent.audio.player", qos: .userInitiated) + // 添加对AgentServiceImpl的弱引用 + private weak var agentService: AgentServiceImpl? // 等待音效超时时间(秒)- 改为 var 以支持动态修改 private var awaitTimeoutSeconds: TimeInterval = 30.0 + + // 修改初始化方法,接受AgentServiceImpl引用 + init(agentService: AgentServiceImpl) { + self.agentService = agentService + } private var resourcesBundle: Bundle { #if SWIFT_PACKAGE @@ -1548,6 +1595,8 @@ class AudioPlayer { awaitPlayer?.stop() awaitPlayer = nil + + // 停止超时定时器 stopAwaitTimeout() @@ -1711,12 +1760,19 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { sendEvent(name: "recognition_started", data: ["status": "started"]) isRecognizing = true hasSpeechDetected = false + // 新增:会话真正启动,复位"启动中"状态 + isStartingRecognition = false // 使用模式感知的空闲检测启动方法 startIdleCheckForMode(mode: currentRecognitionMode) if (currentRecognitionMode == "ble_wakeup") { audioPlayer?.playStartSound() } - + + // 新增:若在启动期间已经收到停止请求,则立即执行停止 + if stopRequestedDuringStart { + stopRequestedDuringStart = false + _ = stopRecognition() + } } func onSessionStopped() { @@ -1740,6 +1796,9 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { sendEvent(name: "recognition_canceled", data: data) isRecognizing = false + // 新增:取消时复位"启动中/待停止"状态 + isStartingRecognition = false + stopRequestedDuringStart = false stopIdleCheck() } @@ -1747,6 +1806,9 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { let data: [String: Any] = ["message": error.isEmpty ? "未知错误" : error] sendEvent(name: "error", data: data) isRecognizing = false + // 新增:出错时复位"启动中/待停止"状态 + isStartingRecognition = false + stopRequestedDuringStart = false stopIdleCheck() } } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index f113e5a3b..45457b848 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -24,6 +24,34 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { // MARK: - FlutterPlugin 实现 /// 处理Flutter发来的方法调用 + // MARK: - 防抖相关(iOS 原生层) + // 函数级注释:用于限制 startConversation/stopConversation 在极短时间内的重复触发 + private var lastStartConversationAt: TimeInterval = 0 + private var lastStopConversationAt: TimeInterval = 0 + private let conversationDebounceInterval: TimeInterval = 0.6 + + /// 判断是否允许处理“开始对话”的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStartConversation() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStartConversationAt < conversationDebounceInterval { + return false + } + lastStartConversationAt = now + return true + } + + /// 判断是否允许处理“停止对话”的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStopConversation() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStopConversationAt < conversationDebounceInterval { + return false + } + lastStopConversationAt = now + return true + } + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "startAgentService": @@ -41,6 +69,12 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { result(success) case "startConversation": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStartConversation() else { + result(true) + return + } + print("startConversation") let mode = (call.arguments as? [String: Any])?["mode"] as? String ?? "normal" impl.startRecognition(mode: mode) result(true) @@ -50,6 +84,12 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { result(true) case "stopConversation": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStopConversation() else { + result(true) + return + } + print("stopConversation") impl.stopRecognition() result(true) diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift index 75d886488..f9132ee1d 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift @@ -323,19 +323,19 @@ public class AudioSessionHub { public func appDidEnterBackground() { os_log("📱 应用进入后台,准备释放音频会话", log: logger, type: .info) - do { - try audioSession.setActive(false, options: .notifyOthersOnDeactivation) - os_log("✓ 音频会话已释放(应用进入后台)", log: logger, type: .info) - } catch let error as NSError { - let osStatusCode = error.code - let domain = error.domain - let description = error.localizedDescription + // do { + // try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + // os_log("✓ 音频会话已释放(应用进入后台)", log: logger, type: .info) + // } catch let error as NSError { + // let osStatusCode = error.code + // let domain = error.domain + // let description = error.localizedDescription - os_log("❌ 释放音频会话失败", log: logger, type: .error) - os_log(" - 错误域: %{public}@", log: logger, type: .error, domain) - os_log(" - OSStatus代码: %d (0x%X)", log: logger, type: .error, osStatusCode, osStatusCode) - os_log(" - 错误描述: %{public}@", log: logger, type: .error, description) - } + // os_log("❌ 释放音频会话失败", log: logger, type: .error) + // os_log(" - 错误域: %{public}@", log: logger, type: .error, domain) + // os_log(" - OSStatus代码: %d (0x%X)", log: logger, type: .error, osStatusCode, osStatusCode) + // os_log(" - 错误描述: %{public}@", log: logger, type: .error, description) + // } } /// 获取当前音频路由信息 diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 492c6ea2f..c2a051c5e 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -240,7 +240,22 @@ class AgentService { /// 启动语音识别,开始监听用户语音输入 /// [mode] 语音识别模式:'ble_wakeup'(BLE唤醒), 'phone_call'(通话模式), 'push_to_talk'(按住说话), 'normal'(普通模式) /// 返回是否成功开始对话 + // 防抖相关静态字段(全局统一防抖) + // 说明:用于限制 startConversation/stopConversation 在极短时间内的重复触发 + static DateTime? _lastStartConversationAt; + static DateTime? _lastStopConversationAt; + static const Duration _conversationDebounce = Duration(milliseconds: 600); + static Future startConversation({String mode = 'normal'}) async { + // 防抖:短时间内重复点击直接忽略(冷却式防抖) + final now = DateTime.now(); + if (_lastStartConversationAt != null && + now.difference(_lastStartConversationAt!) < _conversationDebounce) { + // 函数级注释:当在冷却时间内再次调用时,直接返回 false 表示此次调用被防抖拦截 + return false; + } + _lastStartConversationAt = now; + try { final bool result = await _channel.invokeMethod('startConversation', { 'mode': mode, @@ -296,6 +311,15 @@ class AgentService { /// 停止语音识别 /// 返回是否成功停止对话 static Future stopConversation() async { + // 防抖:短时间内重复点击直接忽略(冷却式防抖) + final now = DateTime.now(); + if (_lastStopConversationAt != null && + now.difference(_lastStopConversationAt!) < _conversationDebounce) { + // 函数级注释:当在冷却时间内再次调用时,直接返回 false 表示此次调用被防抖拦截 + return false; + } + _lastStopConversationAt = now; + try { final bool result = await _channel.invokeMethod('stopConversation'); return result; diff --git a/local_plugins/azure_speech/ios/azure_speech/Package.swift b/local_plugins/azure_speech/ios/azure_speech/Package.swift index ef7bf57ea..eaa511209 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Package.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Package.swift @@ -11,7 +11,9 @@ let package = Package( // 依赖speech接口模块 .package(path: "../../../speech/ios/speech"), // 添加对ble_service的依赖 - .package(path: "../../../ble_service/ios/ble_service") + .package(path: "../../../ble_service/ios/ble_service"), + // 添加对classic_bluetooth的依赖 + .package(path: "../../../classic_bluetooth/ios/classic_bluetooth") ], targets: [ .target( @@ -19,6 +21,7 @@ let package = Package( dependencies: [ .product(name: "speech", package: "speech"), .product(name: "ble-service", package: "ble_service"), + .product(name: "classic-bluetooth", package: "classic_bluetooth"), "MicrosoftCognitiveServicesSpeech" ], path: "Sources", // 修改为包含整个 Sources 目录 diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index 62783be72..3254e8a26 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -37,6 +37,11 @@ public class AzureAsrHelper: NSObject { private var lastNetworkLostTime: Int64 = 0 private let networkLostDebounceInterval: Int64 = 3000 // 3秒防抖间隔 + // 防抖机制相关变量 - 只保留开始操作的防抖 + private var lastAudioStartTime: Int64 = 0 + private let audioOperationDebounceInterval: Int64 = 500 // 500毫秒防抖间隔 + private var audioStartWorkItem: DispatchWorkItem? + // 状态管理 private var _isContinuousRecognitionActive = false @@ -216,11 +221,137 @@ public class AzureAsrHelper: NSObject { /** - * 开始连续语音识别 + * 异步启动音频录制(带防抖与状态二次校验) * - * @param callback 连续识别结果回调 * @param audioSourceType 音频源类型 - * @return 是否成功开始识别 + * @param audioDataCallback 音频数据回调 + * @param completion 完成回调(true/false) + */ + /// 异步启动音频录制(带防抖与状态二次校验) + /// - 参数: + /// - audioSourceType: 音频源类型 + /// - audioDataCallback: 音频数据回调 + /// - completion: 完成回调(true/false) + /// + /// 修复点: + /// 1) 新任务创建前,取消旧的未执行任务,确保只有“最后一次启动请求”会生效 + /// 2) 任务执行前二次校验:必须是当前挂起任务且 _isContinuousRecognitionActive 仍为 true 才执行启动 + private func startAudioRecordAsync( + audioSourceType: AudioSourceType, + audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil, + completion: @escaping (Bool) -> Void +) { + let currentTime = Int64(Date().timeIntervalSince1970 * 1000) + + // 防抖:距离上次启动过短,取消旧任务 + if currentTime - lastAudioStartTime < audioOperationDebounceInterval { + audioStartWorkItem?.cancel() + os_log("音频启动操作被防抖过滤,距离上次启动仅%{public}dms", log: log, type: .debug, currentTime - lastAudioStartTime) + } else { + // 即使未触发防抖,也保证旧任务不会残留 + audioStartWorkItem?.cancel() + } + lastAudioStartTime = currentTime + + // 创建新的启动任务 + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { + completion(false) + return + } + print("startAudioRecordAsync\(_isContinuousRecognitionActive)") + // 二次校验:stop 可能已发生,确认仍需启动 + guard self._isContinuousRecognitionActive else { + os_log("已收到停止请求,取消启动音频录制", log: self.log, type: .info) + completion(false) + return + } + + os_log("开始异步启动音频录制", log: self.log, type: .info) + + // 在后台队列执行耗时的音频启动操作 + DispatchQueue.global(qos: .userInitiated).async { + let success = self.performAudioStart(audioSourceType: audioSourceType, audioDataCallback: audioDataCallback) + + // 回到主队列执行完成回调 + DispatchQueue.main.async { + completion(success) + } + } + } + + // 保存"当前挂起任务" + audioStartWorkItem = workItem + + // 延迟执行以实现防抖 + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(Int(audioOperationDebounceInterval)), + execute: workItem + ) +} + + /** + * 执行音频启动操作 + */ + private func performAudioStart( + audioSourceType: AudioSourceType, + audioDataCallback: SimpleAudioReceiver.AudioDataCallback? + ) -> Bool { + do { + audioStream?.startAudioRecord( + audioSourceType: audioSourceType == .microphone ? .microphone : .external, + audioDataCallback: audioDataCallback + ) + os_log("音频录制启动成功", log: log, type: .info) + return true + } catch { + os_log("音频录制启动失败: %{public}@", log: log, type: .error, error.localizedDescription) + return false + } + } + + /** + * 异步停止音频录制(移除防抖机制,立即执行) + * + * @param completion 完成回调 + */ + private func stopAudioRecordAsync(completion: @escaping (Bool) -> Void) { + os_log("开始立即停止音频录制", log: log, type: .info) + + // 在后台队列执行耗时的音频停止操作 + DispatchQueue.global(qos: .userInitiated).async { + let success = self.performAudioStop() + + // 回到主队列执行完成回调 + DispatchQueue.main.async { + completion(success) + } + } + } + + /** + * 执行音频停止操作 + */ + private func performAudioStop() -> Bool { + do { + audioStream?.stopMicrophoneCapture() + os_log("音频录制停止成功", log: log, type: .info) + return true + } catch { + os_log("音频录制停止失败: %{public}@", log: log, type: .error, error.localizedDescription) + return false + } + } + + /** + * 开始连续语音识别(优化:在音频启动成功后再次校验活跃状态) + * + * @param audioSourceType 音频源类型 + * @param audioDataCallback 音频数据回调 + * @return 是否成功开始识别(调度成功即返回 true) + * + * 修复点:在音频启动成功后的闭包中,先判断 _isContinuousRecognitionActive 是否仍然为 true, + * 若 stop 已发生则直接跳过 recognizer 的启动,避免“已停止但仍启动识别”的情况。 */ public func startContinuousRecognition( audioSourceType: AudioSourceType = .microphone, @@ -241,45 +372,61 @@ public class AzureAsrHelper: NSObject { return true } - self.audioSourceType = audioSourceType + // 先取消任何挂起的启动任务 + cancelPendingAudioStart() + // 延迟设置状态,直到音频启动任务真正开始执行 + self.audioSourceType = audioSourceType + print("startContinuousRecognition:准备启动") do { - if recognizer == nil { + if recognizer == nil { // 检查continuousCallback是否为空,只有在不为空时才设置事件监听器 if let callback = self.continuousCallback { setupEventListeners(callback: callback) } } - - print("startContinuousRecognition:\(audioSourceType)") - // 启动音频处理 - audioStream?.startAudioRecord(audioSourceType: audioSourceType == .microphone ? .microphone : .external, audioDataCallback: audioDataCallback) - // 开始连续识别 - try recognizer?.startContinuousRecognition() - _isContinuousRecognitionActive = true - - os_log("连续识别已启动,音频源: %{public}@", log: log, type: .info, audioSourceType == .microphone ? "麦克风" : "外部") + print("startContinuousRecognition:\(audioSourceType)") + // 只有在音频启动成功后才设置状态为 true + self._isContinuousRecognitionActive = true + // 异步启动音频处理 + startAudioRecordAsync(audioSourceType: audioSourceType, audioDataCallback: audioDataCallback) { [weak self] (success: Bool) in + guard let self = self else { return } + + if success { + // 只有在音频启动成功后才设置状态为 true + self._isContinuousRecognitionActive = true + print("startContinuousRecognition:\(self._isContinuousRecognitionActive)") + + // 启动语音识别 + do { + try self.recognizer?.startContinuousRecognition() + os_log("连续识别已启动,音频源: %{public}@", log: self.log, type: .info, audioSourceType == .microphone ? "麦克风" : "外部") + } catch { + os_log("启动连续识别失败: %{public}@", log: self.log, type: .error, error.localizedDescription) + self.stopContinuousRecognition() + self._isContinuousRecognitionActive = false + } + } else { + os_log("音频启动失败,无法开始识别", log: self.log, type: .error) + self._isContinuousRecognitionActive = false + } + } return true } catch { stopContinuousRecognition() _isContinuousRecognitionActive = false - //callback.onError("启动连续识别失败: \(error.localizedDescription)") return false } } - + + /** - * 停止连续语音识别 - * - * @return 停止操作是否成功 - */ - /** - * 停止连续语音识别 - * @return 停止操作是否成功 + * 停止连续语音识别(优化:确保取消未执行的启动任务) + * - 返回: 停止操作是否成功 */ public func stopContinuousRecognition() -> Bool { guard speechConfig != nil else { @@ -288,17 +435,29 @@ public class AzureAsrHelper: NSObject { } guard let recognizer = recognizer else { os_log("识别器为空,重置状态", log: log, type: .info) - return true - } - - if !_isContinuousRecognitionActive { + // 仍需取消潜在的挂起启动任务,避免稍后被触发 + cancelPendingAudioStart() return true } do { - _isContinuousRecognitionActive = false + + + // 关键:取消尚未执行的启动任务,防止 stop 后音频又被启动 + cancelPendingAudioStart() + + // 异步停止音频处理 + stopAudioRecordAsync { [weak self] (success: Bool) in + guard let self = self else { return } + if success { + // 标记不再活跃,供启动任务二次校验 + self._isContinuousRecognitionActive = false + os_log("音频停止成功", log: self.log, type: .info) + } else { + os_log("音频停止失败", log: self.log, type: .error) + } + } - audioStream?.stopMicrophoneCapture() // 停止连续识别 try recognizer.stopContinuousRecognition() self.recognizer = nil @@ -307,24 +466,30 @@ public class AzureAsrHelper: NSObject { } catch { // 强制重置状态 _isContinuousRecognitionActive = false + + // 关键:即使异常,也取消未执行的启动任务 + cancelPendingAudioStart() + os_log("强制停止识别失败", log: log, type: .error) - // 停止音频处理 - audioStream?.stopMicrophoneCapture() + // 异步停止音频处理 + stopAudioRecordAsync { [weak self] (_: Bool) in + guard let self = self else { return } + os_log("强制停止音频处理完成", log: self.log, type: .info) + } - // 【修复】在 catch 块中安全地停止识别器,避免再次抛出异常 + // 尝试安全地停止识别器 do { try recognizer.stopContinuousRecognition() - self.recognizer = nil + self.recognizer = nil } catch { - // 如果强制停止也失败,记录错误但不再抛出异常 os_log("强制停止识别器失败: %@", log: log, type: .error, error.localizedDescription) } return false } } - + /** * 检查连续识别是否活跃 */ @@ -333,7 +498,7 @@ public class AzureAsrHelper: NSObject { } /** - * 释放所有资源 + * 释放资源 */ public func dispose() { do { @@ -344,7 +509,8 @@ public class AzureAsrHelper: NSObject { try? recognizer?.stopContinuousRecognition() _isContinuousRecognitionActive = false } - + // 取消防抖任务 - 只取消开始操作的防抖 + audioStartWorkItem?.cancel() // 停止音频处理 audioStream?.releaseAudioResources() // 释放网络监听资源 @@ -353,7 +519,7 @@ public class AzureAsrHelper: NSObject { recognizer = nil speechConfig = nil audioConfig = nil - audioStream = nil + audioStream = nil // 确保状态被重置 _isContinuousRecognitionActive = false //externalAudioStream = nil @@ -362,8 +528,8 @@ public class AzureAsrHelper: NSObject { _isContinuousRecognitionActive = false //externalAudioStream = nil audioConfig = nil - recognizer = nil - speechConfig = nil + recognizer = nil + speechConfig = nil audioStream = nil } } @@ -853,6 +1019,15 @@ extension AzureAsrHelper: NetworkStateMonitor.NetworkStateListener { self.handleNetworkLost() } - + // .MARK: - 启动任务管理 + /// 取消尚未执行的音频启动任务,防止 stop 后仍然触发启动 + private func cancelPendingAudioStart() { + audioStartWorkItem?.cancel() + audioStartWorkItem = nil + } } + + + + diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 9a772df97..2f5f25bd5 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -20,6 +20,34 @@ import ble_service // 日志对象 private let log = OSLog(subsystem: "com.azure.speech", category: "AzureSpeechPlugin") + // MARK: - 防抖相关(iOS 原生层) + // 函数级注释:用于限制 startContinuousRecognition/stopContinuousRecognition 在极短时间内的重复触发 + private var lastStartRecognitionAt: TimeInterval = 0 + private var lastStopRecognitionAt: TimeInterval = 0 + private let recognitionDebounceInterval: TimeInterval = 0.6 + + /// 判断是否允许处理"开始连续识别"的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStartRecognition() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStartRecognitionAt < recognitionDebounceInterval { + return false + } + lastStartRecognitionAt = now + return true + } + + /// 判断是否允许处理"停止连续识别"的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStopRecognition() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStopRecognitionAt < recognitionDebounceInterval { + return false + } + lastStopRecognitionAt = now + return true + } + // 替换原有的 StreamHandler 类 private typealias AsrEventStreamHandler = BaseEventStreamHandler private typealias TtsEventStreamHandler = BaseEventStreamHandler @@ -263,6 +291,12 @@ private func sendAstEvent(_ event: [String: Any]) { result(FlutterError(code: "NOT_SUPPORTED", message: "iOS版本不支持recognizeOnce", details: nil)) case "startContinuousRecognition": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStartRecognition() else { + result(true) + return + } + // 确保事件通道已准备好 guard asrEventSink != nil else { result(FlutterError(code: "EVENT_CHANNEL_NOT_READY", message: "事件通道未准备好,无法开始连续识别", details: nil)) @@ -311,7 +345,13 @@ private func sendAstEvent(_ event: [String: Any]) { return case "stopContinuousRecognition": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStopRecognition() else { + result(true) + return + } + print("stopContinuousRecognition") let success = azureAsrHelper.stopContinuousRecognition() currentAsrCallback = nil result(success) 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 bd9ccfc32..dd6e17009 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 @@ -33,6 +33,11 @@ public class AzureTtsHelper: NSObject, ITtsService { 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 // 添加处理状态标志 @@ -43,6 +48,7 @@ public class AzureTtsHelper: NSObject, ITtsService { // 自定义音频输出流 private var customAudioOutputStream: SPXPushAudioOutputStream? private var useInternalPlayer = true + private var micCapture: MicrophoneCapture! // 记录最后播放的文本 private var lastSpokenText: String? /** @@ -50,6 +56,7 @@ public class AzureTtsHelper: NSObject, ITtsService { */ public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool { do { + micCapture = MicrophoneCapture.shared // 创建语音配置 speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) speechConfig?.speechSynthesisLanguage = language @@ -110,10 +117,20 @@ public class AzureTtsHelper: NSObject, ITtsService { } } + /** + * 开始语音合成会话 + * @param sessionid 会话ID + * @return 是否成功启动 + */ public func startspeak(sessionid sessionid: String)-> Bool { self.sessionid = sessionid + + // 重置当前会话的文本计数 + self.currentSessionTextCount = 0 + self.pendingTextCount = 0 return true } + /** * 单次合成并播放 */ @@ -131,6 +148,7 @@ public class AzureTtsHelper: NSObject, ITtsService { return false } + // 清理文本 let cleanedText = cleanTextForTTS(text) if cleanedText.isEmpty { @@ -165,6 +183,12 @@ public class AzureTtsHelper: NSObject, ITtsService { return true } + // 增加文本计数统计 + totalTextCount += text.count + currentSessionTextCount += text.count + + print("流式合成文本\(text.count),总计文本数量:\(totalTextCount),当前会话:\(currentSessionTextCount)") + // 清理文本并添加到缓冲区 let cleanedText = cleanTextForTTS(text) streamBuffer.append(cleanedText) @@ -234,6 +258,8 @@ public class AzureTtsHelper: NSObject, ITtsService { sessionid = "" streamBuffer = "" lastSpokenText = "" + // 重置计数器 + pendingTextCount = 0 taskLock.lock() pendingTasks.removeAll() taskLock.unlock() @@ -242,6 +268,13 @@ public class AzureTtsHelper: NSObject, ITtsService { print("liwei-------------停止语音合成播放") try self.synthesizer?.stopSpeaking() self.notifyEvent(eventType: .synthesisCanceled) + + if !micCapture.isCapturing{ + // 停止时设置音频会话为非活跃状态 + try AVAudioSession.sharedInstance().setActive(false) + } + + print("音频会话已设置为非活跃状态") } catch { os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) self.notifyEvent(eventType: .error, params: [ @@ -429,6 +462,8 @@ public class AzureTtsHelper: NSObject, ITtsService { } } catch { speaking = false + // 减少待处理文本计数 + pendingTextCount = max(0, pendingTextCount - text.count) os_log("语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) notifyEvent(eventType: .error, params: [ "errorCode": "SYNTHESIS_FAILED", @@ -481,14 +516,31 @@ public class AzureTtsHelper: NSObject, ITtsService { synthesizer?.addSynthesisCompletedEventHandler { [weak self] _, _ in guard let self = self else { return } self.speaking = false + + // 减少待处理文本计数 + self.pendingTextCount = max(0, self.pendingTextCount - 1) + self.notifyEvent(eventType: .synthesisCompleted) self.notifyEvent(eventType: .playbackCompleted) + + // 检查是否为最后一个文本播放完毕 + if self.pendingTextCount == 0 && self.pendingTasks.isEmpty && !micCapture.isCapturing{ + do { + try AVAudioSession.sharedInstance().setActive(false) + print("最后一个文本播放完毕,音频会话已设置为非活跃状态") + } catch { + print("设置音频会话为非活跃状态失败: \(error.localizedDescription)") + } + } } synthesizer?.addSynthesisCanceledEventHandler { [weak self] _, event in guard let self = self else { return } self.speaking = false + // 减少待处理文本计数 + self.pendingTextCount = max(0, self.pendingTextCount - 1) + var params: [String: Any] = [:] if let details = try? SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: event.result) { params["errorCode"] = details.errorCode @@ -604,4 +656,31 @@ public class AzureTtsHelper: NSObject, ITtsService { public func isSpeaking() -> Bool { return speaking } + +/** + * 获取总计文本数量 + */ +public func getTotalTextCount() -> Int { + return totalTextCount +} + +/** + * 获取当前会话文本数量 + */ +public func getCurrentSessionTextCount() -> Int { + return currentSessionTextCount +} + +/** + * 重置文本计数统计 + */ +public func resetTextCount() { + totalTextCount = 0 + currentSessionTextCount = 0 + pendingTextCount = 0 +} + + } + + diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift index d6479f87d..b888aa715 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift @@ -1,21 +1,23 @@ import AVFoundation +import classic_bluetooth // 添加这行导入语句 -class MicrophoneCapture: NSObject { +// 将 class 改为 public class +public class MicrophoneCapture: NSObject { // MARK: - 单例实例 /** * 单例实例,确保整个应用中只有一个音频捕获对象 * 避免多个实例同时访问麦克风资源导致冲突 */ - static let shared = MicrophoneCapture() + public static let shared = MicrophoneCapture() private var audioEngine: AVAudioEngine! private var audioSession: AVAudioSession! private var audioFormat: AVAudioFormat? private var audioInputNode: AVAudioInputNode! private var sampleRate: Double = 16000 // 修改为16000采样率,符合Microsoft要求 - private var isCapturing = false + public var isCapturing = false + public var hasBluetoothDevices = false - // 音频数据回调 var audioDataHandler: ((Data) -> Void)? /** @@ -25,6 +27,41 @@ class MicrophoneCapture: NSObject { private override init() { super.init() setupAudioSession() + + // 监听音频路由变化 + NotificationCenter.default.addObserver( + self, + selector: #selector(audioRouteChanged), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + } + + /** + * 音频路由变化处理 + * 当用户插拔耳机时自动调整音频路由 + */ + @objc private func audioRouteChanged(notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + switch reason { + case .newDeviceAvailable, .oldDeviceUnavailable: + // 设备连接或断开时重新配置音频路由 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.configureAudioRoute() + } + default: + break + } + } + + deinit { + stopCapture() + NotificationCenter.default.removeObserver(self) } /// 获取最佳音频格式 (iOS 通常支持标准采样率) @@ -48,23 +85,12 @@ class MicrophoneCapture: NSObject { audioSession = AVAudioSession.sharedInstance() do { - // 设置音频会话参数 - try audioSession.setCategory(.playAndRecord, - mode: .videoChat, - options: [//.allowBluetooth, // 允许蓝牙耳机,占用hfp链路 - // .allowAirPlay, - .allowBluetoothA2DP,// 允许蓝牙耳机,不占用hfp链路 - .mixWithOthers, - .defaultToSpeaker - ]) - + try audioSession.setPreferredSampleRate(sampleRate) try audioSession.setPreferredIOBufferDuration(0.005) // 5ms缓冲 - // 设置蓝牙设备优先级 - if let bluetoothInput = audioSession.availableInputs?.first(where: { $0.portType == .bluetoothHFP }) { - try audioSession.setPreferredInput(bluetoothInput) - } + // 优先设置蓝牙音频路由 + configureAudioRoute() try audioSession.setActive(true) print("音频会话配置成功") @@ -73,14 +99,107 @@ class MicrophoneCapture: NSObject { } } + /** + * 配置音频输入输出路由 + * 根据蓝牙连接状态自动配置音频路由 + */ + private func configureAudioRoute() { + // 检查是否有蓝牙音频设备连接 + let bluetoothHelper = ClassicBluetoothHelper.shared + self.hasBluetoothDevices = !bluetoothHelper.getConnectedAudioDevices().isEmpty + + if self.hasBluetoothDevices { + // 有蓝牙连接:末端在手机,输出端在耳机 + configureAudioForBluetoothMode() + } else { + // 无蓝牙连接:末端和输出端都在手机 + configureAudioForPhoneMode() + } + } + + /** + * 配置蓝牙模式的音频路由:末端在手机,输出端在耳机 + */ + private func configureAudioForBluetoothMode() { + guard let availableInputs = audioSession.availableInputs else { return } + + // 强制使用内置麦克风作为输入(手机) + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + do { + try audioSession.setPreferredInput(builtInMic) + print("蓝牙模式:音频输入设置为内置麦克风(手机)") + } catch { + print("设置内置麦克风失败: \(error.localizedDescription)") + } + } + + // 检查当前音频输出路由 + let currentRoute = audioSession.currentRoute + print("当前音频输出路由: \(currentRoute.outputs.map { $0.portType.rawValue })") + + // 确保音频输出通过蓝牙耳机 + let hasBluetoothOutput = currentRoute.outputs.contains { output in + [.bluetoothHFP, .bluetoothLE].contains(output.portType) + } + + if hasBluetoothOutput { + // 取消扬声器强制输出,让音频通过蓝牙耳机输出 + do { + // 设置音频会话参数 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路 + .mixWithOthers, + .allowBluetooth]) // 添加音频优先级控制 + + try audioSession.overrideOutputAudioPort(.none) + print("蓝牙模式:音频输出设置为蓝牙耳机") + } catch { + print("设置蓝牙音频输出失败: \(error.localizedDescription)") + } + } + } + + /** + * 配置手机模式的音频路由:末端和输出端都在手机 + */ + private func configureAudioForPhoneMode() { + guard let availableInputs = audioSession.availableInputs else { return } + + // 使用内置麦克风作为输入(手机) + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + do { + // 设置音频会话参数 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路 + .mixWithOthers, + .defaultToSpeaker]) // 添加音频优先级控制 + try audioSession.setPreferredInput(builtInMic) + print("手机模式:音频输入设置为内置麦克风(手机)") + } catch { + print("设置内置麦克风失败: \(error.localizedDescription)") + } + } + + // 强制音频输出通过扬声器(手机) + do { + try audioSession.overrideOutputAudioPort(.speaker) + print("手机模式:音频输出设置为扬声器(手机)") + } catch { + print("设置扬声器输出失败: \(error.localizedDescription)") + } + } + // MARK: - 开始捕获 /** * 开始音频捕获 * 检查麦克风权限并启动音频引擎 */ public func startCapture() throws { + print("开始捕获\(isCapturing)") guard !isCapturing else { return } - print("开始捕获") + // 检查麦克风权限 switch audioSession.recordPermission { case .granted: @@ -132,7 +251,7 @@ class MicrophoneCapture: NSObject { */ private func setupAudioEngine() throws { audioEngine = AVAudioEngine() - + audioInputNode = audioEngine.inputNode // 获取实际硬件采样率 @@ -149,6 +268,7 @@ class MicrophoneCapture: NSObject { try audioInputNode.setVoiceProcessingEnabled(true) } + // 检查输入格式是否符合Microsoft要求 let needsConversion = !isMicrosoftCompatibleFormat(inputFormat) @@ -295,12 +415,39 @@ class MicrophoneCapture: NSObject { */ func stopCapture() { guard isCapturing else { return } - - audioEngine.stop() - audioInputNode.removeTap(onBus: 0) - // try? audioSession.setActive(false) isCapturing = false - } + + // 安全地停止音频引擎 + if let engine = audioEngine { + engine.stop() + } + + // 安全地移除音频处理块 + if let inputNode = audioInputNode { + inputNode.removeTap(onBus: 0) + } + + try? audioSession.setActive(false) + if self.hasBluetoothDevices { + // 设置音频会话参数 + try? audioSession.setCategory(.playback, + mode: .videoChat, + options: [ + + .allowBluetoothA2DP, + .allowBluetooth + + ]) // 添加音频优先级控制 + } else { + try? audioSession.setCategory(.playback, + mode: .videoChat, + options: [ + .mixWithOthers, + .defaultToSpeaker]) + } + try? audioSession.overrideOutputAudioPort(.none) + try? audioSession.setActive(true) + } /** * 重置音频捕获状态 @@ -311,7 +458,5 @@ class MicrophoneCapture: NSObject { audioDataHandler = nil } - deinit { - stopCapture() - } + } \ 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 628571c53..47cef8139 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 @@ -109,14 +109,14 @@ public class SimpleAudioReceiver: NSObject { public func startAudioRecord(audioSourceType: AudioSourceType = .microphone, audioDataCallback: AudioDataCallback?) { _isWriting=true self.audioSourceType = audioSourceType; - self.audioDataCallback = audioDataCallback - print("startAudioRecord=audioSourceType\(audioSourceType)") - switch audioSourceType { - case .microphone: + self.audioDataCallback = audioDataCallback + print("startAudioRecord=audioSourceType\(audioSourceType)") + switch audioSourceType { + case .microphone: runMicrophoneCapture() - case .external: + case .external: runExternalCapture() - } + } } /** @@ -183,6 +183,13 @@ public class SimpleAudioReceiver: NSObject { 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 self.writeQueue.put(data) @@ -203,6 +210,17 @@ public class SimpleAudioReceiver: NSObject { private func runExternalCapture() { if audioSourceType == .external { + do { + try audioSession.setCategory(.playback, + mode: .videoChat, + options: [ .mixWithOthers,.allowBluetoothA2DP // 允许蓝牙耳机,不占用hfp链路 + ]) // 添加音频优先级控制 + try audioSession.overrideOutputAudioPort(.none) + try audioSession.setActive(true) + } catch { + print("设置音频会话失败: \(error.localizedDescription)") + } + //pushAudioData(data: Data()) micCapture.stopCapture() diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h index 69a8a8d4e..dd9690df4 100644 --- a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h @@ -1,4 +1,4 @@ #import -@interface ClassicBluetoothPlugin : NSObject -@end \ No newline at end of file +@interface SwiftClassicBluetoothPlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m deleted file mode 100644 index 93666d6e1..000000000 --- a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m +++ /dev/null @@ -1,12 +0,0 @@ -#import "ClassicBluetoothPlugin.h" -#if __has_include() -#import -#else -#import "classic_bluetooth-Swift.h" -#endif - -@implementation ClassicBluetoothPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - [SwiftClassicBluetoothPlugin registerWithRegistrar:registrar]; -} -@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift new file mode 100644 index 000000000..c5a9d1c96 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "classic_bluetooth", + platforms: [.iOS("16.0")], + products: [ + .library( + name: "classic-bluetooth", + targets: ["classic_bluetooth"] + ) + ], + dependencies: [ + // 如果需要其他依赖,在这里添加 + ], + targets: [ + .target( + name: "classic_bluetooth", + dependencies: [], + path: "Sources" + ) + ] +) \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift similarity index 61% rename from local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift rename to local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift index 533482149..a2210ae40 100644 --- a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift @@ -3,14 +3,14 @@ import AVFoundation import CoreBluetooth /// 蓝牙事件类型 -enum BluetoothEventType { +public enum BluetoothEventType { case stateChanged case deviceConnected case deviceDisconnected } /// 蓝牙状态枚举 -enum BluetoothState: String { +public enum BluetoothState: String { case unknown = "unknown" case off = "off" case on = "on" @@ -19,7 +19,10 @@ enum BluetoothState: String { } /// 简化的蓝牙助手类,专注于iOS上的蓝牙耳机检测 -class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { +public class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { + /// 单例实例 + public static let shared = ClassicBluetoothHelper() + private var centralManager: CBCentralManager? private var bluetoothState: CBManagerState = .unknown @@ -30,16 +33,132 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { // 音频路由变化观察者 private var routeChangeObserver: NSObjectProtocol? - override init() { + // 添加自动启动标志 + private var autoStartEnabled: Bool = false + + /// 私有初始化方法,防止外部创建实例 + private override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil) + // 应用启动时自动开始监听 + enableAutoStart() + } + + /** + * 启用自动启动蓝牙监听功能 + * 应用启动时自动开始监听蓝牙状态和音频路由变化 + */ + func enableAutoStart() { + autoStartEnabled = true + // 自动开始蓝牙状态监听 + startBluetoothStateMonitoring { [weak self] eventType, data in + self?.handleBluetoothEvent(eventType: eventType, data: data) + } + print("蓝牙监听服务已自动启动") + } + + /** + * 处理蓝牙事件并自动配置音频路由 + */ + private func handleBluetoothEvent(eventType: BluetoothEventType, data: [String: Any]) { + switch eventType { + case .stateChanged: + if let state = data["state"] as? String { + print("蓝牙状态变化: \(state)") + configureAudioRouteBasedOnBluetoothState() + } + case .deviceConnected: + print("蓝牙设备已连接") + // 蓝牙设备连接时:末端在手机,输出端在耳机 + configureAudioForBluetoothConnected() + case .deviceDisconnected: + print("蓝牙设备已断开") + // 蓝牙设备断开时:末端和输出端都在手机 + configureAudioForBluetoothDisconnected() + } + } + + /** + * 根据蓝牙状态配置音频路由 + */ + private func configureAudioRouteBasedOnBluetoothState() { + let hasBluetoothDevices = !getConnectedAudioDevices().isEmpty + + if hasBluetoothDevices { + configureAudioForBluetoothConnected() + } else { + configureAudioForBluetoothDisconnected() + } + } + + /** + * 配置蓝牙连接时的音频路由:末端在手机,输出端在耳机 + */ + private func configureAudioForBluetoothConnected() { + let audioSession = AVAudioSession.sharedInstance() + + do { + // 设置音频会话类别,允许蓝牙音频 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.allowBluetoothA2DP]) + + // 设置音频输入为内置麦克风(手机) + if let availableInputs = audioSession.availableInputs { + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + try audioSession.setPreferredInput(builtInMic) + print("音频输入设置为内置麦克风(手机)") + } + } + + // 音频输出自动路由到蓝牙耳机 + try audioSession.overrideOutputAudioPort(.none) // 不强制扬声器输出,让系统自动选择蓝牙 + print("音频输出设置为蓝牙耳机") + + try audioSession.setActive(true) + + } catch { + print("配置蓝牙连接音频路由失败: \(error.localizedDescription)") + } + } + + /** + * 配置蓝牙断开时的音频路由:末端和输出端都在手机 + */ + private func configureAudioForBluetoothDisconnected() { + let audioSession = AVAudioSession.sharedInstance() + + do { + // 设置音频会话类别,不使用蓝牙 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.mixWithOthers]) + + // 设置音频输入为内置麦克风(手机) + if let availableInputs = audioSession.availableInputs { + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + try audioSession.setPreferredInput(builtInMic) + print("音频输入设置为内置麦克风(手机)") + } + } + + // 设置音频输出为扬声器(手机) + try audioSession.overrideOutputAudioPort(.speaker) + print("音频输出设置为扬声器(手机)") + + try audioSession.setActive(true) + + } catch { + print("配置蓝牙断开音频路由失败: \(error.localizedDescription)") + } } deinit { stopBluetoothStateMonitoring() } - func centralManagerDidUpdateState(_ central: CBCentralManager) { + /// 蓝牙状态更新回调 + public func centralManagerDidUpdateState(_ central: CBCentralManager) { bluetoothState = central.state print("lxm---蓝牙状态更新: \(bluetoothState.rawValue)") @@ -50,7 +169,8 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } } - func isBluetoothEnabled() -> Bool { + /// 检查蓝牙是否可用 + public func isBluetoothEnabled() -> Bool { switch bluetoothState { case .poweredOn: return true @@ -83,7 +203,7 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } // 获取连接的蓝牙音频设备 - private func getConnectedAudioDevices() -> [[String: String]] { + public func getConnectedAudioDevices() -> [[String: String]] { var result: [[String: String]] = [] // 获取当前音频路由 @@ -103,7 +223,7 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } // 检查是否为蓝牙输出 - private func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { + public func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { return portType == .bluetoothA2DP || portType == .bluetoothHFP || portType == .bluetoothLE } @@ -116,8 +236,10 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { return address } - /// 开始监听蓝牙状态变化 - func startBluetoothStateMonitoring(callback: @escaping (BluetoothEventType, [String: Any]) -> Void) { + /** + * 开始监听蓝牙状态变化 + */ + public func startBluetoothStateMonitoring(callback: @escaping (BluetoothEventType, [String: Any]) -> Void) { self.stateCallback = callback if !isMonitoring { @@ -133,9 +255,6 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { self?.handleAudioRouteChange(notification) } - // 激活音频会话 - activateAudioSession() - // 报告当前状态 reportCurrentBluetoothState() } @@ -186,19 +305,10 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } } - // 激活音频会话 - private func activateAudioSession() { - do { - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playback, mode: .default) - try audioSession.setActive(true) - } catch { - print("设置音频会话失败: \(error.localizedDescription)") - } - } - - /// 停止监听蓝牙状态变化 - func stopBluetoothStateMonitoring() { + /** + * 停止监听蓝牙状态变化 + */ + public func stopBluetoothStateMonitoring() { if isMonitoring { isMonitoring = false @@ -226,7 +336,7 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } /// 获取蓝牙可用性状态(优化后的统一状态判断方法) - func getBluetoothAvailability() -> BluetoothState { + public func getBluetoothAvailability() -> BluetoothState { switch bluetoothState { case .poweredOn: return .on diff --git a/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift similarity index 98% rename from local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift rename to local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift index 9257d4c16..6f445ea10 100644 --- a/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift @@ -10,7 +10,7 @@ public class SwiftClassicBluetoothPlugin: NSObject, FlutterPlugin { private var methodChannel: FlutterMethodChannel? private var eventChannel: FlutterEventChannel? private var eventSink: FlutterEventSink? - private let bluetoothHelper = ClassicBluetoothHelper() + private let bluetoothHelper = ClassicBluetoothHelper.shared public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "com.yunqiinnovation.classic_bluetooth/methods", binaryMessenger: registrar.messenger()) diff --git a/local_plugins/classic_bluetooth/pubspec.yaml b/local_plugins/classic_bluetooth/pubspec.yaml index 6e4012e2f..1c7381c84 100644 --- a/local_plugins/classic_bluetooth/pubspec.yaml +++ b/local_plugins/classic_bluetooth/pubspec.yaml @@ -17,4 +17,4 @@ flutter: package: com.yunqiinnovation.classic_bluetooth pluginClass: ClassicBluetoothPlugin ios: - pluginClass: ClassicBluetoothPlugin \ No newline at end of file + pluginClass: SwiftClassicBluetoothPlugin \ No newline at end of file