diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt index 9ed28070d..6ca172b66 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt @@ -166,6 +166,7 @@ class AzureAsrHelper(private val context: Context) { } else { currentLanguage } + FileLogger.d(TAG, "最终识别结果: ${event.result.text}") callback.onResult(event.result.text, detectedLanguage) } } @@ -180,6 +181,7 @@ class AzureAsrHelper(private val context: Context) { } else { currentLanguage } + FileLogger.d(TAG, "识别中结果: ${event.result.text}") callback.onRecognizing(event.result.text, detectedLanguage) } } @@ -228,7 +230,7 @@ class AzureAsrHelper(private val context: Context) { } try { - recognizer?.stopContinuousRecognitionAsync()?.get() + recognizer?.stopContinuousRecognitionAsync() isContinuousRecognitionActive = false callback.onSessionStopped() return true diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt index 287529fdc..a6c7d46b0 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt @@ -24,6 +24,9 @@ class AzureTtsHelper(private val context: Context) { private var isInitialized = false private var audioManager: AudioManager? = null + // 跟踪当前是否正在播放语音 + private var speaking = false + // 音频输出设备类型 enum class AudioOutputType { SPEAKER, // 扬声器 @@ -74,8 +77,14 @@ class AzureTtsHelper(private val context: Context) { synthesizer = SpeechSynthesizer(speechConfig, audioConfig) // 设置事件监听 - synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> semaphore.release() } - synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> semaphore.release() } + synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } + synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } isInitialized = true FileLogger.d(TAG, "TTS 引擎初始化成功") @@ -189,8 +198,14 @@ class AzureTtsHelper(private val context: Context) { synthesizer = SpeechSynthesizer(speechConfig, audioConfig) // 重新设置事件监听 - synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> semaphore.release() } - synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> semaphore.release() } + synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } + synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } FileLogger.d(TAG, "已重新创建语音合成器") } catch (e: Exception) { @@ -323,6 +338,9 @@ class AzureTtsHelper(private val context: Context) { // 异步合成语音 val task: Future = synthesizer!!.SpeakSsmlAsync(ssml) + // 标记为正在播放 + speaking = true + // 等待合成完成 Thread { try { @@ -373,6 +391,7 @@ class AzureTtsHelper(private val context: Context) { try { synthesizer?.StopSpeakingAsync() + speaking = false FileLogger.d(TAG, "已停止语音合成") return true @@ -399,6 +418,7 @@ class AzureTtsHelper(private val context: Context) { } isInitialized = false + speaking = false FileLogger.d(TAG, "TTS 引擎已释放") } catch (e: Exception) { FileLogger.e(TAG, "释放 TTS 引擎失败: ${e.message}") @@ -406,6 +426,15 @@ class AzureTtsHelper(private val context: Context) { } } + /** + * 检查当前是否正在播放语音 + * + * @return 是否正在播放语音 + */ + fun isSpeaking(): Boolean { + return speaking + } + /** * TTS 回调接口 */ diff --git a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt b/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt index 63f10c57b..7ddd8cbe2 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt @@ -311,7 +311,6 @@ class MainActivity: FlutterActivity() { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { Log.d(TAG, "语音交互事件通道开始监听") voiceInteractionEventSink = events - events?.success(mapOf("type" to "channelReady")) } override fun onCancel(arguments: Any?) { @@ -326,7 +325,6 @@ class MainActivity: FlutterActivity() { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { Log.d(TAG, "ASR事件通道开始监听") azureAsrEventSink = events - events?.success(mapOf("type" to "channelReady")) } override fun onCancel(arguments: Any?) { @@ -341,11 +339,6 @@ class MainActivity: FlutterActivity() { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { Log.d(TAG, "蓝牙事件通道开始监听") bluetoothEventSink = events - // 发送就绪事件 - events?.success(mapOf( - "type" to "channelReady", - "timestamp" to System.currentTimeMillis() - )) } override fun onCancel(arguments: Any?) { @@ -542,6 +535,9 @@ class MainActivity: FlutterActivity() { "stopSpeaking" -> { result.success(azureTtsHelper.stopSpeaking()) } + "isSpeaking" -> { + result.success(azureTtsHelper.isSpeaking()) + } "dispose" -> { azureTtsHelper.dispose() result.success(true) @@ -555,14 +551,14 @@ class MainActivity: FlutterActivity() { // 设置语音交互方法通道 MethodChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { - "startVoiceInteractionService" -> { + "startService" -> { startVoiceInteractionService(call, result) } - "stopVoiceInteractionService" -> { + "stopService" -> { stopVoiceInteractionService() result.success(true) } - "isVoiceInteractionServiceRunning" -> { + "isServiceRunning" -> { result.success(VoiceInteractionService.isServiceRunning()) } "pauseVoiceInteraction" -> { diff --git a/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt index 4247298df..e2d9f23f5 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt @@ -768,7 +768,7 @@ class VoiceInteractionService : Service() { * 用于从 Flutter 端控制服务 */ private fun pauseVoiceInteraction() { - FileLogger.d(TAG, "暂停语音交互(来自Flutter的请求)") + FileLogger.d(TAG, "暂停后台语音交互(来自Flutter的请求)") // 停止当前TTS播放 stopCurrentTTS() diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7ee0cf268..aa4aa611a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,9 +1,4 @@ PODS: - - app_settings (5.1.1): - - Flutter - - audio_service (0.0.1): - - Flutter - - FlutterMacOS - audio_session (0.0.1): - Flutter - audio_waveforms (0.0.1): @@ -37,14 +32,9 @@ PODS: - Flutter - FlutterMacOS - Try - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - Try (2.1.1) DEPENDENCIES: - - app_settings (from `.symlinks/plugins/app_settings/ios`) - - audio_service (from `.symlinks/plugins/audio_service/darwin`) - audio_session (from `.symlinks/plugins/audio_session/ios`) - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) @@ -60,7 +50,6 @@ DEPENDENCIES: - record_darwin (from `.symlinks/plugins/record_darwin/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) SPEC REPOS: trunk: @@ -68,10 +57,6 @@ SPEC REPOS: - Try EXTERNAL SOURCES: - app_settings: - :path: ".symlinks/plugins/app_settings/ios" - audio_service: - :path: ".symlinks/plugins/audio_service/darwin" audio_session: :path: ".symlinks/plugins/audio_session/ios" audio_waveforms: @@ -100,12 +85,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/share_plus/ios" speech_to_text: :path: ".symlinks/plugins/speech_to_text/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: - app_settings: 5127ae0678de1dcc19f2293271c51d37c89428b2 - audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 @@ -121,7 +102,6 @@ SPEC CHECKSUMS: record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a speech_to_text: 9dc43a5df3cbc2813f8c7cc9bd0fbf94268ed7ac - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96 PODFILE CHECKSUM: f35c1d63b4b8ce9c2eb88107f162ac59923c4839 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8189f7520..aab918c9b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0A18952B2D8F049C00675C94 /* DialogPipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A18952A2D8F049C00675C94 /* DialogPipView.swift */; }; 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 */; }; @@ -53,6 +54,7 @@ /* Begin PBXFileReference section */ 02CF74744224D1C933681603 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 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 = ""; }; + 0A18952A2D8F049C00675C94 /* DialogPipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogPipView.swift; 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 = ""; }; @@ -161,6 +163,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 0A18952A2D8F049C00675C94 /* DialogPipView.swift */, 0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */, 0A7A32E32D8DBA6D0047F746 /* VolcanoAIService.swift */, 0ADE296F2D8C6C450032BFCB /* AudioSessionManager.swift */, @@ -431,6 +434,7 @@ 0A7A32E42D8DBA6D0047F746 /* VoiceInteractionService.swift in Sources */, 0A7A32E52D8DBA6D0047F746 /* VolcanoAIService.swift in Sources */, 0ADE29702D8C6C450032BFCB /* AudioSessionManager.swift in Sources */, + 0A18952B2D8F049C00675C94 /* DialogPipView.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -524,13 +528,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 42C82SZUVX; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -543,15 +549,21 @@ baseConfigurationReference = 930FE6D58BDB20688CE0A7CF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 42C82SZUVX; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Debug; @@ -563,11 +575,15 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 42C82SZUVX; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Release; @@ -579,11 +595,15 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 42C82SZUVX; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Profile; @@ -705,16 +725,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 42C82SZUVX; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -729,13 +753,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 42C82SZUVX; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 325f897d7..d711d9000 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -30,6 +30,9 @@ import AVFoundation // 初始化音频会话管理器 audioSessionManager = AudioSessionManager.shared + // 配置音频会话支持画中画 + configureAudioSessionForPictureInPicture() + // 统一注册所有方法通道 registerMethodChannel() @@ -451,9 +454,9 @@ import AVFoundation 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 { + let azureKey = args["azure_speech_key"] as? String, + let azureRegion = args["azure_speech_region"] as? String, + let volcanoKey = args["volcano_ai_api_key"] as? String else { result(FlutterError(code: "INVALID_ARGS", message: "启动服务参数无效", details: nil)) return } @@ -524,6 +527,17 @@ import AVFoundation userInfo: eventMap ) } + + // 配置音频会话支持画中画 + private func configureAudioSessionForPictureInPicture() { + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default, options: [.allowBluetooth, .allowBluetoothA2DP, .mixWithOthers]) + try audioSession.setActive(true) + } catch { + NSLog("AppDelegate: 配置音频会话失败: %@", error.localizedDescription) + } + } } // 蓝牙事件流处理器 diff --git a/ios/Runner/AudioSessionManager.swift b/ios/Runner/AudioSessionManager.swift index 75882210b..7c77c62a2 100644 --- a/ios/Runner/AudioSessionManager.swift +++ b/ios/Runner/AudioSessionManager.swift @@ -33,18 +33,20 @@ import UIKit 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() + // do { + // try configureSession( + // category: .playAndRecord, + // mode: .spokenAudio, + // options: [ + // // .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 + // .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 + // ], + // force: true // 强制重新配置,确保设置生效 + // ) + // NSLog("[AudioSessionManager] 初始化音频会话配置成功") + // } catch { + // NSLog("[AudioSessionManager] 初始化音频会话配置失败: \(error.localizedDescription)") + // } } deinit { @@ -167,7 +169,13 @@ import UIKit @objc private func handleAppDidEnterBackground() { NSLog("[AudioSessionManager] 应用进入后台") + do { + try audioSession.setActive(true) + } catch { + print("后台激活 AVAudioSession 失败: \(error.localizedDescription)") + } // 应用进入后台时可能需要调整音频会话 + } @objc private func handleAppWillEnterForeground() { @@ -463,144 +471,12 @@ import UIKit // 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 { + @objc func configureForVoiceInteraction(force: Bool = false) -> Bool { do { // 配置音频会话以支持语音交互 // 使用playAndRecord类别允许同时录音和播放 @@ -611,14 +487,17 @@ import UIKit options: [ // .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 + .defaultToSpeaker, // 默认使用扬声器输出 + .duckOthers, // 降低其他应用的音量 + .interruptSpokenAudioAndMixWithOthers // 中断其他语音音频并混合 ], - force: false // 强制重新配置,确保设置生效 + force: force // 强制重新配置,确保设置生效 ) // 设置首选输入设备 // try configurePreferredInput() - NSLog("[AudioSessionManager] 已配置语音交互(ASR+TTS)的音频会话") + // NSLog("[AudioSessionManager] 已配置语音交互(ASR+TTS)的音频会话") return true } catch { NSLog("[AudioSessionManager] 配置语音交互(ASR+TTS)的音频会话失败: \(error.localizedDescription)") diff --git a/ios/Runner/AzureAsrHelper.swift b/ios/Runner/AzureAsrHelper.swift index 82deddfba..c41974d6e 100644 --- a/ios/Runner/AzureAsrHelper.swift +++ b/ios/Runner/AzureAsrHelper.swift @@ -297,8 +297,6 @@ class AzureAsrHelper: NSObject { // 尝试启动连续识别 do { - - // 启动连续识别 try recognizer?.startContinuousRecognition() _isContinuousRecognitionActive = true @@ -338,30 +336,37 @@ class AzureAsrHelper: NSObject { // 先标记为非活跃状态,防止重复调用 _isContinuousRecognitionActive = false - - // 异步执行停止操作,避免阻塞主线程 - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } + + do { + try recognizer.stopContinuousRecognition() - do { - // 在后台线程执行停止操作 - try recognizer.stopContinuousRecognition() - - // 主线程回调通知结果 - DispatchQueue.main.async { - print("[AzureAsrHelper] 连续识别已停止") - self.eventHandler("sessionStopped", [:]) - } - } catch { - // 主线程回调通知错误 - DispatchQueue.main.async { - print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") - // 停止失败,恢复状态 - self._isContinuousRecognitionActive = true - self.eventHandler("error", ["error": "停止识别失败: \(error.localizedDescription)"]) - } - } + } catch { + print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") } + + // // 异步执行停止操作,避免阻塞主线程 + // DispatchQueue.global(qos: .userInitiated).async { [weak self] in + // guard let self = self else { return } + + // do { + // // 在后台线程执行停止操作 + // try recognizer.stopContinuousRecognition() + + // // 主线程回调通知结果 + // DispatchQueue.main.async { + // print("[AzureAsrHelper] 连续识别已停止") + // self.eventHandler("sessionStopped", [:]) + // } + // } catch { + // // 主线程回调通知错误 + // DispatchQueue.main.async { + // print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") + // // 停止失败,恢复状态 + // self._isContinuousRecognitionActive = true + // self.eventHandler("error", ["error": "停止识别失败: \(error.localizedDescription)"]) + // } + // } + // } // 立即返回,不阻塞调用方 return true diff --git a/ios/Runner/AzureTtsHelper.swift b/ios/Runner/AzureTtsHelper.swift index a212f516f..f4fd0478c 100644 --- a/ios/Runner/AzureTtsHelper.swift +++ b/ios/Runner/AzureTtsHelper.swift @@ -230,10 +230,10 @@ class AzureTtsHelper: NSObject { _isSpeaking = true eventHandler("started", [:]) - Task { + // Task { do { // 使用异步方法进行合成并直接播放 - _ = try await synthesizer.startSpeakingSsml(text) + _ = try synthesizer.startSpeakingSsml(text) } catch { print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") @@ -242,7 +242,7 @@ class AzureTtsHelper: NSObject { self.eventHandler("error", ["error": "语音合成失败: \(error.localizedDescription)"]) } } - } + // } return true } diff --git a/ios/Runner/BluetoothMediaButtonHelper.swift b/ios/Runner/BluetoothMediaButtonHelper.swift index bcbc6b042..3d203b74d 100644 --- a/ios/Runner/BluetoothMediaButtonHelper.swift +++ b/ios/Runner/BluetoothMediaButtonHelper.swift @@ -92,13 +92,13 @@ class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { // 尝试启用静音音频播放(但即使失败,远程命令仍然可用) do { // try audioSessionManager.configureForBluetoothMediaButtons() - // startSilentAudio() + startSilentAudio() - //延时1秒 - // DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - // self.setupNowPlaying(isPlaying: false) - // self.stopSilentAudio() - // } + // 延时1秒 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.setupNowPlaying(isPlaying: false) + self.stopSilentAudio() + } // startUpdatingPlaybackInfo() } catch { diff --git a/ios/Runner/DialogPipView.swift b/ios/Runner/DialogPipView.swift new file mode 100644 index 000000000..f4e17f81b --- /dev/null +++ b/ios/Runner/DialogPipView.swift @@ -0,0 +1,291 @@ +import UIKit +import AVFoundation + +/** + * 带内边距的标签 + */ +class PaddingLabel: UILabel { + var padding: UIEdgeInsets + + init(padding: UIEdgeInsets) { + self.padding = padding + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + self.padding = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + super.init(coder: coder) + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: padding)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + padding.left + padding.right, + height: size.height + padding.top + padding.bottom) + } +} + +/** + * 对话画中画视图 + * + * 用于在画中画模式下显示用户和AI的对话内容 + */ +@available(iOS 13.0, *) +class DialogPipView: UIView { + + // 视频播放器图层(必须有,否则画中画无法工作) + private var playerLayer: AVPlayerLayer? + private var player: AVPlayer? + + // 对话显示视图 + private let dialogView = UIView() + + // 用户消息标签 + private let userMessageLabel: PaddingLabel = { + let label = PaddingLabel(padding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)) + label.font = UIFont.systemFont(ofSize: 14) + label.textColor = UIColor.white + label.numberOfLines = 0 + label.backgroundColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 0.7) + label.layer.cornerRadius = 8 + label.layer.masksToBounds = true + label.textAlignment = .left + label.isHidden = true + return label + }() + + // AI助手消息标签 + private let assistantMessageLabel: PaddingLabel = { + let label = PaddingLabel(padding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)) + label.font = UIFont.systemFont(ofSize: 14) + label.textColor = UIColor.white + label.numberOfLines = 0 + label.backgroundColor = UIColor(red: 0.0, green: 0.4, blue: 0.8, alpha: 0.7) + label.layer.cornerRadius = 8 + label.layer.masksToBounds = true + label.textAlignment = .left + label.isHidden = true + return label + }() + + // 初始化 + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + // 设置视图 + private func setupView() { + backgroundColor = UIColor.black + + // 添加对话视图 + addSubview(dialogView) + dialogView.frame = bounds + dialogView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + // 添加用户消息标签 + dialogView.addSubview(userMessageLabel) + userMessageLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + userMessageLabel.leadingAnchor.constraint(equalTo: dialogView.leadingAnchor, constant: 8), + userMessageLabel.trailingAnchor.constraint(equalTo: dialogView.trailingAnchor, constant: -8), + userMessageLabel.topAnchor.constraint(equalTo: dialogView.topAnchor, constant: 10), + ]) + + // 添加AI助手消息标签 + dialogView.addSubview(assistantMessageLabel) + assistantMessageLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + assistantMessageLabel.leadingAnchor.constraint(equalTo: dialogView.leadingAnchor, constant: 8), + assistantMessageLabel.trailingAnchor.constraint(equalTo: dialogView.trailingAnchor, constant: -8), + assistantMessageLabel.topAnchor.constraint(equalTo: userMessageLabel.bottomAnchor, constant: 10), + assistantMessageLabel.bottomAnchor.constraint(lessThanOrEqualTo: dialogView.bottomAnchor, constant: -10) + ]) + } + + // 创建并返回视频图层 + func setupDialogLayer() -> AVPlayerLayer { + // 创建无声视频,用于支持画中画功能 + let playerItem = createSilentVideoPlayerItem() + player = AVPlayer(playerItem: playerItem) + player?.actionAtItemEnd = .none + player?.play() + + // 创建视频图层 + let layer = AVPlayerLayer(player: player) + layer.frame = bounds + layer.videoGravity = .resizeAspectFill + + // 将视频图层放在最底层 + self.layer.insertSublayer(layer, at: 0) + playerLayer = layer + + // 注册通知,确保视频循环播放 + NotificationCenter.default.addObserver(self, + selector: #selector(playerItemDidReachEnd), + name: .AVPlayerItemDidPlayToEndTime, + object: player?.currentItem) + + return layer + } + + // 创建无声视频用于支持画中画 + private func createSilentVideoPlayerItem() -> AVPlayerItem { + // 创建一个简单的黑色视频,足够支持画中画但不会占用太多资源 + let composition = AVMutableComposition() + + // 添加视频轨道 + let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) + + // 创建一个简单的黑色视频,持续10秒 + let videoSize = CGSize(width: 640, height: 480) + let duration = CMTime(seconds: 10, preferredTimescale: 600) + + // 创建一个黑色图像 + UIGraphicsBeginImageContextWithOptions(videoSize, true, 0) + UIColor.black.setFill() + UIRectFill(CGRect(origin: .zero, size: videoSize)) + let blackImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + // 创建黑色图像的视频 + let settings = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: videoSize.width, + AVVideoHeightKey: videoSize.height + ] as [String : Any] + + let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + let adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: writerInput, sourcePixelBufferAttributes: nil) + + if let pixelBuffer = createPixelBuffer(fromImage: blackImage!, size: videoSize) { + let attributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB, + kCVPixelBufferWidthKey as String: videoSize.width, + kCVPixelBufferHeightKey as String: videoSize.height + ] + + // 创建一个5秒的黑色视频 + let assetWriter = try! AVAssetWriter(outputURL: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("temp.mov"), fileType: .mov) + assetWriter.add(writerInput) + assetWriter.startWriting() + assetWriter.startSession(atSourceTime: .zero) + + let frameCount = 50 // 5秒,10fps + let frameDuration = CMTime(value: 1, timescale: 10) + + writerInput.requestMediaDataWhenReady(on: DispatchQueue.global()) { + for i in 0.. CVPixelBuffer? { + var pixelBuffer: CVPixelBuffer? + let attrs = [ + kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue, + kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue + ] as CFDictionary + + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + Int(size.width), + Int(size.height), + kCVPixelFormatType_32ARGB, + attrs, + &pixelBuffer + ) + + guard status == kCVReturnSuccess, let buffer = pixelBuffer else { + return nil + } + + CVPixelBufferLockBaseAddress(buffer, []) + let context = CGContext( + data: CVPixelBufferGetBaseAddress(buffer), + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(buffer), + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + ) + + context?.draw(image.cgImage!, in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) + CVPixelBufferUnlockBaseAddress(buffer, []) + + return buffer + } + + // 视频播放结束处理 + @objc private func playerItemDidReachEnd(notification: Notification) { + // 循环播放视频 + player?.seek(to: .zero) + player?.play() + } + + // 更新对话内容 + func updateDialogContent(userMessage: String, assistantMessage: String) { + // 设置用户消息 + if !userMessage.isEmpty { + userMessageLabel.text = "用户: \(userMessage)" + userMessageLabel.isHidden = false + } else { + userMessageLabel.isHidden = true + } + + // 设置AI助手消息 + if !assistantMessage.isEmpty { + assistantMessageLabel.text = "AI助手: \(assistantMessage)" + assistantMessageLabel.isHidden = false + } else { + assistantMessageLabel.isHidden = true + } + + // 更新布局 + setNeedsLayout() + } + + // 布局子视图 + override func layoutSubviews() { + super.layoutSubviews() + + // 调整视频图层大小 + playerLayer?.frame = bounds + } + + // 释放资源 + deinit { + NotificationCenter.default.removeObserver(self) + player?.pause() + player = nil + } +} \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5c16566bd..e55e60462 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -39,7 +39,7 @@ UIBackgroundModes audio - bluetooth-central + voip UILaunchStoryboardName LaunchScreen diff --git a/ios/Runner/VoiceInteractionService.swift b/ios/Runner/VoiceInteractionService.swift index 592ddcfe3..a8c25fc9e 100644 --- a/ios/Runner/VoiceInteractionService.swift +++ b/ios/Runner/VoiceInteractionService.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import AVFoundation +import AVKit /** * 语音交互服务,iOS原生实现 @@ -10,6 +11,7 @@ import AVFoundation * 2) 处理语音识别 * 3) 与火山AI服务交互 * 4) 文本转语音播放 + * 5) 画中画显示对话 */ @available(iOS 13.0, *) class VoiceInteractionService: NSObject { @@ -28,6 +30,14 @@ class VoiceInteractionService: NSObject { private var hasSpeechDetected = false private var isTtsSpeaking = false + // 画中画相关属性 + private var pipController: AVPictureInPictureController? + private var pipPossible = false + private var isPipActive = false + private var pipContentView: DialogPipView? + private var lastUserMessage = "" + private var lastAssistantMessage = "" + // 事件处理器 private weak var eventHandler: VoiceInteractionEventHandler? @@ -60,10 +70,23 @@ class VoiceInteractionService: NSObject { // 初始化方法 private override init() { - - super.init() NSLog("%@: VoiceInteractionService 初始化中", TAG) + + // 注册应用生命周期通知 + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) } /** @@ -88,6 +111,9 @@ class VoiceInteractionService: NSObject { // 初始化各个服务组件 initServices(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) + // 初始化画中画组件 + setupPictureInPicture() + // 播放静音音频,确保音频会话活跃 // startSilencePlayback() @@ -125,6 +151,9 @@ class VoiceInteractionService: NSObject { // 停止TTS stopCurrentTTS() + // 停止画中画 + stopPictureInPicture() + // 释放服务实例 azureAsrHelper?.dispose() azureTtsHelper?.dispose() @@ -152,6 +181,9 @@ class VoiceInteractionService: NSObject { isTimeoutPaused = false hasSpeechDetected = false isTtsSpeaking = false + isPipActive = false + lastUserMessage = "" + lastAssistantMessage = "" } /** @@ -249,6 +281,11 @@ class VoiceInteractionService: NSObject { case "result": if let text = eventData["text"] as? String, !text.isEmpty { updateLastActivityTime() + + // 保存用户消息 + lastUserMessage = text + // 更新画中画内容 + updatePictureInPictureContent() processWithVolcanoAI(text) } @@ -317,6 +354,12 @@ class VoiceInteractionService: NSObject { // 停止当前TTS播放 stopCurrentTTS() + // 检查应用是否在后台 + if UIApplication.shared.applicationState == .background { + NSLog("%@: 应用在后台,无法开始语音识别", TAG) + playNotification("请打开应用后重试!") + return + } // 播放提示音 playPrompt("我在!") @@ -326,6 +369,7 @@ class VoiceInteractionService: NSObject { // 启动或重置语音识别 if !isRecognitionActive { NSLog("%@: 语音识别未激活,开始启动", TAG) + startVoiceRecognition() } else { NSLog("%@: 语音识别已激活,更新活动时间", TAG) @@ -340,6 +384,8 @@ class VoiceInteractionService: NSObject { private func startVoiceRecognition() { if isRecognitionActive { return } + + // 通知Flutter语音识别已启动 notifyVoiceRecognitionStarted() @@ -402,6 +448,12 @@ class VoiceInteractionService: NSObject { // 发送请求 let response = try volcanoAIService.sendMessage(messages: messages, systemPrompt: self.systemPrompt) + // 保存助手回复 + self.lastAssistantMessage = response + + // 更新画中画内容 + self.updatePictureInPictureContent() + // 播放AI回复 self.speakAIResponse(response) @@ -511,4 +563,142 @@ class VoiceInteractionService: NSObject { // 使用事件处理器发送事件 eventHandler?.sendEvent(eventData) } + + /** + * 初始化画中画功能 + */ + private func setupPictureInPicture() { + NSLog("%@: 初始化画中画功能", TAG) + + // 创建用于画中画的视图 + let pipContentView = DialogPipView(frame: CGRect(x: 0, y: 0, width: 400, height: 300)) + self.pipContentView = pipContentView + + // 创建视频层 + let playerLayer = pipContentView.setupDialogLayer() + + // 检查画中画是否支持 + if AVPictureInPictureController.isPictureInPictureSupported() { + pipController = AVPictureInPictureController(playerLayer: playerLayer) + pipController?.delegate = self + pipPossible = true + NSLog("%@: 画中画控制器初始化完成", TAG) + + // 允许自动进入画中画模式 + if #available(iOS 14.2, *) { + pipController?.canStartPictureInPictureAutomaticallyFromInline = true + } + } else { + pipPossible = false + NSLog("%@: 当前设备不支持画中画功能", TAG) + } + } + + /** + * 更新画中画内容 + */ + private func updatePictureInPictureContent() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // 更新画中画视图内容 + self.pipContentView?.updateDialogContent( + userMessage: self.lastUserMessage, + assistantMessage: self.lastAssistantMessage + ) + } + } + + /** + * 开始画中画模式 + */ + func startPictureInPicture() { + NSLog("%@: 开始画中画pip", TAG) + guard pipPossible, let pipController = pipController, !isPipActive else { + NSLog("%@: 画中画功能无法启动或已处于活动状态", TAG) + return + } + + // 确保有对话内容才启动画中画 + if !lastUserMessage.isEmpty || !lastAssistantMessage.isEmpty { + pipController.startPictureInPicture() + } else { + NSLog("%@: 无对话内容,不启动画中画", TAG) + } + } + + /** + * 停止画中画模式 + */ + func stopPictureInPicture() { + guard pipPossible, let pipController = pipController, isPipActive else { + return + } + + pipController.stopPictureInPicture() + } + + /** + * 应用进入后台处理 + */ + @objc private func handleAppDidEnterBackground() { + NSLog("%@: 应用进入后台pip", TAG) + + // 如果有对话内容,自动开启画中画 + if !lastUserMessage.isEmpty || !lastAssistantMessage.isEmpty { + // 稍等片刻再启动画中画,确保转场动画完成 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.startPictureInPicture() + } + } + } + + /** + * 应用即将进入前台处理 + */ + @objc private func handleAppWillEnterForeground() { + NSLog("%@: 应用即将进入前台pip", TAG) + + // 停止画中画 + if isPipActive { + stopPictureInPicture() + } + } +} + +// MARK: - AVPictureInPictureControllerDelegate +@available(iOS 13.0, *) +extension VoiceInteractionService: AVPictureInPictureControllerDelegate { + + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + NSLog("%@: 画中画即将启动", TAG) + } + + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + isPipActive = true + NSLog("%@: 画中画已启动", TAG) + sendEvent(type: "pip_started", data: [:]) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + isPipActive = false + NSLog("%@: 画中画启动失败: %@", TAG, error.localizedDescription) + sendEvent(type: "pip_failed", data: ["error": error.localizedDescription]) + } + + func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + NSLog("%@: 画中画即将停止", TAG) + } + + func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + isPipActive = false + NSLog("%@: 画中画已停止", TAG) + sendEvent(type: "pip_stopped", data: [:]) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { + NSLog("%@: 恢复画中画用户界面", TAG) + // 恢复界面完成后调用completionHandler + completionHandler(true) + } } \ No newline at end of file diff --git a/lib/data/services/classic_bluetooth_service.dart b/lib/data/services/classic_bluetooth_service.dart index cd9608d39..d17c90211 100644 --- a/lib/data/services/classic_bluetooth_service.dart +++ b/lib/data/services/classic_bluetooth_service.dart @@ -61,9 +61,6 @@ class ClassicBluetoothService extends GetxService { bluetoothState.value = isEnabled ? 'on' : 'off'; Logger.info('初始蓝牙状态: ${bluetoothState.value}'); - // 启动蓝牙设备监控 - 这将调用原生代码中的initBluetooth - await _channel.invokeMethod('startBluetoothDeviceMonitoring'); - // 设置事件监听 _setupBluetoothStateListener(); @@ -83,6 +80,7 @@ class ClassicBluetoothService extends GetxService { Permission.bluetooth, Permission.bluetoothConnect, Permission.bluetoothScan, + Permission.microphone, ].request(); return statuses[Permission.bluetoothConnect] == PermissionStatus.granted; diff --git a/lib/data/services/speech_impl/azure_tts_service.dart b/lib/data/services/speech_impl/azure_tts_service.dart index 6afb3fd62..4a4069356 100644 --- a/lib/data/services/speech_impl/azure_tts_service.dart +++ b/lib/data/services/speech_impl/azure_tts_service.dart @@ -110,19 +110,10 @@ class AzureTtsService extends GetxService implements TtsService { 'language': language, }); - _isInitialized = result; - return result; - } else { - // 使用默认语言初始化 - final result = await _channel.invokeMethod('initialize', { - 'subscriptionKey': _subscriptionKey, - 'region': _serviceRegion, - 'language': 'zh-CN', - }); - _isInitialized = result; return result; } + return true; } catch (e) { Logger.error('初始化失败: $e'); return false; diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 1ca509528..a1d26d509 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -193,9 +193,9 @@ class VoiceInteractionService extends GetxService { // 使用已加载的配置信息 final result = await _channel.invokeMethod('startService', { - 'azureSpeechKey': _azureSpeechKey, - 'azureSpeechRegion': _azureSpeechRegion, - 'volcanoAiKey': _volcanoAiKey, + 'azure_speech_key': _azureSpeechKey, + 'azure_speech_region': _azureSpeechRegion, + 'volcano_ai_api_key': _volcanoAiKey, }) ?? false; if (result) { @@ -248,7 +248,7 @@ class VoiceInteractionService extends GetxService { /// 暂停语音交互 Future pauseVoiceInteraction() async { try { - Logger.info('暂停语音交互...'); + Logger.info('暂停后台语音交互...'); final result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 39ac07d55..29a4ab851 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -4,7 +4,6 @@ import '../../../routes/app_routes.dart'; 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'; @@ -44,11 +43,7 @@ class PairingController extends GetxController { // 错误信息 final errorMessage = ''.obs; - // 连接设备列表(兼容旧代码) - List get connectedClassicDevices => - _bluetoothService.currentDevice.value != null - ? [_bluetoothService.currentDevice.value!] - : []; + // 状态描述(用于UI显示) final statusDescription = '正在初始化...'.obs; @@ -193,7 +188,7 @@ class PairingController extends GetxController { void openBluetoothSettings() { try { // 使用app_settings插件打开蓝牙设置 - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + // AppSettings.openAppSettings(type: AppSettingsType.bluetooth); } catch (e) { Logger.error('打开蓝牙设置失败: $e'); } diff --git a/lib/modules/pairing/views/pairing_view.dart b/lib/modules/pairing/views/pairing_view.dart index 40de49a0c..c3866a3b9 100644 --- a/lib/modules/pairing/views/pairing_view.dart +++ b/lib/modules/pairing/views/pairing_view.dart @@ -199,7 +199,7 @@ class PairingView extends GetView { ), // 设备列表(有设备时才显示) - if (controller.connectedClassicDevices.isNotEmpty) ...[ + if (controller.connectedDevices.isNotEmpty) ...[ SizedBox(height: 24.h), _buildConnectedDevicesList(isDarkMode), ], @@ -211,7 +211,7 @@ class PairingView extends GetView { // 已连接设备列表 Widget _buildConnectedDevicesList(bool isDarkMode) { return Obx(() { - final devices = controller.connectedClassicDevices; + final devices = controller.connectedDevices; if (devices.isEmpty) { return const SizedBox.shrink(); diff --git a/pubspec.yaml b/pubspec.yaml index c47019e21..32eb78d63 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,6 @@ dependencies: flutter_markdown: ^0.6.18+2 markdown: ^7.1.1 permission_handler: ^11.4.0 - audio_service: ^0.18.12 web_socket_channel: ^3.0.2 flutter_dotenv: ^5.1.0 synchronized: ^3.1.0 @@ -45,7 +44,6 @@ dependencies: audio_waveforms: ^1.3.0 font_awesome_flutter: ^10.8.0 lottie: ^3.3.1 - app_settings: 5.2.0 speech_to_text: ^7.0.0 dev_dependencies: