wolfplus 2 years ago
parent
commit
0ac43558d0
  1. 4
      android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt
  2. 37
      android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt
  3. 16
      android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt
  4. 2
      android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt
  5. 20
      ios/Podfile.lock
  6. 36
      ios/Runner.xcodeproj/project.pbxproj
  7. 20
      ios/Runner/AppDelegate.swift
  8. 167
      ios/Runner/AudioSessionManager.swift
  9. 43
      ios/Runner/AzureAsrHelper.swift
  10. 6
      ios/Runner/AzureTtsHelper.swift
  11. 12
      ios/Runner/BluetoothMediaButtonHelper.swift
  12. 291
      ios/Runner/DialogPipView.swift
  13. 2
      ios/Runner/Info.plist
  14. 194
      ios/Runner/VoiceInteractionService.swift
  15. 4
      lib/data/services/classic_bluetooth_service.dart
  16. 11
      lib/data/services/speech_impl/azure_tts_service.dart
  17. 8
      lib/data/services/voice_interaction_service.dart
  18. 9
      lib/modules/pairing/controllers/pairing_controller.dart
  19. 4
      lib/modules/pairing/views/pairing_view.dart
  20. 2
      pubspec.yaml

4
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

37
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<SpeechSynthesisResult> = 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 回调接口
*/

16
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" -> {

2
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()

20
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

36
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 = "<group>"; };
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 = "<group>"; };
0A18952A2D8F049C00675C94 /* DialogPipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogPipView.swift; sourceTree = "<group>"; };
0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicBluetoothHelper.swift; sourceTree = "<group>"; };
0A5EDC032D8B544100215E26 /* BluetoothMediaButtonHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMediaButtonHelper.swift; sourceTree = "<group>"; };
0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceInteractionService.swift; sourceTree = "<group>"; };
@ -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;

20
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)
}
}
}
// 蓝牙事件流处理器

167
ios/Runner/AudioSessionManager.swift

@ -33,18 +33,20 @@ import UIKit
NSLog("[AudioSessionManager] 音频会话管理器已初始化")
setupNotifications()
do {
// do {
// try configureSession(
// category: .playAndRecord,
// mode: .default,
// options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers, .defaultToSpeaker],
// force: true
// mode: .spokenAudio,
// options: [
// // .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出
// .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出
// ],
// force: true // 强制重新配置,确保设置生效
// )
NSLog("[AudioSessionManager] 初始化音频会话配置成功")
} catch {
NSLog("[AudioSessionManager] 初始化音频会话配置失败: \(error.localizedDescription)")
}
// BluetoothMediaButtonHelper.shared.startButtonListening()
// 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)")

43
ios/Runner/AzureAsrHelper.swift

@ -297,8 +297,6 @@ class AzureAsrHelper: NSObject {
// 尝试启动连续识别
do {
// 启动连续识别
try recognizer?.startContinuousRecognition()
_isContinuousRecognitionActive = true
@ -339,30 +337,37 @@ class AzureAsrHelper: NSObject {
// 先标记为非活跃状态,防止重复调用
_isContinuousRecognitionActive = false
// 异步执行停止操作,避免阻塞主线程
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)"])
}
}
}
// // 异步执行停止操作,避免阻塞主线程
// 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
}

6
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
}

12
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 {

291
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..<frameCount {
while !writerInput.isReadyForMoreMediaData {}
let presentationTime = CMTime(value: CMTimeValue(i), timescale: 10)
adaptor.append(pixelBuffer, withPresentationTime: presentationTime)
}
writerInput.markAsFinished()
assetWriter.finishWriting {}
}
// 等待视频写入完成
while assetWriter.status == .writing {}
// 创建AVPlayerItem
let asset = AVAsset(url: assetWriter.outputURL)
return AVPlayerItem(asset: asset)
}
// 如果上面的方法失败,创建一个简单的默认项
let emptyItem = AVPlayerItem(url: URL(fileURLWithPath: ""))
return emptyItem
}
// 从UIImage创建像素缓冲区
private func createPixelBuffer(fromImage image: UIImage, size: CGSize) -> 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
}
}

2
ios/Runner/Info.plist

@ -39,7 +39,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>bluetooth-central</string>
<string>voip</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

194
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 = ""
}
/**
@ -250,6 +282,11 @@ class VoiceInteractionService: NSObject {
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)
}
}

4
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;

11
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;

8
lib/data/services/voice_interaction_service.dart

@ -193,9 +193,9 @@ class VoiceInteractionService extends GetxService {
// 使用已加载的配置信息
final result = await _channel.invokeMethod<bool>('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<bool> pauseVoiceInteraction() async {
try {
Logger.info('暂停语音交互...');
Logger.info('暂停后台语音交互...');
final result = await _channel.invokeMethod<bool>('pauseVoiceInteraction') ?? false;

9
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<BluetoothDevice> 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');
}

4
lib/modules/pairing/views/pairing_view.dart

@ -199,7 +199,7 @@ class PairingView extends GetView<PairingController> {
),
// 设备列表(有设备时才显示)
if (controller.connectedClassicDevices.isNotEmpty) ...[
if (controller.connectedDevices.isNotEmpty) ...[
SizedBox(height: 24.h),
_buildConnectedDevicesList(isDarkMode),
],
@ -211,7 +211,7 @@ class PairingView extends GetView<PairingController> {
// 已连接设备列表
Widget _buildConnectedDevicesList(bool isDarkMode) {
return Obx(() {
final devices = controller.connectedClassicDevices;
final devices = controller.connectedDevices;
if (devices.isEmpty) {
return const SizedBox.shrink();

2
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:

Loading…
Cancel
Save