diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 80b4d4cf8..8189f7520 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 0A5EDBFC2D89C4FD00215E26 /* ClassicBluetoothHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */; }; 0A5EDC042D8B544100215E26 /* BluetoothMediaButtonHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5EDC032D8B544100215E26 /* BluetoothMediaButtonHelper.swift */; }; + 0A7A32E42D8DBA6D0047F746 /* VoiceInteractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */; }; + 0A7A32E52D8DBA6D0047F746 /* VolcanoAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7A32E32D8DBA6D0047F746 /* VolcanoAIService.swift */; }; 0ADE29692D8C25660032BFCB /* silence.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 0ADE29682D8C25660032BFCB /* silence.mp3 */; }; 0ADE296A2D8C25660032BFCB /* sample.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 0ADE29672D8C25660032BFCB /* sample.mp3 */; }; 0ADE296D2D8C66D60032BFCB /* AzureTtsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADE296C2D8C66D60032BFCB /* AzureTtsHelper.swift */; }; @@ -53,6 +55,8 @@ 0906EF7A73CBA5EAFC64E05D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicBluetoothHelper.swift; sourceTree = ""; }; 0A5EDC032D8B544100215E26 /* BluetoothMediaButtonHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMediaButtonHelper.swift; sourceTree = ""; }; + 0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceInteractionService.swift; sourceTree = ""; }; + 0A7A32E32D8DBA6D0047F746 /* VolcanoAIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolcanoAIService.swift; sourceTree = ""; }; 0ADE29672D8C25660032BFCB /* sample.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = sample.mp3; sourceTree = ""; }; 0ADE29682D8C25660032BFCB /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = ""; }; 0ADE296B2D8C66D60032BFCB /* AzureAsrHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzureAsrHelper.swift; sourceTree = ""; }; @@ -157,6 +161,8 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */, + 0A7A32E32D8DBA6D0047F746 /* VolcanoAIService.swift */, 0ADE296F2D8C6C450032BFCB /* AudioSessionManager.swift */, 0ADE296B2D8C66D60032BFCB /* AzureAsrHelper.swift */, 0ADE296C2D8C66D60032BFCB /* AzureTtsHelper.swift */, @@ -422,6 +428,8 @@ 0A5EDBFC2D89C4FD00215E26 /* ClassicBluetoothHelper.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 0A5EDC042D8B544100215E26 /* BluetoothMediaButtonHelper.swift in Sources */, + 0A7A32E42D8DBA6D0047F746 /* VoiceInteractionService.swift in Sources */, + 0A7A32E52D8DBA6D0047F746 /* VolcanoAIService.swift in Sources */, 0ADE29702D8C6C450032BFCB /* AudioSessionManager.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); @@ -500,7 +508,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -629,7 +637,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -680,7 +688,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6e837111b..325f897d7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import AVFoundation @main @objc class AppDelegate: FlutterAppDelegate { @@ -8,24 +9,28 @@ import UIKit // 添加Azure语音服务辅助类 private var azureAsrHelper: Any? private var azureTtsHelper: Any? + // 添加火山AI服务 + private var volcanoAIService: VolcanoAIService? + // 语音交互服务 + private var voiceInteractionService: VoiceInteractionService? + + private var audioSessionManager: AudioSessionManager? override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // 初始化蓝牙辅助类 - classicBluetoothHelper = ClassicBluetoothHelper() - - // 立即初始化蓝牙,不要等待Flutter调用 - classicBluetoothHelper?.initBluetooth() - // 仅在iOS 13.0及以上版本初始化媒体按钮辅助类和Azure语音服务辅助类 - if #available(iOS 13.0, *) { - mediaButtonHelper = BluetoothMediaButtonHelper() - // 稍后在方法通道注册时初始化Azure语音服务辅助类 + // 获取flutter控制器 + guard let controller = window?.rootViewController as? FlutterViewController else { + NSLog("AppDelegate: Flutter控制器不可用") + return false } - // 建立Flutter方法通道 + // 初始化音频会话管理器 + audioSessionManager = AudioSessionManager.shared + + // 统一注册所有方法通道 registerMethodChannel() GeneratedPluginRegistrant.register(with: self) @@ -39,25 +44,60 @@ import UIKit return } - // 注册经典蓝牙通道 - let classicChannel = FlutterMethodChannel( + // 注册经典蓝牙方法通道 + registerClassicBluetoothChannels(controller: controller) + + // 仅在iOS 13.0及以上版本注册媒体按钮通道和Azure语音服务通道 + if #available(iOS 13.0, *) { + // 注册媒体按钮方法通道 + registerMediaButtonChannels(controller: controller) + + // 注册Azure ASR方法通道 + registerAzureAsrChannels(controller: controller) + + // 注册Azure TTS方法通道 + registerAzureTtsChannels(controller: controller) + + // 注册火山AI服务方法通道 + registerVolcanoAIServiceChannels(controller: controller) + + // 注册语音交互服务方法通道 + registerVoiceInteractionChannels(controller: controller) + } + } + + // MARK: - 经典蓝牙通道 + + private func registerClassicBluetoothChannels(controller: FlutterViewController) { + // 初始化蓝牙辅助类 + if classicBluetoothHelper == nil { + classicBluetoothHelper = ClassicBluetoothHelper() + } + + guard let classicBluetoothHelper = self.classicBluetoothHelper else { + NSLog("AppDelegate: 经典蓝牙辅助实例不可用") + return + } + + // 注册经典蓝牙方法通道 + let classicBluetoothChannel = FlutterMethodChannel( name: "com.deep_voice.classic_bluetooth", binaryMessenger: controller.binaryMessenger ) - classicChannel.setMethodCallHandler { [weak self] (call, result) in - guard let self = self else { - result(FlutterError(code: "UNAVAILABLE", message: "AppDelegate实例不可用", details: nil)) + classicBluetoothChannel.setMethodCallHandler { [weak self, weak classicBluetoothHelper] (call, result) in + guard let _ = self, let classicBluetoothHelper = classicBluetoothHelper else { + result(FlutterError(code: "UNAVAILABLE", message: "经典蓝牙辅助实例不可用", details: nil)) return } switch call.method { case "isBluetoothEnabled": - result(self.classicBluetoothHelper?.isBluetoothEnabled() ?? false) + result(classicBluetoothHelper.isBluetoothEnabled()) case "getConnectedHeadsetDevices": // 获取已连接的耳机设备 - self.classicBluetoothHelper?.getConnectedHeadsetDevices { devices, error in + classicBluetoothHelper.getConnectedHeadsetDevices { devices, error in if let error = error { result(FlutterError(code: "GET_DEVICES_FAILED", message: error, details: nil)) } else { @@ -67,7 +107,7 @@ import UIKit case "startBluetoothDeviceMonitoring": // 启动蓝牙设备监控 - self.classicBluetoothHelper?.initBluetooth() + classicBluetoothHelper.startBluetoothDeviceMonitoring() result(true) default: @@ -75,44 +115,40 @@ import UIKit } } - // 注册蓝牙事件通道 - let classicEventChannel = FlutterEventChannel( + // 注册蓝牙流通道 + let classicBluetoothStreamChannel = FlutterEventChannel( name: "com.deep_voice.classic_bluetooth_events", binaryMessenger: controller.binaryMessenger ) - // 创建事件处理器 - let bluetoothEventHandler = BluetoothStreamHandler(bluetoothHelper: self.classicBluetoothHelper!) - classicEventChannel.setStreamHandler(bluetoothEventHandler) - - // 仅在iOS 13.0及以上版本注册媒体按钮通道和Azure语音服务通道 - if #available(iOS 13.0, *) { - // 注册媒体按钮方法通道 - registerMediaButtonChannels(controller: controller) - - // 注册Azure ASR方法通道 - registerAzureAsrChannels(controller: controller) - - // 注册Azure TTS方法通道 - registerAzureTtsChannels(controller: controller) - } + // 创建蓝牙流处理器(作为局部变量) + let bluetoothStreamHandler = BluetoothStreamHandler(bluetoothHelper: classicBluetoothHelper) + classicBluetoothStreamChannel.setStreamHandler(bluetoothStreamHandler) } - // 注册媒体按钮通道 + // MARK: - 消息通道设置 + + // MARK: - 蓝牙媒体按键通道 + @available(iOS 13.0, *) private func registerMediaButtonChannels(controller: FlutterViewController) { - guard let mediaButtonHelper = mediaButtonHelper as? BluetoothMediaButtonHelper else { - return + // 初始化媒体按钮辅助类 + if mediaButtonHelper == nil { + mediaButtonHelper = BluetoothMediaButtonHelper() } - + // guard let mediaButtonHelper = self.mediaButtonHelper as? BluetoothMediaButtonHelper else { + // NSLog("AppDelegate: 媒体按钮辅助实例不可用") + // return + // } + // mediaButtonHelper.startButtonListening() // 注册媒体按钮方法通道 let mediaButtonChannel = FlutterMethodChannel( name: "com.deep_voice.bluetooth_media_button", binaryMessenger: controller.binaryMessenger ) - mediaButtonChannel.setMethodCallHandler { [weak self, weak mediaButtonHelper] (call, result) in - guard let _ = self, let mediaButtonHelper = mediaButtonHelper else { + mediaButtonChannel.setMethodCallHandler { [weak self] (call, result) in + guard let _ = self, let mediaButtonHelper = self?.mediaButtonHelper as? BluetoothMediaButtonHelper else { result(FlutterError(code: "UNAVAILABLE", message: "媒体按钮辅助实例不可用", details: nil)) return } @@ -131,17 +167,18 @@ import UIKit } } - // 注册媒体按钮事件通道 - let mediaButtonEventChannel = FlutterEventChannel( + // 注册媒体按钮流通道 + let mediaButtonStreamChannel = FlutterEventChannel( name: "com.deep_voice.bluetooth_media_button_events", binaryMessenger: controller.binaryMessenger ) - // 创建媒体按钮事件处理器 - let mediaButtonEventHandler = MediaButtonStreamHandler(mediaButtonHelper: mediaButtonHelper) - mediaButtonEventChannel.setStreamHandler(mediaButtonEventHandler) + // 创建媒体按钮流处理器 + let mediaButtonStreamHandler = MediaButtonStreamHandler(mediaButtonHelper: mediaButtonHelper as! BluetoothMediaButtonHelper) + mediaButtonStreamChannel.setStreamHandler(mediaButtonStreamHandler) } + // 注册Azure ASR通道 @available(iOS 13.0, *) private func registerAzureAsrChannels(controller: FlutterViewController) { @@ -203,15 +240,15 @@ import UIKit } } - // 注册Azure ASR事件通道 - let azureAsrEventChannel = FlutterEventChannel( + // 注册Azure ASR流通道 + let azureAsrStreamChannel = FlutterEventChannel( name: "com.deep_voice.azure_asr_events", binaryMessenger: controller.binaryMessenger ) - // 创建ASR事件处理器 + // 创建ASR流处理器 let asrStreamHandler = AsrStreamHandler() - azureAsrEventChannel.setStreamHandler(asrStreamHandler) + azureAsrStreamChannel.setStreamHandler(asrStreamHandler) } // 注册Azure TTS通道 @@ -300,15 +337,156 @@ import UIKit } } - // 注册Azure TTS事件通道 - let azureTtsEventChannel = FlutterEventChannel( + // 注册Azure TTS流通道 + let azureTtsStreamChannel = FlutterEventChannel( name: "com.deep_voice.azure_tts_events", binaryMessenger: controller.binaryMessenger ) - // 创建TTS事件处理器 + // 创建TTS流处理器 let ttsStreamHandler = TtsStreamHandler() - azureTtsEventChannel.setStreamHandler(ttsStreamHandler) + azureTtsStreamChannel.setStreamHandler(ttsStreamHandler) + } + + // 注册火山AI服务方法通道 + @available(iOS 13.0, *) + private func registerVolcanoAIServiceChannels(controller: FlutterViewController) { + guard let volcanoAIService = volcanoAIService else { + return + } + + // 注册火山AI方法通道 + let volcanoAIChannel = FlutterMethodChannel( + name: "com.deep_voice.volcano_ai", + binaryMessenger: controller.binaryMessenger + ) + + volcanoAIChannel.setMethodCallHandler { [weak self, weak volcanoAIService] (call, result) in + guard let _ = self, let volcanoAIService = volcanoAIService else { + result(FlutterError(code: "UNAVAILABLE", message: "火山AI服务实例不可用", details: nil)) + return + } + + switch call.method { + case "initialize": + let apiKey = call.arguments as? String ?? "" + let success = volcanoAIService.initialize(apiKey: apiKey) + result(success) + + case "sendMessage": + guard let arguments = call.arguments as? [String: Any], + let message = arguments["message"] as? String, + let systemPrompt = arguments["systemPrompt"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) + return + } + + if message.isEmpty { + result(FlutterError(code: "EMPTY_MESSAGE", message: "消息不能为空", details: nil)) + return + } + + // 异步发送消息 + Task { + do { + // 创建用户消息 + let userMessage = volcanoAIService.createUserMessage(content: message) + + // 发送消息并等待响应 + let response = try await volcanoAIService.sendMessage( + messages: [userMessage], + systemPrompt: systemPrompt + ) + + // 返回响应 + DispatchQueue.main.async { + result(response) + } + } catch { + // 处理错误 + DispatchQueue.main.async { + result(FlutterError( + code: "AI_ERROR", + message: "AI请求失败: \(error.localizedDescription)", + details: nil + )) + } + } + } + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册火山AI流通道 + let volcanoAIStreamChannel = FlutterEventChannel( + name: "com.deep_voice.volcano_ai_stream", + binaryMessenger: controller.binaryMessenger + ) + + // 创建火山AI流处理器(作为局部变量) + let volcanoAIStreamHandler = VolcanoAIStreamHandler(volcanoAIService: volcanoAIService) + volcanoAIStreamChannel.setStreamHandler(volcanoAIStreamHandler) + } + + // 注册语音交互服务方法通道 + @available(iOS 13.0, *) + private func registerVoiceInteractionChannels(controller: FlutterViewController) { + // 初始化语音交互服务 + voiceInteractionService = VoiceInteractionService.shared + + // 注册语音交互服务方法通道 + let voiceInteractionChannel = FlutterMethodChannel( + name: "com.deep_voice.voice_interaction", + binaryMessenger: controller.binaryMessenger + ) + + voiceInteractionChannel.setMethodCallHandler { [weak self, weak voiceInteractionService] (call, result) in + guard let _ = self, let voiceInteractionService = voiceInteractionService else { + result(FlutterError(code: "UNAVAILABLE", message: "语音交互服务实例不可用", details: nil)) + return + } + + switch call.method { + case "startService": + guard let args = call.arguments as? [String: Any], + let azureKey = args["azureSpeechKey"] as? String, + let azureRegion = args["azureSpeechRegion"] as? String, + let volcanoKey = args["volcanoAiKey"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "启动服务参数无效", details: nil)) + return + } + + let success = voiceInteractionService.start(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) + result(success) + + case "stopService": + voiceInteractionService.stop() + result(true) + + case "isServiceRunning": + let isRunning = voiceInteractionService.isServiceRunning() + result(isRunning) + + case "pauseVoiceInteraction": + voiceInteractionService.pauseVoiceInteraction() + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册语音交互流通道 + let voiceInteractionStreamChannel = FlutterEventChannel( + name: "com.deep_voice.voice_interaction_events", + binaryMessenger: controller.binaryMessenger + ) + + // 创建语音交互流处理器(作为局部变量) + let voiceInteractionStreamHandler = VoiceInteractionStreamHandler(voiceInteractionService: voiceInteractionService!) + voiceInteractionStreamChannel.setStreamHandler(voiceInteractionStreamHandler) } // 存储ASR事件 @@ -362,13 +540,13 @@ class BluetoothStreamHandler: NSObject, FlutterStreamHandler, BluetoothEventHand } func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - NSLog("BluetoothStreamHandler: 开始监听蓝牙事件") + // NSLog("BluetoothStreamHandler: 开始监听蓝牙事件") self.eventSink = events return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { - NSLog("BluetoothStreamHandler: 取消监听蓝牙事件") + // NSLog("BluetoothStreamHandler: 取消监听蓝牙事件") eventSink = nil return nil } @@ -437,8 +615,11 @@ class AsrStreamHandler: NSObject, FlutterStreamHandler { forName: Notification.Name("AzureAsrEvent"), object: nil, queue: .main) { [weak self] notification in - if let userInfo = notification.userInfo as? [String: Any] { - self?.eventSink?(userInfo) + if let userInfo = notification.userInfo as? [String: Any], let eventSink = self?.eventSink { + // 确保在主线程上发送事件 + DispatchQueue.main.async { + eventSink(userInfo) + } } } @@ -478,8 +659,11 @@ class TtsStreamHandler: NSObject, FlutterStreamHandler { forName: Notification.Name("AzureTtsEvent"), object: nil, queue: .main) { [weak self] notification in - if let userInfo = notification.userInfo as? [String: Any] { - self?.eventSink?(userInfo) + if let userInfo = notification.userInfo as? [String: Any], let eventSink = self?.eventSink { + // 确保在主线程上发送事件 + DispatchQueue.main.async { + eventSink(userInfo) + } } } @@ -498,3 +682,107 @@ class TtsStreamHandler: NSObject, FlutterStreamHandler { return nil } } + +// 火山AI事件流处理器 +@available(iOS 13.0, *) +class VolcanoAIStreamHandler: NSObject, FlutterStreamHandler { + private let volcanoAIService: VolcanoAIService + private var eventSink: FlutterEventSink? + + init(volcanoAIService: VolcanoAIService) { + self.volcanoAIService = volcanoAIService + super.init() + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + // 处理来自Flutter的流式请求 + func handleStreamRequest(messages: [[String: Any]], systemPrompt: String) { + volcanoAIService.sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: VolcanoAIService.StreamCallback( + onToken: { [weak self] token in + guard let self = self, let eventSink = self.eventSink else { return } + + let event: [String: Any] = [ + "type": "token", + "token": token + ] + + DispatchQueue.main.async { + eventSink(event) + } + }, + onComplete: { [weak self] in + guard let self = self, let eventSink = self.eventSink else { return } + + let event: [String: Any] = [ + "type": "complete" + ] + + DispatchQueue.main.async { + eventSink(event) + } + }, + onError: { [weak self] error in + guard let self = self, let eventSink = self.eventSink else { return } + + let event: [String: Any] = [ + "type": "error", + "error": error.localizedDescription + ] + + DispatchQueue.main.async { + eventSink(event) + } + } + )) + } +} + +// 语音交互事件处理器协议 +@available(iOS 13.0, *) +protocol VoiceInteractionEventHandler: AnyObject { + func sendEvent(_ event: [String: Any]) +} + +// 语音交互流处理器 +@available(iOS 13.0, *) +class VoiceInteractionStreamHandler: NSObject, FlutterStreamHandler, VoiceInteractionEventHandler { + private let voiceInteractionService: VoiceInteractionService + private var eventSink: FlutterEventSink? + + init(voiceInteractionService: VoiceInteractionService) { + self.voiceInteractionService = voiceInteractionService + super.init() + + // 设置事件处理器 + voiceInteractionService.setEventHandler(self) + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + NSLog("VoiceInteractionStreamHandler: 开始监听语音交互事件") + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + NSLog("VoiceInteractionStreamHandler: 取消监听语音交互事件") + eventSink = nil + return nil + } + + // 实现VoiceInteractionEventHandler协议 + func sendEvent(_ event: [String: Any]) { + NSLog("VoiceInteractionStreamHandler: 收到事件 \(event)") + DispatchQueue.main.async { [weak self] in + self?.eventSink?(event) + } + } +} diff --git a/ios/Runner/AudioSessionManager.swift b/ios/Runner/AudioSessionManager.swift index 1b1a4df76..75882210b 100644 --- a/ios/Runner/AudioSessionManager.swift +++ b/ios/Runner/AudioSessionManager.swift @@ -1,5 +1,6 @@ import Foundation import AVFoundation +import UIKit /// 统一音频会话管理类 /// 用于统一管理所有组件的音频会话配置 @@ -11,103 +12,211 @@ import AVFoundation // 音频会话 private let audioSession = AVAudioSession.sharedInstance() - // 是否已经配置过音频会话 + // 音频会话状态 private var isConfigured = false + private var isActive = false // 当前配置类型 - private var currentCategory: AVAudioSession.Category = .playAndRecord - private var currentMode: AVAudioSession.Mode = .default - private var currentOptions: AVAudioSession.CategoryOptions = [.allowBluetooth, .allowBluetoothA2DP] + private var currentCategory: AVAudioSession.Category? + private var currentMode: AVAudioSession.Mode? + private var currentOptions: AVAudioSession.CategoryOptions? + + // 中断监听器 + private var interruptionObserver: NSObjectProtocol? + + // 路由变化监听器 + private var routeChangeObserver: NSObjectProtocol? // 私有初始化方法,确保只能通过shared访问 private override init() { super.init() NSLog("[AudioSessionManager] 音频会话管理器已初始化") + setupNotifications() + + do { + // try configureSession( + // category: .playAndRecord, + // mode: .default, + // options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers, .defaultToSpeaker], + // force: true + // ) + NSLog("[AudioSessionManager] 初始化音频会话配置成功") + } catch { + NSLog("[AudioSessionManager] 初始化音频会话配置失败: \(error.localizedDescription)") + } + // BluetoothMediaButtonHelper.shared.startButtonListening() } - // MARK: - 音频会话配置 + deinit { + removeNotifications() + } - /// 在应用启动时调用一次性配置方法 - /// - Parameters: - /// - category: 音频会话类别 - /// - mode: 音频会话模式 - /// - options: 音频会话选项 - /// - Returns: 是否配置成功 - @objc func initialConfiguration(category: AVAudioSession.Category = .playAndRecord, - mode: AVAudioSession.Mode = .default, - options: AVAudioSession.CategoryOptions = [.allowBluetooth, .allowBluetoothA2DP]) -> Bool { - if isConfigured { - NSLog("[AudioSessionManager] 音频会话已经配置过,忽略初始化请求") - return true + + // MARK: - 通知设置 + + private func setupNotifications() { + // 监听中断事件 + interruptionObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main) { [weak self] notification in + self?.handleInterruption(notification) + } + + // 监听路由变化事件 + routeChangeObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main) { [weak self] notification in + self?.handleRouteChange(notification) + } + + // 监听应用进入后台/前台 + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + private func removeNotifications() { + if let interruptionObserver = interruptionObserver { + NotificationCenter.default.removeObserver(interruptionObserver) } - do { - NSLog("[AudioSessionManager] 执行一次性音频会话配置: \(category)") - try audioSession.setCategory(category, mode: mode, options: options) - try audioSession.setActive(true) - - // 记录当前配置 - currentCategory = category - currentMode = mode - currentOptions = options - isConfigured = true - - return true - } catch { - NSLog("[AudioSessionManager] 一次性配置音频会话失败: \(error.localizedDescription)") - return false + if let routeChangeObserver = routeChangeObserver { + NotificationCenter.default.removeObserver(routeChangeObserver) } + + NotificationCenter.default.removeObserver(self) } - /// 配置音频会话,适用于语音识别和合成 - @objc func configureForVoiceProcessing() throws { - if !applyConfigurationIfNeeded(category: .playAndRecord, - mode: .spokenAudio, - options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers]) { - NSLog("[AudioSessionManager] 使用已存在的音频会话配置,跳过语音处理模式配置") + // MARK: - 通知处理 + + private func handleInterruption(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { + return + } + + switch type { + case .began: + // 中断开始 + NSLog("[AudioSessionManager] 音频会话被中断") + isActive = false + + case .ended: + // 中断结束,尝试恢复 + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + NSLog("[AudioSessionManager] 尝试恢复被中断的音频会话") + try? activateSession() + } + } + + @unknown default: + break } } - /// 配置音频会话,适用于蓝牙耳机连接和监听 - @objc func configureForBluetoothHeadset() throws { - if !applyConfigurationIfNeeded(category: .playAndRecord, - mode: .default, - options: [.allowBluetooth, .allowBluetoothA2DP]) { - NSLog("[AudioSessionManager] 使用已存在的音频会话配置,跳过蓝牙耳机模式配置") + private func handleRouteChange(_ 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: + // 新设备连接 + let currentRoute = audioSession.currentRoute + for output in currentRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { + NSLog("[AudioSessionManager] 检测到蓝牙音频设备连接: \(output.portName)") + // 蓝牙设备连接,可能需要更新配置 + if isActive && currentOptions?.contains(.allowBluetooth) == true { + try? forceReconfigure() + } + break + } + + case .oldDeviceUnavailable: + // 设备断开连接 + if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { + for output in previousRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { + NSLog("[AudioSessionManager] 蓝牙音频设备断开连接: \(output.portName)") + // 蓝牙设备断开,重新配置到内置扬声器 + if isActive { + try? forceReconfigure() + } + break + } + } + + default: + break } } - /// 配置音频会话,适用于媒体播放和蓝牙媒体按钮 - @objc func configureForMediaPlayback() throws { - if !applyConfigurationIfNeeded(category: .playback, - mode: .default, - options: [.allowBluetooth, .allowBluetoothA2DP]) { - NSLog("[AudioSessionManager] 使用已存在的音频会话配置,跳过媒体播放模式配置") - } + @objc private func handleAppDidEnterBackground() { + NSLog("[AudioSessionManager] 应用进入后台") + // 应用进入后台时可能需要调整音频会话 } - /// 配置自定义音频会话 - @objc func configure(category: AVAudioSession.Category, - mode: AVAudioSession.Mode, - options: AVAudioSession.CategoryOptions) throws { - if !applyConfigurationIfNeeded(category: category, mode: mode, options: options) { - NSLog("[AudioSessionManager] 使用已存在的音频会话配置,跳过自定义模式配置") + @objc private func handleAppWillEnterForeground() { + NSLog("[AudioSessionManager] 应用即将进入前台") + // 应用返回前台,检查并恢复音频会话 + if isConfigured && !isActive { + try? activateSession() } } - /// 内部方法:如果尚未配置,应用新的配置 - private func applyConfigurationIfNeeded(category: AVAudioSession.Category, - mode: AVAudioSession.Mode, - options: AVAudioSession.CategoryOptions) -> Bool { - // 如果已经配置过,不再重复配置 - if isConfigured { - return false + // MARK: - 音频会话配置 + + /// 统一配置方法 - 所有音频配置都通过此方法进行 + @objc func configureSession(category: AVAudioSession.Category, + mode: AVAudioSession.Mode, + options: AVAudioSession.CategoryOptions, + force: Bool = false) throws { + + NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode), 选项=\(options), 强制=\(force)") + + // 如果已配置且不强制重新配置,则直接返回 + if isConfigured && !force && + currentCategory == category && + currentMode == mode && + currentOptions == options { + NSLog("[AudioSessionManager] 音频会话已配置,跳过配置") + return } do { - NSLog("[AudioSessionManager] 应用音频会话配置: \(category)") + // NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode)") + + // 暂时停用当前会话(如果正在活动) + // if isActive { + // try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + // } + + // 设置新的分类、模式和选项 try audioSession.setCategory(category, mode: mode, options: options) - try audioSession.setActive(true) + + // 设置采样率和缓冲区大小(对于语音识别优化) + // if mode == .spokenAudio { + // try audioSession.setPreferredSampleRate(16000) + // try audioSession.setPreferredIOBufferDuration(0.01) // 10ms + // } + + // 重新激活会话 + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + isActive = true // 记录当前配置 currentCategory = category @@ -115,10 +224,109 @@ import AVFoundation currentOptions = options isConfigured = true - return true + // 检查并设置首选的输入设备(如果需要) + // if category == .playAndRecord || category == .record { + // try configurePreferredInput() + // } + return } catch { NSLog("[AudioSessionManager] 配置音频会话失败: \(error.localizedDescription)") - return false + throw error + } + } + + /// 配置首选输入设备 + private func configurePreferredInput() throws { + // 获取可用输入设备 + guard let availableInputs = audioSession.availableInputs else { + NSLog("[AudioSessionManager] 无法获取可用输入设备") + return + } + + // 首选顺序:有线耳机 > 蓝牙耳机 > 内置麦克风 + var preferredInput: AVAudioSessionPortDescription? = nil + + // 先尝试找到有线耳机 + if let wiredHeadset = availableInputs.first(where: { $0.portType == .headsetMic }) { + preferredInput = wiredHeadset + NSLog("[AudioSessionManager] 设置首选输入设备: 有线耳机") + } + // 如果没有有线耳机,尝试使用蓝牙 + else if let bluetooth = availableInputs.first(where: { $0.portType == .bluetoothHFP }) { + preferredInput = bluetooth + NSLog("[AudioSessionManager] 设置首选输入设备: 蓝牙耳机") + } + // 如果没有外部设备,使用内置麦克风 + else if let builtIn = availableInputs.first(where: { $0.portType == .builtInMic }) { + preferredInput = builtIn + NSLog("[AudioSessionManager] 设置首选输入设备: 内置麦克风") + } + + // 设置首选输入设备 + if let input = preferredInput { + try audioSession.setPreferredInput(input) + } + } + + + + /// 激活音频会话(如果已配置) + @objc func activateSession() throws { + if !isConfigured { + NSLog("[AudioSessionManager] 警告: 尝试激活未配置的音频会话,应用默认配置") + // 未配置,应用默认配置 + try configureSession( + category: .playAndRecord, + mode: .spokenAudio, + options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers, .defaultToSpeaker], + force: true + ) + } else if !isActive { + NSLog("[AudioSessionManager] 激活已配置的音频会话") + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + isActive = true + } + } + + /// 停用音频会话(保留配置) + @objc func deactivateSession() throws { + if isActive { + NSLog("[AudioSessionManager] 停用音频会话") + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + isActive = false + } + } + + /// 重新配置当前会话 + @objc func forceReconfigure() throws { + NSLog("[AudioSessionManager] 强制重新配置音频会话") + try configureSession( + category: currentCategory ?? .playAndRecord, + mode: currentMode ?? .default, + options: currentOptions ?? [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker], + force: true + ) + } + + // MARK: - 状态检查 + + /// 检查麦克风权限 + @objc func checkMicrophonePermission(completion: @escaping (Bool) -> Void) { + switch AVAudioSession.sharedInstance().recordPermission { + case .granted: + completion(true) + case .denied: + NSLog("[AudioSessionManager] 麦克风权限被拒绝") + completion(false) + case .undetermined: + NSLog("[AudioSessionManager] 请求麦克风权限") + AVAudioSession.sharedInstance().requestRecordPermission { granted in + DispatchQueue.main.async { + completion(granted) + } + } + @unknown default: + completion(false) } } @@ -126,51 +334,68 @@ import AVFoundation @objc func getCurrentConfiguration() -> [String: Any] { return [ "isConfigured": isConfigured, - "category": "\(currentCategory)", - "mode": "\(currentMode)" + "isActive": isActive, + "category": String(describing: currentCategory), + "mode": String(describing: currentMode), + "bluetoothEnabled": currentOptions?.contains(.allowBluetooth) == true, + "currentInputs": audioSession.currentRoute.inputs.map { [$0.portType.rawValue: $0.portName] }, + "currentOutputs": audioSession.currentRoute.outputs.map { [$0.portType.rawValue: $0.portName] } ] } - /// 仅在特殊情况下使用:强制重新配置音频会话 - @objc func forceReconfigure() -> Bool { - isConfigured = false + /// 检查当前是否有蓝牙耳机连接 + @objc func hasBluetoothHeadsetConnected() -> Bool { + let outputs = audioSession.currentRoute.outputs + return outputs.contains { $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE } + } + + /// 检查蓝牙是否启用 + @objc func isBluetoothEnabled() -> Bool { + // 在iOS中无法直接检查蓝牙是否启用,我们可以通过检查音频会话配置来推断 - do { - NSLog("[AudioSessionManager] 强制重新配置音频会话") - try audioSession.setCategory(currentCategory, mode: currentMode, options: currentOptions) - try audioSession.setActive(true) - isConfigured = true - return true - } catch { - NSLog("[AudioSessionManager] 强制重新配置失败: \(error.localizedDescription)") - return false + // 检查音频会话是否配置了允许蓝牙 + if let options = currentOptions { + return options.contains(.allowBluetooth) || options.contains(.allowBluetoothA2DP) } + + // 如果尚未配置音频会话,尝试检查当前路由中是否有蓝牙设备 + let outputs = audioSession.currentRoute.outputs + let hasBluetooth = outputs.contains { + $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE + } + + return hasBluetooth } - /// 激活音频会话(如果已配置) - @objc func activateSession() throws { - if isConfigured { - try audioSession.setActive(true) - } else { - NSLog("[AudioSessionManager] 警告: 尝试激活未配置的音频会话") - // 应用默认配置并激活 - try applyConfigurationIfNeeded(category: .playAndRecord, - mode: .default, - options: [.allowBluetooth, .allowBluetoothA2DP]) + /// 获取当前音频路由 + @objc func getCurrentAudioRoute() -> String { + let outputs = audioSession.currentRoute.outputs + if let output = outputs.first { + return "\(output.portType.rawValue) (\(output.portName))" } + return "unknown" } - /// 停用音频会话(保留配置) - @objc func deactivateSession() throws { - try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + /// 获取当前输入设备 + @objc func getCurrentInputDevice() -> String { + let inputs = audioSession.currentRoute.inputs + if let input = inputs.first { + return "\(input.portType.rawValue) (\(input.portName))" + } + return "unknown" } - /// 获取当前音频会话路由描述 + // MARK: - 路由管理和监听 + + /// 获取当前路由信息 @objc func getCurrentRoute() -> AVAudioSessionRouteDescription { return audioSession.currentRoute } - /// 添加路由变化监听器 + /// 添加路由变更监听器 + /// - Parameters: + /// - observer: 监听对象 + /// - selector: 监听方法选择器 @objc func addRouteChangeListener(_ observer: Any, selector: Selector) { NotificationCenter.default.addObserver( observer, @@ -180,7 +405,8 @@ import AVFoundation ) } - /// 移除路由变化监听器 + /// 移除路由变更监听器 + /// - Parameter observer: 要移除的监听对象 @objc func removeRouteChangeListener(_ observer: Any) { NotificationCenter.default.removeObserver( observer, @@ -188,4 +414,216 @@ import AVFoundation object: nil ) } + /// 获取设备类型描述 + private func getDeviceType(portType: AVAudioSession.Port) -> String { + switch portType { + case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: + return "bluetooth" + case .headphones, .headsetMic: + return "wired" + case .airPlay: + return "airplay" + default: + return "unknown" + } + } + + + + // MARK: - 蓝牙媒体按钮配置 + + /// 配置用于获取蓝牙媒体按钮响应的音频会话 + /// 此函数专门针对需要接收蓝牙耳机上媒体按钮事件的场景进行优化 + /// - Returns: 配置是否成功 + @objc func configureForBluetoothMediaButtons() -> Bool { + do { + // 配置音频会话以支持蓝牙媒体按钮 + // 使用playback类别确保可以接收远程控制事件 + // 使用spokenAudio模式优化语音交互 + try configureSession( + category: .playback, + mode: .default, // 使用default模式,避免与spokenAudio可能的冲突 + options: [ + .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 + .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 + .mixWithOthers // 允许与其他音频混合,避免中断 + ], + force: true // 强制重新配置,确保设置生效 + ) + + NSLog("[AudioSessionManager] 已配置蓝牙媒体按钮响应的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置蓝牙媒体按钮响应的音频会话失败: \(error.localizedDescription)") + return false + } + } + + + // MARK: - Azure语音服务配置 + + + /// 配置用于语音合成(TTS)的音频会话 + /// 此函数针对TTS使用场景优化音频播放 + /// - Returns: 配置是否成功 + @objc func configureForTts() -> Bool { + do { + // 配置音频会话以支持语音合成 + // 使用playback类别用于音频播放 + try configureSession( + category: .playback, + mode: .spokenAudio, // 针对语音输出优化 + options: [ + .allowBluetooth, // 允许通过蓝牙连接进行输出 + .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 + .mixWithOthers // 允许与其他音频混合,避免中断 + ], + force: false + ) + + NSLog("[AudioSessionManager] 已配置TTS的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置TTS的音频会话失败: \(error.localizedDescription)") + return false + } + } + + /// 配置用于语音识别(ASR)的音频会话 + /// 此函数针对ASR使用场景优化音频输入 + /// - Returns: 配置是否成功 + @objc func configureForAsr() -> Bool { + do { + // 配置音频会话以支持语音识别 + // 使用record类别专注于录音 + try configureSession( + category: .record, + mode: .measurement, // 使用measurement模式获取最高质量的音频输入 + options: [ + .allowBluetooth // 允许通过蓝牙SCO连接进行输入 + ], + force: false + ) + + // 设置首选输入设备 + try configurePreferredInput() + + NSLog("[AudioSessionManager] 已配置ASR的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置ASR的音频会话失败: \(error.localizedDescription)") + return false + } + } + + /// 配置用于语音合成+音频播放的音频会话(同时支持TTS和音乐播放) + /// 此函数针对同时需要TTS和背景音乐的场景进行优化 + /// - Returns: 配置是否成功 + @objc func configureForTtsWithMusic() -> Bool { + do { + // 配置音频会话以支持TTS和音乐播放 + try configureSession( + category: .playback, + mode: .default, // 使用默认模式以适应多种音频类型 + options: [ + .allowBluetooth, // 允许通过蓝牙连接进行输出 + .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 + .mixWithOthers, // 允许与其他应用的音频混合 + .allowAirPlay // 支持AirPlay设备 + ], + force: false + ) + + NSLog("[AudioSessionManager] 已配置TTS+音乐播放的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置TTS+音乐播放的音频会话失败: \(error.localizedDescription)") + return false + } + } + + /// 配置用于全语音助手模式的音频会话(优先级最高) + /// 此函数针对需要完全控制音频体验的语音助手场景 + /// - Returns: 配置是否成功 + @objc func configureForVoiceAssistant() -> Bool { + do { + // 配置音频会话以支持完整语音助手功能 + try configureSession( + category: .playAndRecord, + mode: .spokenAudio, + options: [ + .allowBluetooth, // 允许通过蓝牙连接 + .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接 + .defaultToSpeaker, // 默认使用扬声器输出 + .duckOthers, // 降低其他应用的音量 + .interruptSpokenAudioAndMixWithOthers // 中断其他语音音频并混合 + ], + force: true // 强制重新配置,确保设置生效 + ) + + // 设置首选输入设备 + try configurePreferredInput() + + NSLog("[AudioSessionManager] 已配置语音助手模式的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置语音助手模式的音频会话失败: \(error.localizedDescription)") + return false + } + } + + /// 配置用于多任务模式的音频会话(与其他应用友好共存) + /// 此函数针对需要在后台继续工作但不中断其他应用音频的场景 + /// - Returns: 配置是否成功 + @objc func configureForMultitasking() -> Bool { + do { + // 配置音频会话以支持多任务 + try configureSession( + category: .ambient, // 使用ambient类别不会中断其他音频 + mode: .default, + options: [ + .mixWithOthers, // 允许与其他应用的音频混合 + .allowBluetooth, // 允许通过蓝牙连接 + .allowBluetoothA2DP // 允许通过蓝牙A2DP连接 + ], + force: false + ) + + NSLog("[AudioSessionManager] 已配置多任务模式的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置多任务模式的音频会话失败: \(error.localizedDescription)") + return false + } + } + + /// 配置用于语音交互(同时支持ASR和TTS)的音频会话 + /// 此函数综合优化语音识别和语音合成,适用于需要双向交互的场景 + /// - Returns: 配置是否成功 + @objc func configureForVoiceInteraction() -> Bool { + do { + // 配置音频会话以支持语音交互 + // 使用playAndRecord类别允许同时录音和播放 + // 使用spokenAudio模式优化语音交互 + try configureSession( + category: .playAndRecord, + mode: .spokenAudio, + options: [ + // .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 + .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 + ], + force: false // 强制重新配置,确保设置生效 + ) + + // 设置首选输入设备 + // try configurePreferredInput() + + NSLog("[AudioSessionManager] 已配置语音交互(ASR+TTS)的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置语音交互(ASR+TTS)的音频会话失败: \(error.localizedDescription)") + return false + } + } + } \ No newline at end of file diff --git a/ios/Runner/AzureAsrHelper.swift b/ios/Runner/AzureAsrHelper.swift index 87619c037..c799f8f9f 100644 --- a/ios/Runner/AzureAsrHelper.swift +++ b/ios/Runner/AzureAsrHelper.swift @@ -141,10 +141,10 @@ class AzureAsrHelper: NSObject { /// 设置音频会话 private func setupAudioSession() throws { - // 使用音频会话管理器配置语音处理模式 - try audioSessionManager.configureForVoiceProcessing() - - print("[AzureAsrHelper] 音频会话配置成功,已启用回音消除") + print("[AzureAsrHelper] 开始配置音频会话...") + + audioSessionManager.configureForVoiceInteraction() + } /// 设置所有回调 @@ -279,54 +279,84 @@ class AzureAsrHelper: NSObject { return false } - // 如果已经在进行连续识别,先停止 + // 检查是否已经在识别 if _isContinuousRecognitionActive { - stopContinuousRecognition() + print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") + return true } // 确保识别器已创建 - if recognizer == nil && !createRecognizerAndSetupCallbacks() { - return false - } - - // 重新确保音频设置正确 - do { - try setupAudioSession() - } catch { - print("[AzureAsrHelper] 警告: 设置音频会话失败: \(error.localizedDescription)") + if recognizer == nil { + print("[AzureAsrHelper] 尝试重新创建识别器...") + if !createRecognizerAndSetupCallbacks() { + eventHandler("error", ["message": "无法创建语音识别器"]) + return false + } } + + // 尝试启动连续识别 do { + + // 启动连续识别 try recognizer?.startContinuousRecognition() _isContinuousRecognitionActive = true - - print("[AzureAsrHelper] 连续识别开始") + + + print("[AzureAsrHelper] 连续识别已启动") return true } catch { + _isContinuousRecognitionActive = false print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") + + // 发送错误通知 eventHandler("error", ["message": "开始连续识别失败: \(error.localizedDescription)"]) - _isContinuousRecognitionActive = false + return false } } + /// 停止连续语音识别 /// - Returns: 是否成功停止识别 func stopContinuousRecognition() -> Bool { - if !_isContinuousRecognitionActive || recognizer == nil { + if !_isContinuousRecognitionActive { + print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") return true } + print("[AzureAsrHelper] 停止连续语音识别") + do { - try recognizer?.stopContinuousRecognition() + // 防止空指针异常 + guard let recognizer = recognizer else { + print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") + _isContinuousRecognitionActive = false + eventHandler("sessionStopped", [:]) + return true + } + + // 尝试停止 + try recognizer.stopContinuousRecognition() + + // 标记为非活跃状态 _isContinuousRecognitionActive = false + print("[AzureAsrHelper] 连续识别已停止") return true } catch { print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") - eventHandler("error", ["message": "停止连续识别失败: \(error.localizedDescription)"]) + + // 即使失败,也将状态设置为非活跃 _isContinuousRecognitionActive = false + + // 发送停止事件,确保上层知道识别已经结束 + eventHandler("sessionStopped", [:]) + + // 尝试释放和重新创建识别器 + recognizer = nil + return false } } diff --git a/ios/Runner/AzureTtsHelper.swift b/ios/Runner/AzureTtsHelper.swift index 42c9f97db..a212f516f 100644 --- a/ios/Runner/AzureTtsHelper.swift +++ b/ios/Runner/AzureTtsHelper.swift @@ -123,7 +123,7 @@ class AzureTtsHelper: NSObject { private func configureAudioSession() -> Bool { do { // 使用音频会话管理器配置媒体播放模式 - try audioSessionManager.configureForMediaPlayback() + try audioSessionManager.configureForVoiceInteraction() isAudioSessionConfigured = true print("[AzureTtsHelper] 音频会话配置成功") diff --git a/ios/Runner/BluetoothMediaButtonHelper.swift b/ios/Runner/BluetoothMediaButtonHelper.swift index a7c103e5d..bcbc6b042 100644 --- a/ios/Runner/BluetoothMediaButtonHelper.swift +++ b/ios/Runner/BluetoothMediaButtonHelper.swift @@ -15,6 +15,8 @@ import UIKit class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { // MARK: - 属性 + @objc static let shared = BluetoothMediaButtonHelper() + /// 日志标签 private let TAG = "BluetoothMediaButtonHelper" @@ -89,14 +91,14 @@ class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { // 尝试启用静音音频播放(但即使失败,远程命令仍然可用) do { - // try passivelySetupAudioSession() - startSilentAudio() + // try audioSessionManager.configureForBluetoothMediaButtons() + // startSilentAudio() //延时1秒 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.setupNowPlaying(isPlaying: false) - self.stopSilentAudio() - } + // DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + // self.setupNowPlaying(isPlaying: false) + // self.stopSilentAudio() + // } // startUpdatingPlaybackInfo() } catch { @@ -274,6 +276,12 @@ class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { commandCenter.seekBackwardCommand.isEnabled = true commandCenter.seekBackwardCommand.addTarget(self, action: #selector(handleSeekBackwardCommand(_:))) + // 停止命令 + commandCenter.stopCommand.isEnabled = true + commandCenter.stopCommand.addTarget(self, action: #selector(handleStopCommand(_:))) + + + NSLog("\(TAG): 远程命令已注册") } @@ -281,7 +289,7 @@ class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { private func unregisterRemoteCommands() { NSLog("\(TAG): 取消注册远程命令") - // 取消所有命令 + // 基本控制命令 remoteCommandCenter.playCommand.isEnabled = false remoteCommandCenter.playCommand.removeTarget(nil) @@ -291,147 +299,105 @@ class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { remoteCommandCenter.togglePlayPauseCommand.isEnabled = false remoteCommandCenter.togglePlayPauseCommand.removeTarget(nil) - remoteCommandCenter.nextTrackCommand.isEnabled = false - remoteCommandCenter.nextTrackCommand.removeTarget(nil) - - remoteCommandCenter.previousTrackCommand.isEnabled = false - remoteCommandCenter.previousTrackCommand.removeTarget(nil) + remoteCommandCenter.stopCommand.isEnabled = false + remoteCommandCenter.stopCommand.removeTarget(nil) - remoteCommandCenter.seekForwardCommand.isEnabled = false - remoteCommandCenter.seekForwardCommand.removeTarget(nil) - - remoteCommandCenter.seekBackwardCommand.isEnabled = false - remoteCommandCenter.seekBackwardCommand.removeTarget(nil) + } - /// 被动设置音频会话 - 只读取现有配置,不做侵入性修改 - private func passivelySetupAudioSession() throws { - let audioSession = AVAudioSession.sharedInstance() - - // 如果会话已经活跃,我们完全不修改它 - if audioSession.isOtherAudioPlaying || audioSession.isInputAvailable { - NSLog("\(TAG): 检测到活跃的音频会话,不进行任何修改") - return - } - - // 检查应用是否已经拥有音频会话 - if audioSession.category != .ambient && audioSession.category != .soloAmbient { - // 应用已经有一个有意义的类别,不要修改它 - NSLog("\(TAG): 检测到应用已设置音频会话类别: \(audioSession.category)") - return - } - - NSLog("\(TAG): 当前没有活跃的音频,设置最小化会话配置") - - // 使用音频会话管理器配置媒体播放模式 - try audioSessionManager.configure(category: .ambient, mode: .default, options: []) + /// 发送按钮事件到Flutter + private func sendButtonEvent(type: String) { + // 创建事件数据 + let eventData: [String: Any] = [ + "type": "mediaButtonEvent", + "buttonType": type, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] - NSLog("\(TAG): 被动音频会话配置完成") + // 发送事件到处理器 + eventHandler?.sendEvent(eventData) } - /// 检查是否有其他应用正在播放音频 - private func isOtherAudioPlaying() -> Bool { - return AVAudioSession.sharedInstance().isOtherAudioPlaying - } // MARK: - 命令处理方法 /// 处理播放/暂停命令 - @objc func handlePlayPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + @objc private func handlePlayPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { NSLog("\(TAG): 收到播放/暂停命令") - // 创建事件数据 - let eventData: [String: Any] = [ - "type": "mediaButtonEvent", - "buttonType": "playPause", - "timestamp": Int(Date().timeIntervalSince1970 * 1000) - ] + // 发送事件到Flutter + sendButtonEvent(type: "play_pause") - // 发送事件到处理器 - eventHandler?.sendEvent(eventData) + // 调用VoiceInteractionService处理媒体按钮事件 + if VoiceInteractionService.shared.isServiceRunning() { + NSLog("\(TAG): 转发到VoiceInteractionService处理") + VoiceInteractionService.shared.handleMediaButtonAction() + } return .success } /// 处理下一曲命令 - @objc func handleNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + @objc private func handleNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { NSLog("\(TAG): 收到下一曲命令") - // 创建事件数据 - let eventData: [String: Any] = [ - "type": "mediaButtonEvent", - "buttonType": "next", - "timestamp": Int(Date().timeIntervalSince1970 * 1000) - ] + // 发送事件到Flutter + sendButtonEvent(type: "next_track") - // 发送事件到处理器 - eventHandler?.sendEvent(eventData) + // 也可以转发到VoiceInteractionService + if VoiceInteractionService.shared.isServiceRunning() { + VoiceInteractionService.shared.handleMediaButtonAction() + } return .success } /// 处理上一曲命令 - @objc func handlePreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + @objc private func handlePreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { NSLog("\(TAG): 收到上一曲命令") - // 创建事件数据 - let eventData: [String: Any] = [ - "type": "mediaButtonEvent", - "buttonType": "previous", - "timestamp": Int(Date().timeIntervalSince1970 * 1000) - ] + // 发送事件到Flutter + sendButtonEvent(type: "previous_track") - // 发送事件到处理器 - eventHandler?.sendEvent(eventData) + // 也可以转发到VoiceInteractionService + if VoiceInteractionService.shared.isServiceRunning() { + VoiceInteractionService.shared.handleMediaButtonAction() + } return .success } /// 处理快进命令 - @objc func handleSeekForwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + @objc private func handleSeekForwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { NSLog("\(TAG): 收到快进命令") - // 创建事件数据 - let eventData: [String: Any] = [ - "type": "mediaButtonEvent", - "buttonType": "seekForward", - "timestamp": Int(Date().timeIntervalSince1970 * 1000) - ] - - // 发送事件到处理器 - eventHandler?.sendEvent(eventData) - - // 模拟快进 - currentPlaybackTime += 10.0 - if currentPlaybackTime > totalDuration { - currentPlaybackTime = totalDuration - } - - setupNowPlaying(isPlaying: true) + // 发送事件到Flutter + sendButtonEvent(type: "seek_forward") return .success } /// 处理快退命令 - @objc func handleSeekBackwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + @objc private func handleSeekBackwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { NSLog("\(TAG): 收到快退命令") - // 创建事件数据 - let eventData: [String: Any] = [ - "type": "mediaButtonEvent", - "buttonType": "seekBackward", - "timestamp": Int(Date().timeIntervalSince1970 * 1000) - ] + // 发送事件到Flutter + sendButtonEvent(type: "seek_backward") - // 发送事件到处理器 - eventHandler?.sendEvent(eventData) + return .success + } + + /// 处理停止命令 + @objc private func handleStopCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + NSLog("\(TAG): 收到停止命令") - // 模拟快退 - currentPlaybackTime -= 10.0 - if currentPlaybackTime < 0 { - currentPlaybackTime = 0 + // 发送事件到Flutter + sendButtonEvent(type: "stop") + + // 调用VoiceInteractionService处理媒体按钮事件 + if VoiceInteractionService.shared.isServiceRunning() { + VoiceInteractionService.shared.handleMediaButtonAction() } - setupNowPlaying(isPlaying: true) return .success } diff --git a/ios/Runner/ClassicBluetoothHelper.swift b/ios/Runner/ClassicBluetoothHelper.swift index 1f5c3c42d..9fffd1e41 100644 --- a/ios/Runner/ClassicBluetoothHelper.swift +++ b/ios/Runner/ClassicBluetoothHelper.swift @@ -8,15 +8,13 @@ import UIKit func sendEvent(_ event: [String: Any]) } -@objc class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { +@objc class ClassicBluetoothHelper: NSObject { // MARK: - 属性 // 音频会话管理器 private let audioSessionManager = AudioSessionManager.shared - // 蓝牙管理器 - private var centralManager: CBCentralManager? - + // 事件处理器 private weak var eventHandler: BluetoothEventHandler? @@ -31,32 +29,6 @@ import UIKit eventHandler = handler } - // 初始化蓝牙(主入口方法) - @objc func initBluetooth() { - // 初始化蓝牙中央管理器 - if centralManager == nil { - centralManager = CBCentralManager(delegate: self, queue: nil) - } - - // 配置音频会话 - configureAudioSession() - - // 启动设备监控 - startBluetoothDeviceMonitoring() - - // 发送当前蓝牙状态 - if let manager = centralManager { - let stateString = getBluetoothStateString(state: manager.state) - sendBluetoothStateEvent(state: stateString) - } - } - - // MARK: - CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - let stateString = getBluetoothStateString(state: central.state) - sendBluetoothStateEvent(state: stateString) - } // MARK: - 设备监控 @@ -86,6 +58,7 @@ import UIKit @objc func startBluetoothDeviceMonitoring() { NotificationCenter.default.removeObserver(self) audioSessionManager.addRouteChangeListener(self, selector: #selector(handleRouteChange(_:))) + BluetoothMediaButtonHelper.shared.startButtonListening() } // 处理音频路由变化 @@ -135,41 +108,23 @@ import UIKit // MARK: - 设备事件 - // 发送设备连接事件 + // 发送设备已连接事件 private func sendDeviceConnectedEvent(device: [String: String]) { - guard let name = device["name"], let address = device["address"] else { return } - - let deviceMap: [String: Any] = [ - "name": name, - "address": address, - "type": "headset" - ] - let event: [String: Any] = [ "type": "deviceConnected", - "device": deviceMap, + "device": device, "timestamp": Int(Date().timeIntervalSince1970 * 1000) ] - eventHandler?.sendEvent(event) } - // 发送设备断开事件 + // 发送设备已断开事件 private func sendDeviceDisconnectedEvent(device: [String: String]) { - guard let name = device["name"], let address = device["address"] else { return } - - let deviceMap: [String: Any] = [ - "name": name, - "address": address, - "type": "headset" - ] - let event: [String: Any] = [ "type": "deviceDisconnected", - "device": deviceMap, + "device": device, "timestamp": Int(Date().timeIntervalSince1970 * 1000) ] - eventHandler?.sendEvent(event) } @@ -177,12 +132,7 @@ import UIKit // 获取当前连接的耳机设备 @objc func getConnectedHeadsetDevices(_ completion: @escaping ([[String: String]]?, String?) -> Void) { - // 检查蓝牙状态 - if centralManager?.state != .poweredOn { - completion(nil, "蓝牙未启用") - return - } - + do { // 获取设备 let devices = getConnectedAudioDevices() @@ -210,23 +160,11 @@ import UIKit return result } - - // 释放资源 - @objc func dispose() { - audioSessionManager.removeRouteChangeListener(self) - eventHandler = nil - } - - // 配置音频会话 - private func configureAudioSession() { - do { - try audioSessionManager.configureForBluetoothHeadset() - } catch {} - } + // 检查蓝牙是否启用 @objc func isBluetoothEnabled() -> Bool { - return centralManager?.state == .poweredOn + return audioSessionManager.isBluetoothEnabled() } // 清理设备地址,移除后缀 diff --git a/ios/Runner/VoiceInteractionService.swift b/ios/Runner/VoiceInteractionService.swift new file mode 100644 index 000000000..592ddcfe3 --- /dev/null +++ b/ios/Runner/VoiceInteractionService.swift @@ -0,0 +1,514 @@ +import Foundation +import UIKit +import AVFoundation + +/** + * 语音交互服务,iOS原生实现 + * + * 负责: + * 1) 监听蓝牙耳机按键 + * 2) 处理语音识别 + * 3) 与火山AI服务交互 + * 4) 文本转语音播放 + */ +@available(iOS 13.0, *) +class VoiceInteractionService: NSObject { + // 常量 + private let TAG = "VoiceInteractionService" + private let RECOGNITION_TIMEOUT: TimeInterval = 8.0 + + // 单例 + static let shared = VoiceInteractionService() + + // 服务状态 + private var isRunning = false + private var isActive = false + private var isRecognitionActive = false + private var isTimeoutPaused = false + private var hasSpeechDetected = false + private var isTtsSpeaking = false + + // 事件处理器 + private weak var eventHandler: VoiceInteractionEventHandler? + + // 按键处理 + private var lastKeyEventTime: TimeInterval = 0 + private var keyEventCount = 0 + + // 活动时间 + private var lastActivityTime: TimeInterval = 0 + + // 当前用户输入 + private var currentUserInput = "" + + // 服务组件 + private var azureAsrHelper: AzureAsrHelper? + private var azureTtsHelper: AzureTtsHelper? + private var volcanoAIService: VolcanoAIService? + + + private var silenceTimer: Timer? + + // 系统提示词 + private let systemPrompt = """ + 你是一个智能语音助手,能够简洁明了地回答用户的问题。 + 请保持回答简短、准确,避免过长的解释。 + 如果用户的问题不清楚,请礼貌地请求澄清。 + 不要使用复杂的术语,除非用户明确要求。 + 用户用语音和你交互。 + """ + + // 初始化方法 + private override init() { + + + super.init() + NSLog("%@: VoiceInteractionService 初始化中", TAG) + } + + /** + * 启动服务 + * + * @param apiKey Azure语音服务API密钥 + * @param region Azure语音服务区域 + * @param volcanoKey 火山AI服务API密钥 + * @return 启动是否成功 + */ + func start(azureKey: String, azureRegion: String, volcanoKey: String) -> Bool { + guard !isRunning else { + NSLog("%@: 服务已经在运行", TAG) + return true + } + + NSLog("%@: 启动服务中", TAG) + + // 重置状态 + resetState() + + // 初始化各个服务组件 + initServices(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) + + // 播放静音音频,确保音频会话活跃 + // startSilencePlayback() + + // 启动monitorService定时器 + silenceTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(monitorService), userInfo: nil, repeats: true) + NSLog("%@: monitorService定时器已启动", TAG) + + // 标记服务为运行状态 + isRunning = true + + NSLog("%@: 服务启动完成,等待蓝牙按键事件", TAG) + return true + } + + /** + * 停止服务 + */ + func stop() { + guard isRunning else { + NSLog("%@: 服务未运行", TAG) + return + } + + NSLog("%@: 停止服务中", TAG) + + // 停止monitorService定时器 + silenceTimer?.invalidate() + silenceTimer = nil + + // 停止语音识别 + if isRecognitionActive { + stopVoiceRecognition() + } + + // 停止TTS + stopCurrentTTS() + + // 释放服务实例 + azureAsrHelper?.dispose() + azureTtsHelper?.dispose() + + // 更新状态 + resetState() + isRunning = false + + NSLog("%@: 服务已停止", TAG) + } + + /** + * 获取服务运行状态 + */ + func isServiceRunning() -> Bool { + return isRunning + } + + /** + * 重置状态 + */ + private func resetState() { + isActive = false + isRecognitionActive = false + isTimeoutPaused = false + hasSpeechDetected = false + isTtsSpeaking = false + } + + /** + * 初始化服务 + */ + private func initServices(azureKey: String, azureRegion: String, volcanoKey: String) { + NSLog("%@: 初始化服务组件", TAG) + + // 初始化Azure ASR + azureAsrHelper = AzureAsrHelper { [weak self] eventName, eventData in + // 处理ASR事件 + self?.handleAsrEvent(eventName: eventName, eventData: eventData) + } + + // 初始化Azure TTS + azureTtsHelper = AzureTtsHelper { [weak self] eventName, eventData in + // 处理TTS事件 + self?.handleTtsEvent(eventName: eventName, eventData: eventData) + } + + // 初始化火山AI服务 + volcanoAIService = VolcanoAIService() + + // 配置Azure ASR + if !azureKey.isEmpty && !azureRegion.isEmpty { + azureAsrHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, supportedLanguages: ["zh-CN"]) + NSLog("%@: Azure ASR初始化完成", TAG) + } else { + NSLog("%@: Azure配置信息不完整,无法初始化Azure ASR", TAG) + } + + // 配置Azure TTS + if !azureKey.isEmpty && !azureRegion.isEmpty { + azureTtsHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, language: "zh-CN") + NSLog("%@: Azure TTS初始化完成", TAG) + } else { + NSLog("%@: Azure配置信息不完整,无法初始化Azure TTS", TAG) + } + + // 配置火山AI + if !volcanoKey.isEmpty { + volcanoAIService?.initialize(apiKey: volcanoKey) + NSLog("%@: 火山AI服务初始化完成", TAG) + } else { + NSLog("%@: 火山AI配置信息不完整,无法初始化火山AI服务", TAG) + } + } + + /** + * 监控服务状态 + */ + @objc private func monitorService() { + // 确保服务保持活跃状态 + if !isActive { + isActive = true + } + + // 检查语音识别状态 + if isRecognitionActive { + let currentTime = Date().timeIntervalSince1970 + let elapsedTime = currentTime - lastActivityTime + + // NSLog("%@: 已过去时间: %@, 是否检测到语音: %@, 是否TTS播放: %@", TAG, String(elapsedTime), String(hasSpeechDetected), String(isTtsSpeaking), String(elapsedTime)) + + // 如果超过超时时间没有检测到语音,且不在TTS播放中,暂停语音识别 + if !hasSpeechDetected && !isTtsSpeaking && elapsedTime >= RECOGNITION_TIMEOUT { + // NSLog("%@: 超过%@秒未检测到语音,停止识别", TAG, String(RECOGNITION_TIMEOUT)) + isTimeoutPaused = true + playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。") + + // 停止语音识别并确保资源完全释放 + stopVoiceRecognition() + + // 清理识别状态 + isRecognitionActive = false + hasSpeechDetected = false + } + } + + } + + /** + * 处理ASR事件 + */ + private func handleAsrEvent(eventName: String, eventData: [String: Any]) { + // NSLog("%@: 处理ASR事件: %@", TAG, eventName) + switch eventName { + case "recognizing": + if let text = eventData["text"] as? String, !text.isEmpty { + hasSpeechDetected = true + stopCurrentTTS() + updateLastActivityTime() + } + + case "result": + if let text = eventData["text"] as? String, !text.isEmpty { + updateLastActivityTime() + + processWithVolcanoAI(text) + } + + // 重置状态,继续识别 + hasSpeechDetected = false + + case "sessionStarted": + updateLastActivityTime() + + case "sessionStopped": + isRecognitionActive = false + + case "canceled": + isRecognitionActive = false + + case "error": + isRecognitionActive = false + playNotification("语音识别出错") + + default: + break + } + } + + /** + * 处理TTS事件 + */ + private func handleTtsEvent(eventName: String, eventData: [String: Any]) { + // NSLog("%@: 处理TTS事件: %@", TAG, eventName) + switch eventName { + case "started": + isTtsSpeaking = true + + case "completed": + isTtsSpeaking = false + updateLastActivityTime() + + case "canceled": + isTtsSpeaking = false + + case "error": + isTtsSpeaking = false + NSLog("%@: TTS错误: %@", TAG, eventData["error"] as? String ?? "未知错误") + + default: + break + } + } + + /** + * 处理双击事件 + */ + func handleMediaButtonAction() { + NSLog("%@: 处理媒体按钮事件", TAG) + + // 屏蔽短时间重复响应的问题 + let currentTime = Date().timeIntervalSince1970 + let timeDiff = currentTime - lastKeyEventTime + if timeDiff < 0.5 { + NSLog("%@: 短时间重复响应,忽略", TAG) + return + } + lastKeyEventTime = currentTime + + // 停止当前TTS播放 + stopCurrentTTS() + + // 播放提示音 + playPrompt("我在!") + + // 重置超时暂停标志 + isTimeoutPaused = false + + // 启动或重置语音识别 + if !isRecognitionActive { + NSLog("%@: 语音识别未激活,开始启动", TAG) + startVoiceRecognition() + } else { + NSLog("%@: 语音识别已激活,更新活动时间", TAG) + updateLastActivityTime() + hasSpeechDetected = false + } + } + + /** + * 开始语音识别 + */ + private func startVoiceRecognition() { + if isRecognitionActive { return } + + // 通知Flutter语音识别已启动 + notifyVoiceRecognitionStarted() + + isActive = true + isRecognitionActive = true + hasSpeechDetected = false + updateLastActivityTime() + + if let asrHelper = azureAsrHelper { + let success = asrHelper.startContinuousRecognition() + if !success { + isRecognitionActive = false + NSLog("%@: 启动语音识别失败", TAG) + playNotification("启动语音识别失败") + } + } else { + isRecognitionActive = false + NSLog("%@: Azure ASR服务未初始化", TAG) + playNotification("语音识别服务未初始化") + } + } + + /** + * 停止语音识别 + */ + private func stopVoiceRecognition() { + if !isRecognitionActive { return } + + NSLog("%@: 停止语音识别", TAG) + + if let asrHelper = azureAsrHelper { + let success = asrHelper.stopContinuousRecognition() + if !success { + NSLog("%@: 停止语音识别失败", TAG) + } + } + + isRecognitionActive = false + hasSpeechDetected = false + } + + /** + * 使用火山AI处理语音识别结果 + */ + private func processWithVolcanoAI(_ text: String) { + // 保存当前用户输入 + currentUserInput = text + + // 在后台线程处理 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self, let volcanoAIService = self.volcanoAIService else { + return + } + + do { + // 创建消息 + let message = volcanoAIService.createUserMessage(content: text) + let messages = [message] + + // 发送请求 + let response = try volcanoAIService.sendMessage(messages: messages, systemPrompt: self.systemPrompt) + + // 播放AI回复 + self.speakAIResponse(response) + + // 同步聊天记录到Flutter端 + self.notifyChatHistoryUpdated(userMessage: text, assistantMessage: response) + } catch { + NSLog("%@: AI处理出错: %@", self.TAG, error.localizedDescription) + self.playNotification("AI处理出错") + } + } + } + + /** + * 播放AI回复 + */ + private func speakAIResponse(_ text: String) { + isTtsSpeaking = true + azureTtsHelper?.speakText(text: text) + } + + /** + * 播放提示音 + */ + private func playPrompt(_ message: String) { + isTtsSpeaking = true + azureTtsHelper?.speakText(text: message) + } + + /** + * 播放通知提示音 + */ + private func playNotification(_ message: String) { + isTtsSpeaking = true + azureTtsHelper?.speakText(text: message) + } + + /** + * 停止当前TTS播放 + */ + private func stopCurrentTTS() { + if isTtsSpeaking { + azureTtsHelper?.stopSpeaking() + isTtsSpeaking = false + } + } + + /** + * 更新最后活动时间 + */ + private func updateLastActivityTime() { + lastActivityTime = Date().timeIntervalSince1970 + } + + /** + * 暂停语音交互 + */ + func pauseVoiceInteraction() { + NSLog("%@: 暂停语音交互", TAG) + + // 停止当前语音播放 + stopCurrentTTS() + + // 停止语音识别 + if isRecognitionActive { + stopVoiceRecognition() + } + + // 设置状态为暂停,但服务保持运行 + isTimeoutPaused = true + } + + /** + * 通知语音识别已开始 + */ + private func notifyVoiceRecognitionStarted() { + // 发送识别开始事件 + sendEvent(type: "recognition_started", data: [:]) + } + + /** + * 通知聊天历史更新 + */ + private func notifyChatHistoryUpdated(userMessage: String, assistantMessage: String) { + // 发送聊天历史更新事件 + sendEvent(type: "chat_history_updated", data: [ + "agentId": "personal_assistant", + "userMessage": userMessage, + "assistantMessage": assistantMessage + ]) + } + + /** + * 设置事件处理器 + */ + func setEventHandler(_ handler: VoiceInteractionEventHandler) { + self.eventHandler = handler + } + + /** + * 发送事件到Flutter端 + */ + private func sendEvent(type: String, data: [String: Any] = [:]) { + var eventData = data + eventData["type"] = type + eventData["timestamp"] = Int(Date().timeIntervalSince1970 * 1000) + + // 使用事件处理器发送事件 + eventHandler?.sendEvent(eventData) + } +} \ No newline at end of file diff --git a/ios/Runner/VolcanoAIService.swift b/ios/Runner/VolcanoAIService.swift new file mode 100644 index 000000000..4289b3b1b --- /dev/null +++ b/ios/Runner/VolcanoAIService.swift @@ -0,0 +1,391 @@ +import Foundation + +/** + * 火山AI服务的iOS原生实现 + * + * 参考Android端的VolcanoAIService实现,提供同步和异步的API调用方式 + */ +class VolcanoAIService { + private let TAG = "VolcanoAIService" + private let baseUrl = "https://ark.cn-beijing.volces.com/api/v3" + private let chatEndpoint = "/chat/completions" + private let session: URLSession + + private var apiKey: String = "" + private var isInitialized = false + + init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30.0 + config.timeoutIntervalForResource = 30.0 + self.session = URLSession(configuration: config) + } + + /** + * 初始化火山AI服务 + * + * @param apiKey 火山AI API密钥 + * @return 初始化是否成功 + */ + func initialize(apiKey: String) -> Bool { + self.apiKey = apiKey + isInitialized = !apiKey.isEmpty + + if !isInitialized { + NSLog("%@: 初始化失败:API key 不能为空", TAG) + } else { + NSLog("%@: 火山AI服务初始化成功", TAG) + } + + return isInitialized + } + + /** + * 生成个性化问候语 + * + * @param agentName 代理名称 + * @param systemPrompt 系统提示词 + * @param callback 回调函数,返回生成的问候语 + */ + func generateGreeting(agentName: String, systemPrompt: String, callback: @escaping (String?, Error?) -> Void) { + let messages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ["role": "user", "content": "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。"] + ] + + let messagesData = try? JSONSerialization.data(withJSONObject: messages, options: []) + let messagesArray = try? JSONSerialization.jsonObject(with: messagesData!, options: []) as? [[String: Any]] + + var result = "" + + sendMessageStream(messages: messagesArray!, systemPrompt: systemPrompt, streamCallback: StreamCallback( + onToken: { token in + result.append(token) + }, + onComplete: { + callback(result, nil) + }, + onError: { error in + callback(nil, error) + } + )) + } + + /** + * 发送消息(非流式输出) + * + * @param messages 消息列表 + * @param systemPrompt 系统提示词 + * @return 返回AI的回复 + * @throws VolcanoAIError 如果API调用失败 + */ + func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { + // 检查是否已初始化 + if !isInitialized || apiKey.isEmpty { + throw VolcanoAIError.serviceNotInitialized + } + + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + + fullMessages.append(contentsOf: messages) + + let requestBody: [String: Any] = [ + "model": "doubao-1-5-lite-32k-250115", + "messages": fullMessages, + "temperature": 0.7, + "max_tokens": 2000, + "stream": false + ] + + guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { + throw VolcanoAIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) + } catch { + throw VolcanoAIError.invalidRequestBody + } + + let semaphore = DispatchSemaphore(value: 0) + var responseData: Data? + var responseError: Error? + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + responseError = VolcanoAIError.networkError(error.localizedDescription) + semaphore.signal() + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + responseError = VolcanoAIError.invalidResponse + semaphore.signal() + return + } + + if !(200...299).contains(httpResponse.statusCode) { + var errorMessage = "Unknown error occurred" + if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String { + errorMessage = message + } + responseError = VolcanoAIError.apiError(errorMessage) + semaphore.signal() + return + } + + responseData = data + semaphore.signal() + } + + task.resume() + _ = semaphore.wait(timeout: .distantFuture) + + if let error = responseError { + throw error + } + + guard let data = responseData else { + throw VolcanoAIError.emptyResponse + } + + do { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let content = message["content"] as? String else { + throw VolcanoAIError.invalidResponseFormat + } + + return content + } catch { + throw VolcanoAIError.invalidResponseFormat + } + } + + /** + * 发送消息(流式输出) + * + * @param messages 消息列表 + * @param systemPrompt 系统提示词 + * @param streamCallback 回调函数,用于接收流式输出的结果 + */ + func sendMessageStream(messages: [[String: Any]], systemPrompt: String, streamCallback: StreamCallback) { + // 检查是否已初始化 + if !isInitialized || apiKey.isEmpty { + streamCallback.onError(VolcanoAIError.serviceNotInitialized) + return + } + + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + + fullMessages.append(contentsOf: messages) + + let requestBody: [String: Any] = [ + "model": "doubao-1-5-lite-32k-250115", + "messages": fullMessages, + "temperature": 0.7, + "max_tokens": 2000, + "stream": true + ] + + guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { + streamCallback.onError(VolcanoAIError.invalidURL) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.addValue("text/event-stream", forHTTPHeaderField: "Accept") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) + } catch { + streamCallback.onError(VolcanoAIError.invalidRequestBody) + return + } + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + streamCallback.onError(VolcanoAIError.networkError(error.localizedDescription)) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + streamCallback.onError(VolcanoAIError.invalidResponse) + return + } + + if !(200...299).contains(httpResponse.statusCode) { + var errorMessage = "Unknown error occurred" + if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String { + errorMessage = message + } + streamCallback.onError(VolcanoAIError.apiError(errorMessage)) + return + } + + guard let data = data else { + streamCallback.onError(VolcanoAIError.emptyResponse) + return + } + + // 处理SSE流数据 + let responseString = String(data: data, encoding: .utf8) ?? "" + let lines = responseString.components(separatedBy: "\n") + + for line in lines { + if line.isEmpty { continue } + + if line.hasPrefix("data: ") { + let data = String(line.dropFirst(6)) + if data == "[DONE]" { + streamCallback.onComplete() + break + } + + do { + if let jsonData = data.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let delta = firstChoice["delta"] as? [String: Any], + let content = delta["content"] as? String { + streamCallback.onToken(content) + } + } catch { + // 忽略无效的JSON数据 + continue + } + } + } + } + + task.resume() + } + + /** + * 同步方式发送消息(流式输出) + * + * 注意:此方法会阻塞当前线程,请在后台线程中调用 + * + * @param messages 消息列表 + * @param systemPrompt 系统提示词 + * @return 返回完整的AI回复 + * @throws VolcanoAIError 如果API调用失败 + */ + func sendMessageStreamSync(messages: [[String: Any]], systemPrompt: String) throws -> String { + var result = "" + let semaphore = DispatchSemaphore(value: 0) + var responseError: Error? + + sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: StreamCallback( + onToken: { token in + result.append(token) + }, + onComplete: { + semaphore.signal() + }, + onError: { error in + responseError = error + semaphore.signal() + } + )) + + // 等待流式输出完成或出错 + _ = semaphore.wait(timeout: .now() + 60) + + if let error = responseError { + throw error + } + + return result + } + + /** + * 创建用户消息 + */ + func createUserMessage(content: String) -> [String: Any] { + return ["role": "user", "content": content] + } + + /** + * 创建系统消息 + */ + func createSystemMessage(content: String) -> [String: Any] { + return ["role": "system", "content": content] + } + + /** + * 创建助手消息 + */ + func createAssistantMessage(content: String) -> [String: Any] { + return ["role": "assistant", "content": content] + } + + /** + * 流式输出回调类 + */ + class StreamCallback { + let onToken: (String) -> Void + let onComplete: () -> Void + let onError: (Error) -> Void + + init(onToken: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void) { + self.onToken = onToken + self.onComplete = onComplete + self.onError = onError + } + } +} + +/** + * 火山AI错误枚举 + */ +enum VolcanoAIError: Error { + case serviceNotInitialized + case invalidURL + case invalidRequestBody + case networkError(String) + case invalidResponse + case emptyResponse + case invalidResponseFormat + case apiError(String) + + var localizedDescription: String { + switch self { + case .serviceNotInitialized: + return "火山AI服务未初始化或API key为空,请先调用initialize方法" + case .invalidURL: + return "无效的URL" + case .invalidRequestBody: + return "无效的请求体" + case .networkError(let message): + return "网络错误: \(message)" + case .invalidResponse: + return "无效的响应" + case .emptyResponse: + return "空响应" + case .invalidResponseFormat: + return "无效的响应格式" + case .apiError(let message): + return "API错误: \(message)" + } + } +} \ No newline at end of file diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 9752548b5..7546378d7 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -1,16 +1,14 @@ import 'package:get/get.dart'; -import 'dart:io'; import '../../data/services/speech_factory.dart'; import '../../data/services/chat_history_service.dart'; import '../../data/services/volcano_translation_service.dart'; -import '../../data/services/voice_interaction_service.dart'; import '../../data/services/language_manager.dart'; import '../../data/services/volcano_ai_service.dart'; import '../../data/services/classic_bluetooth_service.dart'; import '../../data/services/bluetooth_media_button_service.dart'; import '../../core/utils/logger.dart'; -import '../../data/services/voice_interaction_impl/ios_voice_interaction_service.dart'; -import '../../data/services/voice_interaction_impl/android_voice_interaction_service.dart'; +import '../../data/services/voice_interaction_service.dart'; + /// 初始绑定,用于管理全局依赖 @@ -44,12 +42,8 @@ class InitialBinding extends Bindings { - // 根据平台注册对应的VoiceInteractionService实现 - if (Platform.isIOS) { - Get.lazyPut(() => IosVoiceInteractionService(), fenix: true); - } else { - Get.lazyPut(() => AndroidVoiceInteractionService(), fenix: true); - } + Get.lazyPut(() => VoiceInteractionService(), fenix: true); + } } \ No newline at end of file diff --git a/lib/data/services/bluetooth_media_button_service.dart b/lib/data/services/bluetooth_media_button_service.dart index 559928ab8..98864ba13 100644 --- a/lib/data/services/bluetooth_media_button_service.dart +++ b/lib/data/services/bluetooth_media_button_service.dart @@ -193,14 +193,7 @@ class BluetoothMediaButtonService extends GetxService { timestamp: eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, ); - // 唤醒语音交互服务 - try { - - Get.find().onWakeup(); - } catch (e) { - Logger.error('唤醒语音交互服务失败: $e'); - } // 广播事件 _buttonEventController.add(buttonEvent); diff --git a/lib/data/services/classic_bluetooth_service.dart b/lib/data/services/classic_bluetooth_service.dart index a73ad5d6a..cd9608d39 100644 --- a/lib/data/services/classic_bluetooth_service.dart +++ b/lib/data/services/classic_bluetooth_service.dart @@ -153,6 +153,7 @@ class ClassicBluetoothService extends GetxService { .receiveBroadcastStream() .listen((event) { if (event is Map) { + Logger.info('蓝牙状态监听: $event'); final type = event['type'] as String?; if (type == 'bluetoothStateChanged') { @@ -174,7 +175,7 @@ class ClassicBluetoothService extends GetxService { /// 处理设备连接事件 void _handleDeviceConnected(Map event) { final deviceMap = event['device'] as Map?; - if (deviceMap != null && (deviceMap['type'] == 'headset' || deviceMap['type'] == 'unknown')) { + if (deviceMap != null) { final device = BluetoothDevice( name: deviceMap['name'] ?? '未知设备', address: deviceMap['address'] ?? '', diff --git a/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart index 203bbf774..5a3831d79 100644 --- a/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart @@ -6,13 +6,12 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../modules/chat/models/message_model.dart'; import '../../models/events/voice_interaction_event.dart'; import '../chat_history_service.dart'; -import '../voice_interaction_service.dart'; /// Android语音交互服务 /// /// 该服务提供了与Android端的VoiceInteractionService.kt通信的接口, /// 用于管理后台语音交互服务的生命周期和接收语音交互事件 -class AndroidVoiceInteractionService extends GetxService implements VoiceInteractionService { +class AndroidVoiceInteractionService extends GetxService { static AndroidVoiceInteractionService get to => Get.find(); // 方法通道和事件通道 static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); @@ -89,7 +88,7 @@ class AndroidVoiceInteractionService extends GetxService implements VoiceInterac /// 初始化服务 @override - Future initialize() async { + Future initialize() async { try { if (_isInitialized) { return this; @@ -111,7 +110,7 @@ class AndroidVoiceInteractionService extends GetxService implements VoiceInterac return this; } } - + @override void onWakeup() { Logger.info('语音交互服务唤醒'); diff --git a/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart index ac339871d..2d129b050 100644 --- a/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart @@ -7,14 +7,13 @@ import '../chat_history_service.dart'; import '../../../modules/chat/models/message_model.dart'; import '../../models/events/voice_interaction_event.dart'; import '../../../core/utils/logger.dart'; -import '../voice_interaction_service.dart'; enum VoiceInteractionState { idle, // 等待唤醒 active, // 活跃状态 - 可以同时识别和播放 } -class IosVoiceInteractionService extends GetxService implements VoiceInteractionService { +class IosVoiceInteractionService extends GetxService { final AsrService _asrService = Get.find(); final TtsService _ttsService = Get.find(); final VolcanoAIService _aiService = Get.find(); @@ -60,7 +59,7 @@ class IosVoiceInteractionService extends GetxService implements VoiceInteraction /// 初始化服务 @override - Future initialize() async { + Future initialize() async { try { // 保持idle状态,不自动启动语音识别 Logger.info('iOS语音交互服务初始化完成,处于idle状态'); diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 396fc6df3..1ca509528 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -1,33 +1,275 @@ import 'dart:async'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/events/voice_interaction_event.dart'; +import '../../core/utils/logger.dart'; +import '../../modules/chat/models/message_model.dart'; +import 'chat_history_service.dart'; -/// 语音交互服务抽象基类 -/// -/// 定义了iOS和Android平台共用的语音交互服务接口 -abstract class VoiceInteractionService { - /// 事件流,用于发布语音交互相关事件 - Stream get eventStream; +/// 语音交互服务接口 +/// +/// 管理与平台原生语音交互服务的通信,提供统一的接口供应用使用 +class VoiceInteractionService extends GetxService { + static VoiceInteractionService get to => Get.find(); - /// 初始化服务 - /// - /// 返回服务实例自身,以支持链式调用 - Future initialize(); + // 方法通道 + static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); - /// 暂停语音交互 - /// - /// 停止正在进行的语音识别和TTS播放 - void pauseVoiceInteraction(); + // 事件通道 + static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); + + // 服务状态 + final _isServiceRunning = false.obs; + bool get isServiceRunning => _isServiceRunning.value; + + // 流控制器 + final _eventStreamController = StreamController.broadcast(); + + // 事件流 + Stream get eventStream => _eventStreamController.stream; + + // 事件通道订阅 + StreamSubscription? _eventSubscription; + + // 标记是否初始化 + bool _isInitialized = false; + + // 配置信息 + late String _azureSpeechKey; + late String _azureSpeechRegion; + late String _volcanoAiKey; + + // 聊天历史服务 + late final ChatHistoryService _chatHistoryService; + + + /// 设置事件通道 + void _setupEventChannel() { + _eventSubscription = _eventChannel + .receiveBroadcastStream() + .listen(_handleVoiceInteractionEvent, onError: (error) { + Logger.error('语音交互事件通道错误: $error'); + }); + } + + /// 从环境变量加载配置 + void _loadConfig() { + _azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; + _azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; + _volcanoAiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; + + if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { + Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); + } + + if (_volcanoAiKey.isEmpty) { + Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); + } + } + + /// 处理来自原生层的事件 + void _handleVoiceInteractionEvent(dynamic event) { + if (event is! Map) return; + + final eventMap = event as Map; + final String eventType = eventMap['type'] as String? ?? ''; + // final int timestamp = eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch; + + Logger.info('收到语音交互事件: $eventType'); + + switch (eventType) { + case 'recognition_started': + // 语音识别开始事件 + final recognitionEvent = RecognitionStartedEvent( + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + _eventStreamController.add(recognitionEvent); + break; + + case 'chat_history_updated': + // 聊天历史更新事件 + final String agentId = eventMap['agentId'] as String? ?? ''; + final String userMessage = eventMap['userMessage'] as String? ?? ''; + final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; + + final chatHistoryEvent = ChatHistoryEvent( + agentId: agentId, + userMessage: userMessage, + assistantMessage: assistantMessage, + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + _eventStreamController.add(chatHistoryEvent); + + // 保存聊天历史到ChatHistoryService + _saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); + break; + } + } + + /// 保存聊天历史 + void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { + try { + // 检查参数有效性 + if (userMessage.isEmpty) { + return; + } + + // 创建用户消息和助手消息 + final userMsg = Message( + role: 'user', + content: userMessage, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), + ); + + final assistantMsg = Message( + role: 'assistant', + content: assistantMessage, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 + ); + + // 加载现有历史记录 + final existingMessages = _chatHistoryService.loadHistory(agentId); + + // 添加新消息 + existingMessages.addAll([userMsg, assistantMsg]); + + // 保存更新后的历史记录 + _chatHistoryService.saveHistory(agentId, existingMessages); + + Logger.info('已保存聊天历史: agentId=$agentId'); + } catch (e) { + Logger.error('保存聊天历史失败: $e'); + } + } + + /// 初始化服务 + Future initialize() async { + if (_isInitialized) return true; + + try { + Logger.info('正在初始化语音交互服务...'); + + // 获取聊天历史服务 + try { + _chatHistoryService = Get.find(); + } catch (e) { + Logger.warning('获取ChatHistoryService失败,将创建新实例'); + _chatHistoryService = Get.put(ChatHistoryService()); + } + + // 设置事件通道 + _setupEventChannel(); + + // 加载配置 + _loadConfig(); - /// 唤醒语音交互 - void onWakeup(); + // 检查服务是否已在运行 + final bool running = await checkServiceStatus(); + + // 如果服务未运行,启动服务 + if (!running) { + // 启动语音交互服务 + final success = await startService(); + if (!success) { + Logger.error('语音交互服务启动失败'); + return false; + } + } + + _isInitialized = true; + Logger.info('语音交互服务初始化完成'); + return true; + } catch (e) { + Logger.error('语音交互服务初始化失败: $e'); + return false; + } + } + + /// 启动语音交互服务 + Future startService() async { + try { + Logger.info('启动语音交互服务...'); + + // 使用已加载的配置信息 + final result = await _channel.invokeMethod('startService', { + 'azureSpeechKey': _azureSpeechKey, + 'azureSpeechRegion': _azureSpeechRegion, + 'volcanoAiKey': _volcanoAiKey, + }) ?? false; + + if (result) { + _isServiceRunning.value = true; + Logger.info('语音交互服务已启动'); + } else { + Logger.error('启动语音交互服务失败'); + } + + return result; + } catch (e) { + Logger.error('启动语音交互服务时发生错误: $e'); + return false; + } + } - /// 是否处于活跃状态 - bool isActive(); + /// 停止语音交互服务 + Future stopService() async { + try { + Logger.info('停止语音交互服务...'); + + final result = await _channel.invokeMethod('stopService') ?? false; + + if (result) { + _isServiceRunning.value = false; + Logger.info('语音交互服务已停止'); + } else { + Logger.error('停止语音交互服务失败'); + } + + return result; + } catch (e) { + Logger.error('停止语音交互服务时发生错误: $e'); + return false; + } + } - /// 获取当前对话历史 - List> getMessageHistory(); + /// 检查服务是否运行 + Future checkServiceStatus() async { + try { + final bool result = await _channel.invokeMethod('isServiceRunning') ?? false; + _isServiceRunning.value = result; + return result; + } catch (e) { + Logger.error('检查服务状态时发生错误: $e'); + return false; + } + } + + /// 暂停语音交互 + Future pauseVoiceInteraction() async { + try { + Logger.info('暂停语音交互...'); + + final result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; + + if (result) { + Logger.info('语音交互已暂停'); + } else { + Logger.error('暂停语音交互失败'); + } + + return result; + } catch (e) { + Logger.error('暂停语音交互时发生错误: $e'); + return false; + } + } - /// 获取当前服务ID - String getServiceId(); + /// 资源释放 + @override + void onClose() { + _eventSubscription?.cancel(); + _eventStreamController.close(); + super.onClose(); + } } \ No newline at end of file diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 6fa59a41f..083ac2494 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -11,7 +11,6 @@ import '../../../data/services/asr_service.dart'; import '../../../data/services/chat_history_service.dart'; import '../../../data/models/events/voice_interaction_event.dart'; import '../../../data/services/voice_interaction_service.dart'; -import 'dart:io' show Platform; class ChatController extends GetxController { // 服务 diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 55fa2264f..c15c5eb42 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -1,15 +1,12 @@ -import 'package:deep_voice/data/services/speech_impl/azure_tts_service.dart'; import 'package:get/get.dart'; import 'dart:async'; import '../../../routes/app_routes.dart'; import '../../../data/services/classic_bluetooth_service.dart'; import '../../../data/services/bluetooth_media_button_service.dart'; +import '../../../data/services/voice_interaction_service.dart'; import '../../../core/utils/logger.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart' hide BluetoothDevice; import 'package:flutter/material.dart'; -import '../../../data/services/voice_interaction_impl/android_voice_interaction_service.dart'; -import 'package:permission_handler/permission_handler.dart'; -import '../../../data/providers/agent_provider.dart'; + // 连接状态枚举 enum ConnectionStatus { disconnected, // 未连接 @@ -27,6 +24,9 @@ class HomeController extends GetxController { // 蓝牙媒体按钮服务 late BluetoothMediaButtonService _mediaButtonService; + // 媒体按钮订阅 + StreamSubscription? _mediaButtonSubscription; + // 设备连接状态 final _isConnected = false.obs; bool get isConnected => _isConnected.value; @@ -56,9 +56,7 @@ class HomeController extends GetxController { // 蓝牙状态 RxString get bluetoothState => _bluetoothService.bluetoothState; - // 按钮事件流订阅 - StreamSubscription? _mediaButtonEventSubscription; - + // 状态更新标志,防止多次重复更新 bool _isUpdatingStatus = false; @@ -70,7 +68,7 @@ class HomeController extends GetxController { _initBluetoothService(); // 初始化蓝牙媒体按钮服务 - _initMediaButtonService(); + // _initMediaButtonService(); // 电池模拟更新(实际应用中可以通过蓝牙获取) _simulateBatteryUpdate(); @@ -100,32 +98,18 @@ class HomeController extends GetxController { // 获取媒体按钮服务实例 _mediaButtonService = Get.find(); - // 检查是否支持该功能 - if (!_mediaButtonService.isSupported) { - Logger.info('当前设备不支持蓝牙媒体按钮功能'); - return; - } - - // 初始化服务 + // 初始化媒体按钮服务 _mediaButtonService.initialize().then((success) { if (success) { - // 开始监听蓝牙媒体按钮事件 + // 启动按钮监听 _mediaButtonService.startButtonListening(); - // 订阅按钮事件流 - _mediaButtonEventSubscription = _mediaButtonService.buttonEvents.listen( - (event) { - Logger.info('HomeController: 收到蓝牙媒体按钮事件 $event'); - _handleMediaButtonEvent(event); - }, - onError: (error) { - Logger.error('HomeController: 蓝牙媒体按钮事件流错误: $error'); - } - ); + // 设置按钮事件监听 + _setupMediaButtonListener(); Logger.info('蓝牙媒体按钮服务初始化成功'); } else { - Logger.error('蓝牙媒体按钮服务初始化失败'); + Logger.warning('蓝牙媒体按钮服务初始化失败'); } }); } catch (e) { @@ -133,6 +117,22 @@ class HomeController extends GetxController { } } + // 设置媒体按钮事件监听 + void _setupMediaButtonListener() { + // 取消之前的订阅 + _mediaButtonSubscription?.cancel(); + + // 设置新的订阅 + _mediaButtonSubscription = _mediaButtonService.buttonEvents.listen((event) { + Logger.info('收到媒体按钮事件: $event'); + + // 处理按钮事件 + if (event.buttonType == MediaButtonType.playPause) { + } + }); + } + + // 电池电量模拟更新 void _simulateBatteryUpdate() { // 每5分钟更新一次电池电量(模拟) @@ -325,29 +325,12 @@ class HomeController extends GetxController { return device?.address ?? '未知'; } - // 处理蓝牙媒体按钮事件 - void _handleMediaButtonEvent(MediaButtonEvent event) { - Logger.info('收到蓝牙媒体按钮事件: $event'); - - // 根据按钮类型执行相应操作 - switch (event.buttonType) { - case MediaButtonType.playPause: - - // AzureTtsService.to.speakOnce('我在呢'); - - break; - - default: - // 处理其他按钮事件 - break; - } - } @override void onClose() { // 取消按钮事件流订阅 - _mediaButtonEventSubscription?.cancel(); + _mediaButtonSubscription?.cancel(); super.onClose(); } diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index c6d3efe17..39ac07d55 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -1,4 +1,3 @@ -import 'package:deep_voice/data/services/voice_interaction_service.dart'; import 'package:get/get.dart'; import 'dart:async'; import '../../../routes/app_routes.dart'; @@ -6,6 +5,7 @@ import '../../../data/services/classic_bluetooth_service.dart'; import '../../../core/utils/logger.dart'; import 'dart:io'; import 'package:app_settings/app_settings.dart'; +import '../../../data/services/voice_interaction_service.dart'; /// 简化的配对控制器,仅用于显示已连接经典蓝牙设备和处理基本配对流程 @@ -19,7 +19,7 @@ class PairingController extends GetxController { // 语音交互服务 final VoiceInteractionService _voiceInteractionService = Get.find(); - + // 是否开始搜索 final _isSearching = false.obs; bool get isSearching => _isSearching.value;