wolfplus 1 year ago
parent
commit
fb5df39fba
  1. 42
      .cursorrules
  2. 4
      android/app/build.gradle.kts
  3. 7
      android/app/src/debug/AndroidManifest.xml
  4. 31
      android/app/src/main/AndroidManifest.xml
  5. 45
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleBroadcastReceiver.kt
  6. 335
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleWakeService.kt
  7. 242
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt
  8. 808
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt
  9. 444
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt
  10. 670
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt
  11. 144
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/utils.kt
  12. 6
      android/settings.gradle.kts
  13. 3
      lib/data/services/speech_factory.dart
  14. 72
      lib/data/services/speech_impl/azure_asr_service.dart
  15. 397
      lib/data/services/speech_impl/azure_tts_service.dart
  16. 350
      lib/data/services/speech_impl/volcano_asr_service.dart
  17. 285
      lib/data/services/speech_impl/volcano_tts_service.dart
  18. 11
      lib/modules/ble_test/bindings/ble_test_binding.dart
  19. 433
      lib/modules/ble_test/controllers/ble_test_controller.dart
  20. 279
      lib/modules/ble_test/views/ble_test_view.dart
  21. 6
      lib/modules/pairing/controllers/pairing_controller.dart
  22. 45
      lib/modules/settings/views/settings_view.dart
  23. 12
      lib/modules/speech_test/bindings/speech_test_binding.dart
  24. 416
      lib/modules/speech_test/controllers/speech_test_controller.dart
  25. 349
      lib/modules/speech_test/views/speech_test_view.dart
  26. 14
      lib/routes/app_pages.dart
  27. 2
      lib/routes/app_routes.dart
  28. 42
      local_plugins/agent_service/android/build.gradle.kts
  29. 13
      local_plugins/agent_service/android/settings.gradle.kts
  30. 5
      local_plugins/agent_service/android/src/main/AndroidManifest.xml
  31. 577
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  32. 212
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  33. 34
      local_plugins/agent_service/pubspec.yaml
  34. 752
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  35. 338
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  36. 489
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt
  37. 345
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt
  38. 35
      local_plugins/ble_service/android/build.gradle.kts
  39. 38
      local_plugins/ble_service/android/src/main/AndroidManifest.xml
  40. 89
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt
  41. 311
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt
  42. 234
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt
  43. 905
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  44. 425
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  45. 34
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt
  46. 19
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt
  47. 356
      local_plugins/ble_service/lib/ble_service.dart
  48. 24
      local_plugins/ble_service/pubspec.yaml
  49. 175
      local_plugins/bytedance_speech/README.md
  50. 36
      local_plugins/bytedance_speech/android/build.gradle.kts
  51. 1
      local_plugins/bytedance_speech/android/settings.gradle.kts
  52. 8
      local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml
  53. 750
      local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt
  54. 331
      local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt
  55. 938
      local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt
  56. 26
      local_plugins/bytedance_speech/pubspec.yaml
  57. 29
      local_plugins/chat_storage/android/build.gradle.kts
  58. 2
      local_plugins/chat_storage/android/settings.gradle.kts
  59. 367
      local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt
  60. 216
      local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt
  61. 8
      local_plugins/chat_storage/pubspec.yaml
  62. 40
      local_plugins/classic_bluetooth/android/build.gradle.kts
  63. 1
      local_plugins/classic_bluetooth/android/settings.gradle.kts
  64. 17
      local_plugins/classic_bluetooth/android/src/main/AndroidManifest.xml
  65. 266
      local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt
  66. 186
      local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt
  67. 119
      local_plugins/classic_bluetooth/lib/classic_bluetooth.dart
  68. 23
      local_plugins/classic_bluetooth/pubspec.yaml
  69. 46
      local_plugins/deepvoice_utils/android/build.gradle.kts
  70. 1
      local_plugins/deepvoice_utils/android/settings.gradle.kts
  71. 7
      local_plugins/deepvoice_utils/android/src/main/AndroidManifest.xml
  72. 33
      local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/DeepvoiceUtilsPlugin.kt
  73. 2
      local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/FileLogger.kt
  74. 3
      local_plugins/deepvoice_utils/lib/deepvoice_utils.dart
  75. 31
      local_plugins/deepvoice_utils/lib/src/file_logger.dart
  76. 27
      local_plugins/deepvoice_utils/pubspec.yaml
  77. 10
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt
  78. 4
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt
  79. BIN
      local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.bin
  80. BIN
      local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock
  81. BIN
      local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  82. 4
      local_plugins/opus/.gradle/buildOutputCleanup/cache.properties
  83. 863
      local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt
  84. 3
      local_plugins/volcano_speech/android/build.gradle.kts
  85. 559
      local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt
  86. 310
      local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt
  87. 647
      local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt
  88. 603
      local_plugins/volcano_speech/lib/volcano_speech.dart
  89. 10
      pubspec.yaml
  90. 64
      test.json

42
.cursorrules

@ -1,14 +1,34 @@
1. 请严格遵循 Effective Dart 的风格指南来编写代码 # 代码简明规范
2. 不要主动运行程序, 如flutter run ## Dart / Flutter
3. 不要改动android, iOS项目的配置信息,包括SDK, NDK, JAVA版本等 1. 风格统一
4. 不要使用print, 而是使用logger, 尽可能避免打印日志,仅在出错时记录日志 • 严格遵循 Effective Dart 格式与命名惯例。
5. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget 2. 日志与调试
6. 不要增加重试逻辑,出现失败直接返回错误 • 禁止 print;仅在异常场景调用 logger.e(...)。
7. 尽可能避免使用try catch 3. 业务逻辑
8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 • 默认使用 GetX(状态、路由、依赖注入等),避免手写 StatefulWidget。
9. 请尽可能使用中文回复问题 4. 异常处理
10. 重要:不要过度设计!! • 不写重试逻辑,失败即返回错误。
11. 本项目使用 Kotlin DSL 进行开发 • 谨慎使用 try-catch;优先通过类型系统与空安全防御。
5. 代码演进
• 任何改动都应 “小步、安全”,杜绝一次性大改或过度设计。
## 原生层(Android / iOS)
1. 配置不可改
• 禁止修改 SDK、NDK、JDK 与 iOS 构建配置。
2. Kotlin DSL
• 原生实现一律采用 Kotlin + 协程,遵守 Kotlin 最佳实践。
• 不写 Java 代码。
## 通用要求
1. 不自动执行
• 生成脚本/代码不得包含 flutter run 等执行指令。
2. 并发优先级
• 并发场景首选 Kotlin 协程;仅在 Flutter 端按需使用 Dart Future / Stream。
3. 语言
• 与用户交流、注释及文档一律使用中文。
宗旨:保持极简,先跑通业务,再考虑优化。
# 项目架构概述 # 项目架构概述
DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。

4
android/app/build.gradle.kts

@ -67,7 +67,7 @@ android {
applicationId = "com.yunqiinnovation.deepsound" applicationId = "com.yunqiinnovation.deepsound"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24 // Updated to meet azure_speech_recognition_null_safety requirements minSdk = 26 // 更新为26以满足ble_service库的要求
targetSdk = 34 // 设置为 Android 13 (API 33),以匹配 FOREGROUND_SERVICE_MEDIA_PLAYBACK 权限的要求 targetSdk = 34 // 设置为 Android 13 (API 33),以匹配 FOREGROUND_SERVICE_MEDIA_PLAYBACK 权限的要求
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@ -126,10 +126,10 @@ dependencies {
// 添加本地插件模块依赖 // 添加本地插件模块依赖
implementation(project(":azure_speech")) implementation(project(":azure_speech"))
implementation(project(":open_ai_service")) implementation(project(":open_ai_service"))
implementation(project(":volcano_speech"))
implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件 implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件
implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件 implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件
implementation(project(":spotify_sdk")) implementation(project(":spotify_sdk"))
implementation(project(":deepvoice_utils"))
// 添加OkHttp依赖 // 添加OkHttp依赖
implementation("com.squareup.okhttp3:okhttp:4.9.3") implementation("com.squareup.okhttp3:okhttp:4.9.3")

7
android/app/src/debug/AndroidManifest.xml

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

31
android/app/src/main/AndroidManifest.xml

@ -7,12 +7,15 @@
<!-- Android 13+ 前台服务特定类型权限 --> <!-- Android 13+ 前台服务特定类型权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<!-- Android 14+ 蓝牙设备连接前台服务权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/> <uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- Android 13+ 通知权限 --> <!-- Android 13+ 通知权限 -->
@ -75,6 +78,8 @@
<!-- <action android:name="android.media.browse.MediaBrowserService" /> --> <!-- <action android:name="android.media.browse.MediaBrowserService" /> -->
</intent-filter> </intent-filter>
</service> </service>
<!-- 添加媒体按钮接收器 --> <!-- 添加媒体按钮接收器 -->
<receiver <receiver
@ -86,32 +91,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- 添加BLE唤醒服务 -->
<service
android:name=".BleWakeService"
android:foregroundServiceType="location"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.bluetooth.adapter.action.REQUEST_ENABLE" />
</intent-filter>
</service>
<!-- 添加蓝牙广播接收器 -->
<receiver
android:name=".BleBroadcastReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="com.yunqiinnovation.deepsound.ACTION_BLE_SCAN_RESULT" />
<action android:name="android.bluetooth.adapter.action.STATE_CHANGED" />
<action android:name="android.bluetooth.adapter.action.DISCOVERY_FINISHED" />
<action android:name="android.bluetooth.adapter.action.DISCOVERY_STARTED" />
</intent-filter>
</receiver>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

45
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleBroadcastReceiver.kt

@ -1,45 +0,0 @@
package com.yunqiinnovation.deepsound
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.yunqiinnovation.deepsound.core.utils.FileLogger
/**
* 蓝牙BLE广播接收器
* 用于接收系统通过PendingIntent触发的蓝牙扫描结果
* 即使应用进程已被杀死,系统也能通过此接收器唤醒应用
*/
class BleBroadcastReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BleBroadcastReceiver"
// 蓝牙扫描结果Action
const val ACTION_BLE_SCAN_RESULT = "com.yunqiinnovation.deepsound.ACTION_BLE_SCAN_RESULT"
}
override fun onReceive(context: Context, intent: Intent) {
FileLogger.init(context)
//FileLogger.d(TAG, "接收到广播: ${intent.action}")
if (intent.action == ACTION_BLE_SCAN_RESULT) {
//FileLogger.d(TAG, "收到系统BLE扫描唤醒事件")
// 打印日志,表明唤醒成功
//FileLogger.d(TAG, "应用被蓝牙BLE广播唤醒成功")
// 启动BLE唤醒服务,该服务将执行后续操作
val serviceIntent = Intent(context, BleWakeService::class.java)
serviceIntent.action = "android.bluetooth.adapter.action.REQUEST_ENABLE"
// 对于Android 8.0及以上版本,必须使用startForegroundService启动前台服务
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
}
}
}

335
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleWakeService.kt

@ -1,335 +0,0 @@
package com.yunqiinnovation.deepsound
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.ParcelUuid
import androidx.core.app.NotificationCompat
import com.yunqiinnovation.deepsound.core.utils.FileLogger
import java.util.UUID
/**
* BLE唤醒服务,用于接收蓝牙BLE广播唤醒应用
* 可以作为前台服务运行,以提高可靠性
*/
class BleWakeService : Service() {
companion object {
private const val TAG = "BleWakeService"
private const val NOTIFICATION_ID = 1002
private const val CHANNEL_ID = "ble_wake_channel"
// 定义唤醒用的特定UUID,应与耳机约定一致
// 此UUID仅作示例,实际使用时应根据耳机厂商提供的UUID进行修改
val OLA_FRIEND_UUID = UUID.fromString("0000fd7b-0000-1000-8000-00805f9b34fb")
// 服务启动标志
private var isRunning = false
// 获取服务运行状态
fun isServiceRunning(): Boolean = isRunning
}
private val binder = LocalBinder()
private var bluetoothAdapter: BluetoothAdapter? = null
private var bluetoothLeScanner: BluetoothLeScanner? = null
private var isScanning = false
/**
* 本地绑定器
*/
inner class LocalBinder : Binder() {
fun getService(): BleWakeService = this@BleWakeService
}
override fun onCreate() {
super.onCreate()
FileLogger.d(TAG, "BLE唤醒服务正在创建")
// 初始化蓝牙
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothAdapter = bluetoothManager.adapter
bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
// 标记服务状态
isRunning = true
// 启动前台服务
startForeground(NOTIFICATION_ID, createNotification())
// 开始扫描
// startScanning()
FileLogger.d(TAG, "BLE唤醒服务已启动")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// FileLogger.d(TAG, "收到onStartCommand: intent=$intent")
// 如果是PendingIntent触发(BLE广播唤醒),打印日志
if (intent?.action == "android.bluetooth.adapter.action.REQUEST_ENABLE") {
//FileLogger.d(TAG, "收到BLE广播唤醒事件,应用被唤醒")
// 在这里可以执行唤醒后的动作,如启动语音交互服务
val serviceIntent = Intent(this, VoiceInteractionService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onDestroy() {
super.onDestroy()
FileLogger.d(TAG, "BLE唤醒服务即将销毁")
// 停止扫描
stopScanning()
// 标记服务状态
isRunning = false
}
/**
* 开始扫描BLE广播
*/
fun startScanning() {
FileLogger.d(TAG, "开始扫描BLE广播")
if (bluetoothLeScanner == null) {
FileLogger.e(TAG, "蓝牙扫描器不可用")
return
}
if (isScanning) {
FileLogger.d(TAG, "已在扫描中,忽略")
return
}
// 构建扫描过滤器,匹配特定的UUID
val scanFilter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(OLA_FRIEND_UUID))
.build()
// 设置扫描参数
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) // 低功耗模式
.build()
try {
FileLogger.d(TAG, "开始BLE扫描, 扫描过滤器: $OLA_FRIEND_UUID")
// 开始扫描
bluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, scanCallback)
isScanning = true
FileLogger.d(TAG, "BLE扫描已启动")
} catch (e: Exception) {
FileLogger.e(TAG, "开始BLE扫描失败: ${e.message}")
}
}
/**
* 停止扫描BLE广播
*/
fun stopScanning() {
FileLogger.d(TAG, "停止扫描BLE广播")
if (bluetoothLeScanner == null || !isScanning) {
FileLogger.d(TAG, "没有正在进行的扫描")
return
}
try {
// 停止扫描
bluetoothLeScanner?.stopScan(scanCallback)
isScanning = false
FileLogger.d(TAG, "BLE扫描已停止")
} catch (e: Exception) {
FileLogger.e(TAG, "停止BLE扫描失败: ${e.message}")
}
}
/**
* 扫描回调
*/
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
FileLogger.d(TAG, "收到扫描结果: ${result.device.address}")
// 检查是否是目标设备和唤醒广播
val serviceUuids = result.scanRecord?.serviceUuids
if (serviceUuids != null && serviceUuids.any { it.uuid == OLA_FRIEND_UUID }) {
FileLogger.d(TAG, "检测到Ola Friend耳机唤醒广播")
// 从广播包中获取额外数据
val manufacturerData = result.scanRecord?.getManufacturerSpecificData(0xFFFF)
if (manufacturerData != null && manufacturerData.size > 1) {
// 假设第一个字节为命令类型,如0x01表示语音唤醒
val commandType = manufacturerData[0].toInt() and 0xFF
FileLogger.d(TAG, "收到命令类型: $commandType")
if (commandType == 0x01) { // 语音唤醒命令
FileLogger.d(TAG, "耳机语音唤醒命令,启动语音交互")
// 启动语音交互服务
val serviceIntent = Intent(applicationContext, VoiceInteractionService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
}
}
}
}
override fun onScanFailed(errorCode: Int) {
FileLogger.e(TAG, "BLE扫描失败,错误代码: $errorCode")
isScanning = false
}
}
/**
* 创建通知
*/
private fun createNotification(): Notification {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Android 8.0+ 需要创建通知渠道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"蓝牙唤醒服务",
NotificationManager.IMPORTANCE_LOW
)
manager.createNotificationChannel(channel)
}
// 点击通知打开MainActivity
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("蓝牙唤醒服务")
.setContentText("正在监听耳机唤醒信号")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.build()
}
/**
* 为后台持久扫描注册PendingIntent
* 这种方式即使应用被销毁后仍然可被唤醒
*/
fun registerBackgroundScan() {
FileLogger.d(TAG, "注册后台持久扫描")
if (bluetoothLeScanner == null) {
FileLogger.e(TAG, "蓝牙扫描器不可用")
return
}
// 构建扫描过滤器,匹配特定的UUID
val scanFilter = ScanFilter.Builder()
// .setServiceUuid(ParcelUuid(OLA_FRIEND_UUID))
.build()
// 设置扫描参数
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) // 低功耗模式
.build()
// 创建PendingIntent
val intent = Intent(this, BleBroadcastReceiver::class.java)
intent.action = BleBroadcastReceiver.ACTION_BLE_SCAN_RESULT
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
} else {
PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
try {
// 启动带PendingIntent的扫描
bluetoothLeScanner?.startScan(
listOf(scanFilter),
scanSettings,
pendingIntent
)
FileLogger.d(TAG, "后台持久扫描已注册")
} catch (e: Exception) {
FileLogger.e(TAG, "注册后台持久扫描失败: ${e.message}")
}
}
/**
* 注销后台持久扫描
*/
fun unregisterBackgroundScan() {
FileLogger.d(TAG, "注销后台持久扫描")
if (bluetoothLeScanner == null) {
FileLogger.e(TAG, "蓝牙扫描器不可用")
return
}
// 创建与注册时相同的PendingIntent
val intent = Intent(this, BleBroadcastReceiver::class.java)
intent.action = BleBroadcastReceiver.ACTION_BLE_SCAN_RESULT
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
} else {
PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
try {
// 停止带PendingIntent的扫描
bluetoothLeScanner?.stopScan(pendingIntent)
FileLogger.d(TAG, "后台持久扫描已注销")
} catch (e: Exception) {
FileLogger.e(TAG, "注销后台持久扫描失败: ${e.message}")
}
}
}

242
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt

@ -1,242 +0,0 @@
package com.yunqiinnovation.deepsound
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import android.Manifest
import androidx.core.content.ContextCompat
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* 经典蓝牙辅助类,用于获取已连接的蓝牙设备信息
*/
class ClassicBluetoothHelper(private val context: Context) {
private val TAG = "ClassicBluetoothHelper"
// 缓存蓝牙代理对象
private var a2dpProxy: BluetoothProfile? = null
private var headsetProxy: BluetoothProfile? = null
/**
* 获取已连接的A2DP设备(音频输出设备)
* @param callback 回调接口,返回设备列表或错误信息
*/
fun getConnectedA2dpDevices(callback: BluetoothDevicesCallback) {
getConnectedBluetoothDevices(BluetoothProfile.A2DP, callback)
}
/**
* 获取已连接的蓝牙耳机设备
* @param callback 回调接口,返回设备列表或错误信息
*/
fun getConnectedHeadsetDevices(callback: BluetoothDevicesCallback) {
getConnectedBluetoothDevices(BluetoothProfile.HEADSET, callback)
}
/**
* 检查是否有蓝牙连接权限
* @return 是否有权限
*/
private fun hasBluetoothConnectPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
/**
* 检查蓝牙是否启用
* @return 蓝牙是否启用
*/
fun isBluetoothEnabled(): Boolean {
try {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
Log.w(TAG, "设备不支持蓝牙")
return false
}
return bluetoothAdapter.isEnabled
} catch (e: Exception) {
Log.e(TAG, "检查蓝牙状态失败: ${e.message}")
return false
}
}
/**
* 获取指定配置文件的已连接蓝牙设备
* @param profile 蓝牙配置文件类型,如 BluetoothProfile.A2DP 或 BluetoothProfile.HEADSET
* @param callback 回调接口,返回设备列表或错误信息
*/
private fun getConnectedBluetoothDevices(profile: Int, callback: BluetoothDevicesCallback) {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
Log.e(TAG, "没有找到蓝牙适配器")
callback.onError("NO_ADAPTER", "没有找到蓝牙适配器")
return
}
// 检查蓝牙权限
if (!hasBluetoothConnectPermission()) {
Log.e(TAG, "缺少BLUETOOTH_CONNECT权限")
callback.onError("PERMISSION_DENIED", "缺少蓝牙连接权限,请在应用设置中授予该权限")
return
}
try {
// 检查是否已经有缓存的代理对象
val cachedProxy = when (profile) {
BluetoothProfile.A2DP -> a2dpProxy
BluetoothProfile.HEADSET -> headsetProxy
else -> null
}
if (cachedProxy != null) {
// 使用已缓存的代理对象
processDeviceList(cachedProxy, profile, callback)
return
}
// 使用原子布尔值确保回调只被调用一次
val callbackCalled = AtomicBoolean(false)
// 使用CountDownLatch等待服务连接
val latch = CountDownLatch(1)
// 通过 getProfileProxy 获取指定 Profile (A2DP/HEADSET) 的连接状态
bluetoothAdapter.getProfileProxy(context,
object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profileId: Int, proxy: BluetoothProfile) {
Log.d(TAG, "蓝牙配置文件服务已连接: $profileId")
if (profileId == profile) {
// 缓存代理对象
when (profileId) {
BluetoothProfile.A2DP -> a2dpProxy = proxy
BluetoothProfile.HEADSET -> headsetProxy = proxy
}
try {
processDeviceList(proxy, profileId, callback)
latch.countDown()
} catch (e: Exception) {
Log.e(TAG, "处理设备信息时出错: ${e.message}")
// 确保回调只被调用一次
if (callbackCalled.compareAndSet(false, true)) {
callback.onError("DEVICE_ERROR", "处理设备信息时出错: ${e.message}")
}
latch.countDown()
}
}
}
override fun onServiceDisconnected(profileId: Int) {
// Profile 断开连接
Log.e(TAG, "蓝牙配置文件服务断开连接: $profileId")
// 清除对应的缓存代理
when (profileId) {
BluetoothProfile.A2DP -> a2dpProxy = null
BluetoothProfile.HEADSET -> headsetProxy = null
}
// 确保回调只被调用一次,但不在这里调用错误回调
// 避免服务断开时也返回错误导致重复回调
if (callbackCalled.compareAndSet(false, true) && !latch.await(0, TimeUnit.MILLISECONDS)) {
callback.onError("SERVICE_DISCONNECTED", "蓝牙配置文件服务断开连接")
latch.countDown()
}
}
}, profile
)
// 设置超时处理
Thread {
try {
// 最多等待3秒
if (!latch.await(3, TimeUnit.SECONDS)) {
Log.e(TAG, "获取蓝牙设备超时")
if (callbackCalled.compareAndSet(false, true)) {
callback.onError("TIMEOUT", "获取蓝牙设备超时")
}
}
} catch (e: InterruptedException) {
Log.e(TAG, "等待蓝牙服务被中断: ${e.message}")
if (callbackCalled.compareAndSet(false, true)) {
callback.onError("INTERRUPTED", "等待蓝牙服务被中断: ${e.message}")
}
}
}.start()
} catch (e: Exception) {
Log.e(TAG, "获取蓝牙设备失败: ${e.message}")
callback.onError("BLUETOOTH_ERROR", "获取蓝牙设备失败: ${e.message}")
}
}
/**
* 处理设备列表
*/
private fun processDeviceList(proxy: BluetoothProfile, profileId: Int, callback: BluetoothDevicesCallback) {
// 获取已连接设备
val connectedDevices = proxy.connectedDevices
// 转换为设备信息列表
val devicesList = connectedDevices.map { device ->
mapOf(
"name" to (device.name ?: "未知设备"),
"address" to device.address
)
}
Log.d(TAG, "获取到的设备列表:${devicesList.size}个设备")
// 返回设备列表(不关闭代理)
callback.onSuccess(devicesList)
}
/**
* 释放资源
*/
fun dispose() {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: return
// 释放A2DP代理
a2dpProxy?.let {
try {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, it)
} catch (e: Exception) {
Log.e(TAG, "关闭A2DP代理失败: ${e.message}")
}
a2dpProxy = null
}
// 释放HEADSET代理
headsetProxy?.let {
try {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, it)
} catch (e: Exception) {
Log.e(TAG, "关闭HEADSET代理失败: ${e.message}")
}
headsetProxy = null
}
}
/**
* 蓝牙设备回调接口
*/
interface BluetoothDevicesCallback {
fun onSuccess(devices: List<Map<String, String>>)
fun onError(code: String, message: String)
}
}

808
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt

@ -1,816 +1,108 @@
package com.yunqiinnovation.deepsound package com.yunqiinnovation.deepsound
import android.Manifest
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.EventChannel
import io.flutter.plugins.GeneratedPluginRegistrant import io.flutter.plugins.GeneratedPluginRegistrant
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import android.content.Context import android.content.Context
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.bluetooth.BluetoothAdapter import com.yunqiinnovation.deepvoice_utils.FileLogger
import android.bluetooth.BluetoothDevice import com.yunqiinnovation.ble_service.BleForegroundService
import androidx.security.crypto.EncryptedSharedPreferences import com.yunqiinnovation.ble_service.BleManagerWrapper
import androidx.security.crypto.MasterKey
import com.yunqiinnovation.deepsound.core.utils.FileLogger
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import android.content.ServiceConnection
import android.content.ComponentName
import android.os.IBinder
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
private val VOICE_INTERACTION_CHANNEL = "com.deep_voice.voice_interaction"
private val VOICE_INTERACTION_EVENT_CHANNEL = "com.deep_voice.voice_interaction_events"
private val CLASSIC_BLUETOOTH_CHANNEL = "com.deep_voice.classic_bluetooth"
private val CLASSIC_BLUETOOTH_EVENT_CHANNEL = "com.deep_voice.classic_bluetooth_events"
private val TAG = "MainActivity" private val TAG = "MainActivity"
private lateinit var classicBluetoothHelper: ClassicBluetoothHelper
private var voiceInteractionEventSink: EventChannel.EventSink? = null
private var bluetoothEventSink: EventChannel.EventSink? = null
// 添加权限请求相关常量
private val PERMISSION_REQUEST_CODE = 100
private val REQUIRED_PERMISSIONS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.SEND_SMS,
Manifest.permission.READ_CONTACTS,
Manifest.permission.CALL_PHONE,
Manifest.permission.POST_NOTIFICATIONS,
Manifest.permission.FOREGROUND_SERVICE_LOCATION
)
} else {
arrayOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.SEND_SMS,
Manifest.permission.READ_CONTACTS,
Manifest.permission.CALL_PHONE
)
}
// 广播接收器
private val voiceInteractionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
FileLogger.d(TAG, "收到广播: ${intent?.action}")
when (intent?.action) {
VoiceInteractionService.ACTION_RECOGNITION_STARTED -> {
val timestamp = intent.getLongExtra("timestamp", 0)
sendVoiceInteractionEvent(mapOf(
"type" to "recognition_started",
"timestamp" to timestamp
))
}
VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED -> {
val agentId = intent.getStringExtra("agentId") ?: "default"
val userMessage = intent.getStringExtra("userMessage") ?: ""
val assistantMessage = intent.getStringExtra("assistantMessage") ?: ""
val timestamp = intent.getLongExtra("timestamp", System.currentTimeMillis())
sendChatHistoryEvent(agentId, userMessage, assistantMessage, timestamp)
}
VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE -> {
sendVoiceInteractionEvent(mapOf(
"type" to "enter_translation_mode",
"timestamp" to System.currentTimeMillis()
))
}
}
}
}
// 蓝牙状态变化广播接收器
private val bluetoothStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
if (device != null) {
Log.d(TAG, "蓝牙设备已连接: ${device.name ?: "未知设备"} (${device.address})")
sendBluetoothEvent(mapOf(
"type" to "deviceConnected",
"device" to mapOf(
"name" to (device.name ?: "未知设备"),
"address" to device.address
),
"timestamp" to System.currentTimeMillis()
))
}
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
if (device != null) {
Log.d(TAG, "蓝牙设备已断开: ${device.name ?: "未知设备"} (${device.address})")
sendBluetoothEvent(mapOf(
"type" to "deviceDisconnected",
"device" to mapOf(
"name" to (device.name ?: "未知设备"),
"address" to device.address
),
"timestamp" to System.currentTimeMillis()
))
}
}
BluetoothAdapter.ACTION_STATE_CHANGED -> {
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
when (state) {
BluetoothAdapter.STATE_ON -> {
Log.d(TAG, "蓝牙已开启")
sendBluetoothEvent(mapOf(
"type" to "bluetoothStateChanged",
"state" to "on",
"timestamp" to System.currentTimeMillis()
))
}
BluetoothAdapter.STATE_OFF -> {
Log.d(TAG, "蓝牙已关闭")
sendBluetoothEvent(mapOf(
"type" to "bluetoothStateChanged",
"state" to "off",
"timestamp" to System.currentTimeMillis()
))
}
}
}
}
}
}
// 使用伴生对象存储配置信息,使其在整个应用中可访问 // 使用伴生对象存储配置信息,使其在整个应用中可访问
companion object { companion object {
// Azure 语音服务配置 // 配置字典,替代原来的单独配置变量
var azureSpeechKey: String = "" val configMap: MutableMap<String, String?> = mutableMapOf()
var azureSpeechRegion: String = ""
var volcanoAiApiKey: String = ""
var openaiApiKey: String = ""
var openaiBaseUrl: String? = null
var openaiModel: String? = null
var volcanoSpeechAppId: String = ""
var volcanoSpeechAppToken: String = ""
var mcpServer: String = ""
var spotifyClientId: String = ""
var spotifyRedirectUrl: String = ""
// 安全存储相关常量 // 安全存储相关常量
private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" const val KEY_AZURE_SPEECH_KEY = "azure_speech_key"
private const val KEY_AZURE_SPEECH_KEY = "azure_speech_key" const val KEY_AZURE_SPEECH_REGION = "azure_speech_region"
private const val KEY_AZURE_SPEECH_REGION = "azure_speech_region" const val KEY_VOLCANO_AI_API_KEY = "volcano_ai_api_key"
private const val KEY_VOLCANO_AI_API_KEY = "volcano_ai_api_key" const val KEY_OPENAI_API_KEY = "openai_api_key"
private const val KEY_OPENAI_API_KEY = "openai_api_key" const val KEY_OPENAI_BASE_URL = "openai_base_url"
private const val KEY_OPENAI_BASE_URL = "openai_base_url" const val KEY_OPENAI_MODEL = "openai_model"
private const val KEY_OPENAI_MODEL = "openai_model" const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint"
private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id"
private const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id" const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token"
private const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token" const val KEY_MCP_SERVER = "mcp_server"
private const val KEY_MCP_SERVER = "mcp_server" const val KEY_SPOTIFY_CLIENT_ID = "spotify_client_id"
private const val KEY_SPOTIFY_CLIENT_ID = "spotify_client_id" const val KEY_SPOTIFY_REDIRECT_URL = "spotify_redirect_url"
private const val KEY_SPOTIFY_REDIRECT_URL = "spotify_redirect_url"
// 会话管理
private const val KEY_SESSION_ID = "session_id"
private var currentSessionId = ""
/** // 获取配置的便捷方法
* 将密钥安全地存储到 EncryptedSharedPreferences fun getConfig(key: String, defaultValue: String = ""): String {
*/ return configMap[key] ?: defaultValue
fun saveKeysToSecureStorage(context: Context) {
try {
// 创建或获取主密钥
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// 创建 EncryptedSharedPreferences
val sharedPreferences = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_FILENAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// 生成新的会话ID
if (currentSessionId.isEmpty()) {
currentSessionId = java.util.UUID.randomUUID().toString()
}
// 保存密钥和会话ID
sharedPreferences.edit()
.putString(KEY_AZURE_SPEECH_KEY, azureSpeechKey)
.putString(KEY_AZURE_SPEECH_REGION, azureSpeechRegion)
.putString(KEY_VOLCANO_AI_API_KEY, volcanoAiApiKey)
.putString(KEY_OPENAI_API_KEY, openaiApiKey)
.putString(KEY_OPENAI_BASE_URL, openaiBaseUrl)
.putString(KEY_SESSION_ID, currentSessionId)
.putString(KEY_OPENAI_MODEL, openaiModel)
.putString(KEY_VOLCANO_SPEECH_APP_ID, volcanoSpeechAppId)
.putString(KEY_VOLCANO_SPEECH_APP_TOKEN, volcanoSpeechAppToken)
.putString(KEY_MCP_SERVER, mcpServer)
.putString(KEY_SPOTIFY_CLIENT_ID, spotifyClientId)
.putString(KEY_SPOTIFY_REDIRECT_URL, spotifyRedirectUrl)
.apply()
FileLogger.d("MainActivity", "密钥已安全保存到加密存储中")
} catch (e: Exception) {
FileLogger.e("MainActivity", "保存密钥到加密存储失败: ${e.message}")
e.printStackTrace()
}
} }
/**
* 从 EncryptedSharedPreferences 获取密钥
*/
fun loadKeysFromSecureStorage(context: Context): Boolean {
try {
// 创建或获取主密钥
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// 获取 EncryptedSharedPreferences
val sharedPreferences = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_FILENAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// 检查会话ID是否匹配
val savedSessionId = sharedPreferences.getString(KEY_SESSION_ID, "") ?: ""
// 如果会话ID为空或不匹配,说明是新会话或应用被重启
if (savedSessionId.isEmpty() || currentSessionId.isEmpty() || savedSessionId != currentSessionId) {
// 会话不匹配,但尝试加载密钥,不返回错误
FileLogger.d("MainActivity", "会话ID不匹配,但尝试加载密钥")
// 设置当前会话ID为保存的会话ID
currentSessionId = savedSessionId
}
// 加载密钥
azureSpeechKey = sharedPreferences.getString(KEY_AZURE_SPEECH_KEY, "") ?: ""
azureSpeechRegion = sharedPreferences.getString(KEY_AZURE_SPEECH_REGION, "") ?: ""
volcanoAiApiKey = sharedPreferences.getString(KEY_VOLCANO_AI_API_KEY, "") ?: ""
openaiApiKey = sharedPreferences.getString(KEY_OPENAI_API_KEY, "") ?: ""
openaiBaseUrl = sharedPreferences.getString(KEY_OPENAI_BASE_URL, null)
openaiModel = sharedPreferences.getString(KEY_OPENAI_MODEL, null)
volcanoSpeechAppId = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_ID, "") ?: ""
volcanoSpeechAppToken = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_TOKEN, "") ?: ""
mcpServer = sharedPreferences.getString(KEY_MCP_SERVER, "") ?: ""
FileLogger.d("MainActivity", "已从加密存储加载密钥")
// 检查是否成功获取所有必要密钥
return azureSpeechKey.isNotEmpty() && azureSpeechRegion.isNotEmpty() &&
(openaiApiKey.isNotEmpty() || volcanoAiApiKey.isNotEmpty())
} catch (e: Exception) {
FileLogger.e("MainActivity", "从加密存储加载密钥失败: ${e.message}")
e.printStackTrace()
return false
}
}
// 请求码
private const val REQUEST_BLUETOOTH_PERMISSIONS = 1003
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 初始化 FileLogger // 初始化FileLogger
FileLogger.init(applicationContext) FileLogger.init(applicationContext)
// 请求必要权限 // 初始化ble_service后台唤醒功能
requestRequiredPermissions() // initBleBackgroundScan()
// 初始化 Azure 语音服务 // 加载配置
classicBluetoothHelper = ClassicBluetoothHelper(applicationContext) // loadConfigFromSecureStorage()
// 注册广播接收器 FileLogger.d(TAG, "MainActivity.onCreate 已初始化")
registerReceivers()
// 确保 Flutter 引擎准备好后立即设置事件通道
flutterEngine?.let { engine ->
Log.d(TAG, "Flutter 引擎已存在,立即设置事件通道")
setupEventChannels(engine)
}
// 初始化BLE唤醒服务
initBleWakeService()
} }
private fun registerReceivers() { /**
// 注册语音交互广播接收器 * 从安全存储中加载配置
val voiceInteractionFilter = IntentFilter().apply { */
addAction(VoiceInteractionService.ACTION_RECOGNITION_STARTED) private fun loadConfigFromSecureStorage() {
addAction(VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED) val data = Utils.loadAllSecureData(applicationContext)
addAction(VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE)
}
// 打印已注册的广播
FileLogger.d(TAG, "已注册语音交互广播:${VoiceInteractionService.ACTION_RECOGNITION_STARTED}, " +
"${VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED}, " +
"${VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE}")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // 将所有数据加载到全局配置字典中
registerReceiver(voiceInteractionReceiver, voiceInteractionFilter, Context.RECEIVER_NOT_EXPORTED) configMap.clear()
} else { configMap.putAll(data)
registerReceiver(voiceInteractionReceiver, voiceInteractionFilter)
}
// 注册蓝牙状态变化广播接收器 FileLogger.d(TAG, "已从安全存储加载配置")
val bluetoothFilter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
registerReceiver(bluetoothStateReceiver, bluetoothFilter, Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(bluetoothStateReceiver, bluetoothFilter)
}
} }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
Log.d(TAG, "开始配置 Flutter 引擎")
// 先设置事件通道,确保在插件注册之前就已准备好
setupEventChannels(flutterEngine)
// 然后调用父类方法和注册插件
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
GeneratedPluginRegistrant.registerWith(flutterEngine) GeneratedPluginRegistrant.registerWith(flutterEngine)
// 设置方法通道 FileLogger.d(TAG, "Flutter 引擎配置完成")
setupMethodChannels(flutterEngine)
// 初始化 Azure 语音服务
classicBluetoothHelper = ClassicBluetoothHelper(this)
Log.d(TAG, "Flutter 引擎配置完成")
}
/**
* 设置事件通道
*/
private fun setupEventChannels(flutterEngine: FlutterEngine) {
Log.d(TAG, "开始设置事件通道")
try {
// 语音交互事件通道
EventChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
Log.d(TAG, "语音交互事件通道开始监听")
voiceInteractionEventSink = events
}
override fun onCancel(arguments: Any?) {
voiceInteractionEventSink = null
}
}
)
// 设置蓝牙事件通道
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CLASSIC_BLUETOOTH_EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
Log.d(TAG, "蓝牙事件通道开始监听")
bluetoothEventSink = events
}
override fun onCancel(arguments: Any?) {
Log.d(TAG, "蓝牙事件通道停止监听")
bluetoothEventSink = null
}
}
)
} catch (e: Exception) {
Log.e(TAG, "设置事件通道时发生错误: ${e.message}", e)
}
} }
/**
* 设置方法通道
*/
private fun setupMethodChannels(flutterEngine: FlutterEngine) {
Log.d(TAG, "开始设置方法通道")
// 设置语音交互方法通道
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"startService" -> {
startVoiceInteractionService(call, result)
}
"stopService" -> {
stopVoiceInteractionService()
result.success(true)
}
"isServiceRunning" -> {
result.success(VoiceInteractionService.isServiceRunning())
}
"pauseVoiceInteraction" -> {
pauseVoiceInteraction()
result.success(true)
}
else -> {
result.notImplemented()
}
}
}
// 设置经典蓝牙方法通道
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CLASSIC_BLUETOOTH_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"getConnectedA2dpDevices" -> {
try {
classicBluetoothHelper.getConnectedA2dpDevices(object : ClassicBluetoothHelper.BluetoothDevicesCallback {
override fun onSuccess(devices: List<Map<String, String>>) {
try {
result.success(devices)
} catch (e: Exception) {
Log.e(TAG, "返回A2DP设备时发生异常: ${e.message}")
}
}
override fun onError(code: String, message: String) {
try {
result.error(code, message, null)
} catch (e: Exception) {
Log.e(TAG, "返回A2DP设备错误时发生异常: ${e.message}")
}
}
})
} catch (e: Exception) {
try {
Log.e(TAG, "获取A2DP设备时发生异常: ${e.message}")
result.error("UNEXPECTED_ERROR", "获取A2DP设备时发生异常: ${e.message}", e.toString())
} catch (e2: Exception) {
Log.e(TAG, "处理A2DP设备获取异常时发生二次异常: ${e2.message}")
}
}
}
"getConnectedHeadsetDevices" -> {
try {
classicBluetoothHelper.getConnectedHeadsetDevices(object : ClassicBluetoothHelper.BluetoothDevicesCallback {
override fun onSuccess(devices: List<Map<String, String>>) {
try {
result.success(devices)
} catch (e: Exception) {
Log.e(TAG, "返回耳机设备时发生异常: ${e.message}")
}
}
override fun onError(code: String, message: String) {
try {
result.error(code, message, null)
} catch (e: Exception) {
Log.e(TAG, "返回耳机设备错误时发生异常: ${e.message}")
}
}
})
} catch (e: Exception) {
try {
Log.e(TAG, "获取耳机设备时发生异常: ${e.message}")
result.error("UNEXPECTED_ERROR", "获取耳机设备时发生异常: ${e.message}", e.toString())
} catch (e2: Exception) {
Log.e(TAG, "处理耳机设备获取异常时发生二次异常: ${e2.message}")
}
}
}
"isBluetoothEnabled" -> {
try {
val isEnabled = classicBluetoothHelper.isBluetoothEnabled()
result.success(isEnabled)
Log.d(TAG, "检查蓝牙状态: ${if (isEnabled) "已启用" else "未启用"}")
} catch (e: Exception) {
Log.e(TAG, "检查蓝牙状态时发生异常: ${e.message}")
result.error("BLUETOOTH_ERROR", "检查蓝牙状态失败: ${e.message}", e.toString())
}
}
"initialize" -> {
result.success(true)
}
else -> result.notImplemented()
}
}
}
/**
* 启动语音交互服务(带配置参数)
*/
private fun startVoiceInteractionService(call: MethodCall, result: MethodChannel.Result) {
FileLogger.d(TAG, "启动语音交互服务")
// 获取配置参数
azureSpeechKey = call.argument<String>("azure_speech_key") ?: ""
azureSpeechRegion = call.argument<String>("azure_speech_region") ?: ""
openaiApiKey = call.argument<String>("openai_api_key") ?: ""
openaiBaseUrl = call.argument<String>("openai_base_url")
openaiModel = call.argument<String>("openai_model")
volcanoSpeechAppId = call.argument<String>("volcano_speech_app_id") ?: ""
volcanoSpeechAppToken = call.argument<String>("volcano_speech_app_token") ?: ""
mcpServer = call.argument<String>("mcp_config") ?: ""
// 保存密钥到安全存储
saveKeysToSecureStorage(applicationContext)
// 创建 Intent 并启动服务
val serviceIntent = Intent(this, VoiceInteractionService::class.java)
// 在 Android O 及以上版本,需要使用 startForegroundService
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
// 返回成功结果
result.success(true)
}
/**
* 停止语音交互服务
*/
private fun stopVoiceInteractionService() {
Log.d(TAG, "停止语音交互服务")
val serviceIntent = Intent(this, VoiceInteractionService::class.java)
stopService(serviceIntent)
}
/**
* 暂停语音交互(停止TTS和语音识别,但保持服务运行)
*/
private fun pauseVoiceInteraction() {
Log.d(TAG, "发送暂停语音交互请求")
val intent = Intent(this, VoiceInteractionService::class.java).apply {
action = VoiceInteractionService.ACTION_PAUSE_VOICE_INTERACTION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
/**
* 发送聊天记录事件到 Flutter
*/
private fun sendChatHistoryEvent(agentId: String, userMessage: String, assistantMessage: String, timestamp: Long) {
if (voiceInteractionEventSink == null) {
Log.e(TAG, "无法发送聊天记录事件:事件通道未准备好")
return
}
runOnUiThread {
try {
val eventData = mapOf(
"type" to "chat_history_updated",
"agentId" to agentId,
"userMessage" to userMessage,
"assistantMessage" to assistantMessage,
"timestamp" to timestamp
)
voiceInteractionEventSink?.success(eventData)
} catch (e: Exception) {
Log.e(TAG, "发送聊天记录事件到Flutter失败: ${e.message}")
}
}
}
/**
* 发送语音交互事件到 Flutter
*/
private fun sendVoiceInteractionEvent(event: Map<String, Any>) {
if (voiceInteractionEventSink == null) {
Log.e(TAG, "无法发送语音交互事件:事件通道未准备好")
FileLogger.e(TAG, "无法发送语音交互事件:事件通道未准备好")
return
}
FileLogger.d(TAG, "准备发送事件到Flutter: ${event["type"]}")
runOnUiThread {
try {
voiceInteractionEventSink?.success(event)
FileLogger.d(TAG, "成功发送事件到Flutter: ${event["type"]}")
} catch (e: Exception) {
Log.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}")
FileLogger.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}")
}
}
}
// 发送蓝牙事件到Flutter
private fun sendBluetoothEvent(event: Map<String, Any>) {
if (bluetoothEventSink == null) {
Log.w(TAG, "无法发送蓝牙事件:事件通道未准备好")
return
}
try {
runOnUiThread {
try {
bluetoothEventSink?.success(event)
Log.d(TAG, "已发送蓝牙事件: ${event["type"]}")
} catch (e: Exception) {
Log.e(TAG, "发送蓝牙事件失败: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "转到UI线程发送蓝牙事件失败: ${e.message}")
}
}
override fun onDestroy() { override fun onDestroy() {
Log.d(TAG, "onDestroy") FileLogger.d(TAG, "onDestroy")
// 注销所有广播接收器
try {
unregisterReceiver(voiceInteractionReceiver)
unregisterReceiver(bluetoothStateReceiver)
} catch (e: Exception) {
Log.e(TAG, "注销广播接收器失败: ${e.message}")
}
// 释放资源
classicBluetoothHelper.dispose()
super.onDestroy() super.onDestroy()
} }
/** /**
* 请求必要权限 * 初始化BLE后台扫描和唤醒功能
*/
private fun requestRequiredPermissions() {
val permissionsToRequest = ArrayList<String>()
for (permission in REQUIRED_PERMISSIONS) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
permissionsToRequest.add(permission)
}
}
if (permissionsToRequest.isNotEmpty()) {
ActivityCompat.requestPermissions(
this,
permissionsToRequest.toTypedArray(),
PERMISSION_REQUEST_CODE
)
}
}
/**
* 处理权限请求结果
*/ */
override fun onRequestPermissionsResult( private fun initBleBackgroundScan() {
requestCode: Int, try {
permissions: Array<out String>, // 初始化BleManagerWrapper
grantResults: IntArray val bleManager = BleManagerWrapper.instance
) { bleManager.init(applicationContext)
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) {
val deniedPermissions = ArrayList<String>()
for (i in permissions.indices) {
if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
deniedPermissions.add(permissions[i])
}
}
if (deniedPermissions.isNotEmpty()) { // 启动BLE前台服务以确保后台扫描运行
// 记录未授权的权限
FileLogger.w(TAG, "未授权的权限: ${deniedPermissions.joinToString()}")
} else {
FileLogger.d(TAG, "所有必要权限已授权")
}
}
when (requestCode) {
REQUEST_BLUETOOTH_PERMISSIONS -> {
if (grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
FileLogger.d(TAG, "蓝牙权限已授予,初始化BLE唤醒服务")
initBleWakeService()
} else {
FileLogger.e(TAG, "蓝牙权限被拒绝")
}
}
// 处理其他权限请求...
}
}
/**
* 初始化BLE唤醒服务
*/
private fun initBleWakeService() {
FileLogger.d(TAG, "初始化BLE唤醒服务")
// 检查蓝牙权限
if (hasBluetoothPermissions()) {
// 启动BLE唤醒服务
val serviceIntent = Intent(this, BleWakeService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent) startForegroundService(Intent(this, BleForegroundService::class.java))
} else { } else {
startService(serviceIntent) startService(Intent(this, BleForegroundService::class.java))
} }
FileLogger.d(TAG, "BLE唤醒服务已启动")
// 绑定服务以访问其方法
bindService(serviceIntent, object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
FileLogger.d(TAG, "已连接到BLE唤醒服务")
val binder = service as BleWakeService.LocalBinder
val bleWakeService = binder.getService()
// 注册后台持久扫描,用于应用被杀死后的唤醒
bleWakeService.registerBackgroundScan()
}
override fun onServiceDisconnected(name: ComponentName?) {
FileLogger.d(TAG, "与BLE唤醒服务的连接已断开")
}
}, Context.BIND_AUTO_CREATE)
} else {
FileLogger.e(TAG, "缺少蓝牙权限,无法启动BLE唤醒服务")
// 可以在这里请求权限 FileLogger.d(TAG, "成功启动BLE后台唤醒功能")
requestBluetoothPermissions() } catch (e: Exception) {
} FileLogger.e(TAG, "启动BLE后台唤醒功能失败", e)
}
/**
* 检查是否有蓝牙权限
*/
private fun hasBluetoothPermissions(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return hasPermission(Manifest.permission.BLUETOOTH_SCAN) &&
hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
} else {
return hasPermission(Manifest.permission.BLUETOOTH) &&
hasPermission(Manifest.permission.BLUETOOTH_ADMIN)
}
}
/**
* 请求蓝牙权限
*/
private fun requestBluetoothPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
requestPermissions(
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
),
REQUEST_BLUETOOTH_PERMISSIONS
)
} else {
requestPermissions(
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
),
REQUEST_BLUETOOTH_PERMISSIONS
)
} }
} }
/**
* 检查权限是否已授予
*/
private fun hasPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
} }

444
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt

@ -1,444 +0,0 @@
package com.yunqiinnovation.deepsound
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import org.json.JSONArray
import org.json.JSONObject
import android.util.Log
import com.yunqiinnovation.deepsound.core.utils.FileLogger
import com.yunqiinnovation.azure_speech.AzureAsrHelper
import com.yunqiinnovation.azure_speech.AzureTtsHelper
import com.yunqiinnovation.open_ai_service.OpenAIService
import com.yunqiinnovation.open_ai_service.SystemFunctionHandler
import de.minimalme.spotify_sdk.SpotifySdkHelper
import kotlinx.serialization.json.JsonObject
/**
* 语音交互处理器 - 处理语音识别、TTS和AI对话相关逻辑
*/
class VoiceInteractionHandler(
private val context: Context,
private val azureSpeechKey: String,
private val azureSpeechRegion: String,
private val openaiApiKey: String,
private val openaiBaseUrl: String = "",
private val openaiModel: String = "",
private val volcanoSpeechAppId: String,
private val volcanoSpeechAppToken: String,
private val mcpServer: String,
private val spotifyclientid: String,
private val spotifyRedirectUrl: String
) {
private val TAG = "VoiceInteractionHandler"
// Azure服务
private var azureAsrHelper: AzureAsrHelper? = null
private var azureTtsHelper: AzureTtsHelper? = null
// OpenAI服务
private val openAIService = OpenAIService(context.applicationContext)
private var spotifyHelper: SpotifySdkHelper? = null
// 当前用户输入
private var currentUserInput = ""
// 状态
private var isInitialized = false
var isRecognitionActive = false
private set
var isTtsSpeaking = false
private set
var hasSpeechDetected = false
private set
// 回调
private var callback: InteractionCallback? = null
// 广播接收器
private val exitInteractionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == SystemFunctionHandler.ACTION_EXIT_INTERACTION) {
Log.d(TAG, "收到退出交互广播")
stopRecognition()
}
}
}
/**
* 初始化
*/
fun initialize(): Boolean {
if (isInitialized) return true
try {
// 初始化Azure ASR
azureAsrHelper = AzureAsrHelper(context).apply {
initialize(azureSpeechKey, azureSpeechRegion)
}
// 初始化Azure TTS
azureTtsHelper = AzureTtsHelper(context).apply {
initialize(azureSpeechKey, azureSpeechRegion)
}
// 初始化OpenAI服务
openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel, mcpServer)
// 注册广播接收器
try {
Log.d(TAG, "注册退出交互广播接收器,包名=${context.packageName}, action=${SystemFunctionHandler.ACTION_EXIT_INTERACTION}")
context.registerReceiver(
exitInteractionReceiver,
IntentFilter(SystemFunctionHandler.ACTION_EXIT_INTERACTION),
Context.RECEIVER_NOT_EXPORTED
)
Log.d(TAG, "退出交互广播接收器注册成功")
} catch (e: Exception) {
// 广播注册失败不应该影响整个应用初始化
Log.e(TAG, "注册退出交互广播接收器失败: ${e.message}", e)
}
isInitialized = true
return true
} catch (e: Exception) {
FileLogger.e(TAG, "初始化失败: ${e.message}", e)
return false
}
}
/**
* 设置回调
*/
fun setCallback(callback: InteractionCallback) {
this.callback = callback
}
/**
* 开始语音识别
*/
fun startRecognition() {
if (isRecognitionActive) return
// 检查录音权限
if (!checkRecordAudioPermission()) {
callback?.onError("需要录音权限,请在设置中授予权限")
return
}
isRecognitionActive = true
hasSpeechDetected = false
notifyStateChanged()
try {
azureAsrHelper?.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback {
override fun onRecognizing(recognizing: String, detectedLanguage: String) {
if (recognizing.isNotEmpty()) {
hasSpeechDetected = true
stopTts()
notifyStateChanged()
}
}
override fun onResult(result: String, detectedLanguage: String) {
if (result.isNotEmpty()) {
notifyStateChanged()
processWithOpenAI(result)
}
// 重置状态,继续识别
hasSpeechDetected = false
}
override fun onSessionStarted() {
notifyStateChanged()
}
override fun onSessionStopped() {
isRecognitionActive = false
notifyStateChanged()
}
override fun onCanceled(reason: String, errorDetails: String) {
isRecognitionActive = false
notifyStateChanged()
}
override fun onError(error: String) {
isRecognitionActive = false
callback?.onError("语音识别出错")
notifyStateChanged()
}
override fun onSuccess(message: String) {
// 处理成功事件
}
})
} catch (e: Exception) {
isRecognitionActive = false
FileLogger.e(TAG, "启动语音识别失败: ${e.message}", e)
callback?.onError("启动语音识别失败")
notifyStateChanged()
}
}
/**
* 停止语音识别
*/
fun stopRecognition() {
if (!isRecognitionActive) return
FileLogger.d(TAG, "停止语音识别")
try {
azureAsrHelper?.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback {
override fun onResult(result: String, detectedLanguage: String) {}
override fun onRecognizing(recognizing: String, detectedLanguage: String) {}
override fun onSessionStarted() {}
override fun onSessionStopped() {
isRecognitionActive = false
FileLogger.d(TAG, "语音识别会话已停止")
notifyStateChanged()
}
override fun onCanceled(reason: String, errorDetails: String) {
isRecognitionActive = false
FileLogger.d(TAG, "语音识别已取消: $reason")
notifyStateChanged()
}
override fun onError(error: String) {
isRecognitionActive = false
FileLogger.e(TAG, "停止语音识别时出错: $error")
notifyStateChanged()
}
override fun onSuccess(message: String) {
isRecognitionActive = false
FileLogger.d(TAG, "语音识别已停止: $message")
notifyStateChanged()
}
})
} catch (e: Exception) {
FileLogger.e(TAG, "停止语音识别异常: ${e.message}", e)
// 确保状态一致性
isRecognitionActive = false
notifyStateChanged()
}
}
/**
* 使用OpenAI处理语音识别结果
*/
private fun processWithOpenAI(text: String) {
// 保存当前用户输入,用于后续同步聊天记录
currentUserInput = text
Log.d(TAG, "用户 问题: $text")
Thread {
try {
val messages = JSONArray().apply {
put(openAIService.createUserMessage(text))
}
// 创建响应构建器
val responseBuilder = StringBuilder()
openAIService.sendMessageStream(
messages = messages,
callback = object : OpenAIService.StreamCallback {
override fun onToken(token: String) {
// 累加响应内容
responseBuilder.append(token)
}
override fun onComplete() {
// 处理完整响应
val response = responseBuilder.toString()
if (response.isNotEmpty()) {
// 播放AI回复
Log.d(TAG, "AI 回复: $response")
speakAIResponse(response)
// 同步聊天记录到Flutter端
sendChatHistoryUpdate("personal_assistant", text, response)
}
}
override fun onError(e: Exception) {
FileLogger.e(TAG, "AI流式处理出错: ${e.message}", e)
callback?.onError("AI处理出错")
}
override fun onFunctionCall(call: JSONObject) {
FileLogger.d(TAG, "processWithOpenAI 收到函数调用请求: ${call.getString("name")}")
// Log.e("OpenAIServicePlugin", "context:${call.toString()}")
// val context = call["context"]
// if (call.has("meta")) {
// val meta = call.get("meta")
// val metaJsonObject: JsonObject = meta as JsonObject
// Log.e("OpenAIServicePlugin", "工具调用结果 context:${context} meta:${meta.toString()}")
// if (metaJsonObject.containsKey("card_spotify")) {
// val card_spotify:JsonObject = metaJsonObject.get("card_spotify") as JsonObject
// playSpotifyMusic(card_spotify.get("name") as String)
// }
// }
}
}
)
} catch (e: Exception) {
FileLogger.e(TAG, "AI处理出错: ${e.message}", e)
callback?.onError("AI处理出错")
}
}.start()
}
/**
* 播放TTS
*/
fun playTts(text: String, callback: TtsCallback? = null) {
isTtsSpeaking = true
notifyStateChanged()
azureTtsHelper?.speakText(text, object : AzureTtsHelper.TTSCallback {
override fun onSuccess(message: String) {
isTtsSpeaking = false
notifyStateChanged()
callback?.onComplete()
}
override fun onError(error: String) {
isTtsSpeaking = false
notifyStateChanged()
callback?.onError(error)
}
})
}
/**
* 停止TTS播放
*/
fun stopTts() {
if (isTtsSpeaking) {
azureTtsHelper?.stopSpeaking()
isTtsSpeaking = false
notifyStateChanged()
}
}
/*
* 播放音乐。Spotify
* */
fun playSpotifyMusic(url:String){
if (spotifyHelper == null){
spotifyHelper = SpotifySdkHelper(context).apply {
initialize(spotifyclientid,spotifyRedirectUrl)
}
}
spotifyHelper?.play(url)
}
/**
* 播放AI回复
*/
private fun speakAIResponse(text: String) {
isTtsSpeaking = true
notifyStateChanged()
azureTtsHelper?.speakText(text, object : AzureTtsHelper.TTSCallback {
override fun onSuccess(message: String) {
isTtsSpeaking = false
notifyStateChanged()
}
override fun onError(error: String) {
isTtsSpeaking = false
notifyStateChanged()
}
})
}
/**
* 释放资源
*/
fun dispose() {
// 停止语音识别
stopRecognition()
// 停止TTS播放
stopTts()
// 注销广播接收器
try {
context.unregisterReceiver(exitInteractionReceiver)
} catch (e: Exception) {
FileLogger.e(TAG, "注销广播接收器失败: ${e.message}", e)
}
// 释放Azure资源
azureAsrHelper?.let {
FileLogger.d(TAG, "关闭Azure ASR服务")
it.dispose()
}
azureTtsHelper?.let {
FileLogger.d(TAG, "关闭Azure TTS服务")
it.dispose()
}
FileLogger.d(TAG, "语音交互处理器资源已释放")
}
/**
* 检查录音权限
*/
private fun checkRecordAudioPermission(): Boolean {
val permission = android.Manifest.permission.RECORD_AUDIO
val result = context.checkCallingOrSelfPermission(permission)
return result == android.content.pm.PackageManager.PERMISSION_GRANTED
}
/**
* 通知状态变化
*/
private fun notifyStateChanged() {
callback?.onStateChanged(isRecognitionActive, isTtsSpeaking, hasSpeechDetected)
}
/**
* 发送聊天历史更新
*/
private fun sendChatHistoryUpdate(agentId: String, userMessage: String, assistantMessage: String) {
val intent = android.content.Intent(VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED).apply {
putExtra("agentId", agentId)
putExtra("userMessage", userMessage)
putExtra("assistantMessage", assistantMessage)
putExtra("timestamp", System.currentTimeMillis())
setPackage(context.packageName)
}
// 发送广播
context.sendBroadcast(intent)
}
/**
* 交互回调接口
*/
interface InteractionCallback {
fun onStateChanged(isRecognitionActive: Boolean, isTtsSpeaking: Boolean, hasSpeechDetected: Boolean)
fun onError(message: String)
fun onPromptRequest(message: String)
}
/**
* TTS回调接口
*/
interface TtsCallback {
fun onComplete()
fun onError(error: String)
}
}

670
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt

@ -1,670 +0,0 @@
package com.yunqiinnovation.deepsound
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.view.KeyEvent
import androidx.annotation.Nullable
import androidx.core.app.NotificationCompat
import androidx.media.session.MediaButtonReceiver
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.content.ComponentName
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import java.util.concurrent.atomic.AtomicBoolean
import org.json.JSONArray
import org.json.JSONObject
import android.media.MediaPlayer
import android.media.AudioAttributes
import android.net.Uri
import com.yunqiinnovation.deepsound.core.utils.FileLogger
import com.yunqiinnovation.azure_speech.AzureAsrHelper
import com.yunqiinnovation.volcano_speech.VolcanoTtsHelper
import com.yunqiinnovation.open_ai_service.OpenAIService
import android.os.Bundle
/**
* 后台语音交互 Service:
* 1) 前台服务,确保不会被系统轻易杀死
* 2) MediaSession 捕获蓝牙耳机按键
* 3) 负责唤醒控制和服务生命周期管理
*/
class VoiceInteractionService : Service() {
companion object {
private const val TAG = "VoiceInteractionService"
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "voice_interaction_channel"
// 语音识别超时时间(毫秒)
private const val RECOGNITION_TIMEOUT = 10000L
// 用于跟踪服务是否正在运行
private val isRunning = AtomicBoolean(false)
// 获取服务运行状态
fun isServiceRunning(): Boolean = isRunning.get()
// 广播 Action
const val ACTION_RECOGNITION_STARTED = "com.yunqiinnovation.deepsound.ACTION_RECOGNITION_STARTED"
const val ACTION_PAUSE_VOICE_INTERACTION = "com.yunqiinnovation.deepsound.ACTION_PAUSE_VOICE_INTERACTION"
const val ACTION_CHAT_HISTORY_UPDATED = "com.yunqiinnovation.deepsound.ACTION_CHAT_HISTORY_UPDATED"
const val ACTION_ENTER_TRANSLATION_MODE = "com.yunqiinnovation.deepsound.ACTION_ENTER_TRANSLATION_MODE"
}
// 服务状态
private var isActive = false // 服务是否活跃
private var isTimeoutPaused = false // 是否因超时暂停
// 按键处理
private var lastKeyEventTime = 0L
private var keyEventCount = 0
// 活动时间
private var lastActivityTime = 0L
// 服务组件
private lateinit var mediaSession: MediaSessionCompat
private lateinit var audioManager: AudioManager
// 语音交互处理器
private lateinit var voiceInteractionHandler: VoiceInteractionHandler
// 定时器
private val handler = Handler(Looper.getMainLooper())
private val serviceMonitor = object : Runnable {
override fun run() {
monitorService()
handler.postDelayed(this, 1000) // 每秒执行一次
}
}
// 添加媒体播放器
private var audioPlayer: MiniMediaPlayer? = null
override fun onCreate() {
super.onCreate()
// 初始化文件日志系统
FileLogger.init(applicationContext)
FileLogger.d(TAG, "onCreate - 语音交互服务正在创建")
// 初始化状态
isRunning.set(true)
resetState()
// 初始化组件
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
FileLogger.d(TAG, "AudioManager初始化完成")
// 初始化语音交互处理器
initVoiceInteractionHandler()
initMediaSession()
registerMediaButtonReceiver()
// 创建音频播放器
audioPlayer = MiniMediaPlayer(applicationContext)
audioPlayer?.play(R.raw.silence, 0f)
// 设置为媒体播放状态
setPlaybackState(PlaybackStateCompat.STATE_PAUSED)
// 启动监控和前台服务
startMonitoring()
startForegroundServiceWithNotification()
FileLogger.d(TAG, "VoiceInteractionService 初始化完成,等待蓝牙按键事件...")
}
/**
* 重置所有状态
*/
private fun resetState() {
isActive = false
isTimeoutPaused = false
}
/**
* 初始化语音交互处理器
*/
private fun initVoiceInteractionHandler() {
FileLogger.d(TAG, "初始化语音交互处理器")
// 尝试从静态变量获取配置
var subscriptionKey = MainActivity.azureSpeechKey
var serviceRegion = MainActivity.azureSpeechRegion
var openaiKey = MainActivity.openaiApiKey
var openaiBaseUrl = MainActivity.openaiBaseUrl ?: "" // OpenAI API基本URL
var openaiModel = MainActivity.openaiModel ?: "" // OpenAI模型
var volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: ""
var volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: ""
var mcpServer = MainActivity.mcpServer ?: ""
var spotifyclientid = MainActivity.spotifyClientId ?: ""
var spotifyRedirectUrl = MainActivity.spotifyRedirectUrl ?: ""
// 如果静态变量中没有配置,尝试从加密存储中加载
if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) {
FileLogger.d(TAG, "静态变量中的配置信息不完整,尝试从加密存储加载")
// 从加密存储加载密钥
val keysLoaded = MainActivity.loadKeysFromSecureStorage(applicationContext)
if (keysLoaded) {
// 更新本地变量
subscriptionKey = MainActivity.azureSpeechKey
serviceRegion = MainActivity.azureSpeechRegion
openaiKey = MainActivity.openaiApiKey
openaiBaseUrl = MainActivity.openaiBaseUrl ?: ""
openaiModel = MainActivity.openaiModel ?: ""
volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: ""
volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: ""
mcpServer = MainActivity.mcpServer ?: ""
spotifyclientid = MainActivity.spotifyClientId ?: ""
spotifyRedirectUrl = MainActivity.spotifyRedirectUrl ?: ""
FileLogger.d(TAG, "已从加密存储加载配置信息")
} else {
FileLogger.e(TAG, "无法从加密存储加载配置信息")
}
}
// 初始化语音交互处理器
voiceInteractionHandler = VoiceInteractionHandler(applicationContext,
subscriptionKey, serviceRegion,
openaiKey, openaiBaseUrl, openaiModel,
volcanoSpeechAppId, volcanoSpeechAppToken,
mcpServer,spotifyclientid,spotifyRedirectUrl)
// 初始化回调
voiceInteractionHandler.setCallback(object : VoiceInteractionHandler.InteractionCallback {
override fun onStateChanged(isRecognitionActive: Boolean, isTtsSpeaking: Boolean, hasSpeechDetected: Boolean) {
// 更新活动时间
updateLastActivityTime()
}
override fun onError(message: String) {
playNotification(message)
}
override fun onPromptRequest(message: String) {
playPrompt(message)
}
})
// 初始化处理器
val initialized = voiceInteractionHandler.initialize()
if (initialized) {
FileLogger.d(TAG, "语音交互处理器初始化成功")
} else {
FileLogger.e(TAG, "语音交互处理器初始化失败")
}
}
/**
* 初始化 MediaSession
*/
private fun initMediaSession() {
FileLogger.d(TAG, "开始初始化MediaSession")
mediaSession = MediaSessionCompat(applicationContext, "VoiceServiceSession")
// 设置标志和状态
mediaSession.setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
FileLogger.d(TAG, "MediaSession标志设置完成")
// 设置回调
mediaSession.setCallback(object : MediaSessionCompat.Callback() {
override fun onCustomAction(action: String, extras: Bundle?) {
FileLogger.d(TAG, "收到自定义动作: action=${action}, extras=${extras}")
}
// 处理媒体按钮事件
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
@Suppress("DEPRECATION")
mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent
}
if (keyEvent != null) {
// 仅处理按下操作和相关按键
if (keyEvent.action == KeyEvent.ACTION_DOWN) {
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_HEADSETHOOK,
KeyEvent.KEYCODE_MEDIA_PLAY,
KeyEvent.KEYCODE_MEDIA_PAUSE,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
FileLogger.d(TAG, "接收到相关媒体按钮: keyCode=${keyEvent.keyCode}")
handleDoubleClick()
return true
}
}
}
FileLogger.d(TAG, "忽略不相关媒体按钮: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}")
return false
}
FileLogger.d(TAG, "收到无按键的媒体按钮事件: mediaButtonEvent=${mediaButtonEvent}")
return false
}
override fun onPlay() {
FileLogger.d(TAG, "收到onPlay回调")
handleDoubleClick()
return super.onPlay()
}
override fun onPause() {
FileLogger.d(TAG, "收到onPause回调")
handleDoubleClick()
return super.onPause()
}
})
// 激活 MediaSession
mediaSession.isActive = true
FileLogger.d(TAG, "MediaSession已激活")
}
/**
* 注册媒体按钮接收器
*/
private fun registerMediaButtonReceiver() {
try {
val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name)
audioManager.registerMediaButtonEventReceiver(mediaButtonReceiver)
FileLogger.d(TAG, "媒体按钮接收器注册成功: $mediaButtonReceiver")
} catch (e: Exception) {
FileLogger.e(TAG, "媒体按钮接收器注册失败: ${e.message}", e)
}
}
/**
* 启动监控
*/
private fun startMonitoring() {
handler.post(serviceMonitor)
}
/**
* 停止监控
*/
private fun stopMonitoring() {
handler.removeCallbacks(serviceMonitor)
}
/**
* 监控服务状态
*/
private fun monitorService() {
// 确保服务保持活跃状态
if (!isActive) {
isActive = true
}
// FileLogger.d(TAG, "服务状态: isActive=${isActive}, isRecognitionActive=${voiceInteractionHandler.isRecognitionActive}, isTimeoutPaused=${isTimeoutPaused}")
// 检查语音识别状态
if (voiceInteractionHandler.isRecognitionActive) {
val currentTime = System.currentTimeMillis()
val elapsedTime = currentTime - lastActivityTime
// FileLogger.d(TAG, "hasSpeechDetected=${voiceInteractionHandler.hasSpeechDetected}, isTtsSpeaking=${voiceInteractionHandler.isTtsSpeaking}, elapsedTime=${elapsedTime}")
// 如果超过指定时间没有检测到语音,且不在TTS播放中,暂停语音识别
if (!voiceInteractionHandler.hasSpeechDetected &&
!voiceInteractionHandler.isTtsSpeaking &&
elapsedTime >= RECOGNITION_TIMEOUT) {
FileLogger.d(TAG, "超过${RECOGNITION_TIMEOUT/1000}秒未检测到语音,停止识别")
isTimeoutPaused = true
playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。")
// 停止语音识别
voiceInteractionHandler.stopRecognition()
}
}
}
/**
* 处理双击事件
*/
private fun handleDoubleClick() {
FileLogger.d(TAG, "处理双击事件")
//屏蔽短时间重复响应的问题
val currentTime = System.currentTimeMillis()
val timeDiff = currentTime - lastKeyEventTime
if (timeDiff < 500) {
FileLogger.d(TAG, "短时间重复响应,忽略")
return
}
lastKeyEventTime = currentTime
// 停止当前TTS播放
voiceInteractionHandler.stopTts()
setPlaybackState(PlaybackStateCompat.STATE_PLAYING)
// 播放提示音
playPrompt("我在!")
// 设置为播放状态
setPlaybackState(PlaybackStateCompat.STATE_PAUSED)
// 重置超时暂停标志
isTimeoutPaused = false
// 启动语音识别
if (!voiceInteractionHandler.isRecognitionActive) {
FileLogger.d(TAG, "语音识别未激活,开始启动")
// 通知 Flutter 语音识别已启动
notifyRecognitionStarted()
// 启动语音识别
voiceInteractionHandler.startRecognition()
} else {
FileLogger.d(TAG, "语音识别已激活,更新活动时间")
updateLastActivityTime()
}
}
/**
* 播放提示音
*/
private fun playPrompt(message: String) {
voiceInteractionHandler.playTts(message)
}
/**
* 播放通知提示音
*/
private fun playNotification(message: String) {
// 更新播放状态为播放中,增加接收蓝牙按键事件的几率
setPlaybackState(PlaybackStateCompat.STATE_PLAYING)
// 确保媒体会话处于活跃状态
mediaSession.isActive = true
voiceInteractionHandler.playTts(message, object : VoiceInteractionHandler.TtsCallback {
override fun onComplete() {
setPlaybackState(PlaybackStateCompat.STATE_PAUSED)
}
override fun onError(error: String) {
setPlaybackState(PlaybackStateCompat.STATE_PAUSED)
}
})
}
/**
* 更新最后活动时间
*/
private fun updateLastActivityTime() {
lastActivityTime = System.currentTimeMillis()
}
/**
* 设置播放状态
*/
private fun setPlaybackState(state: Int) {
val playbackStateBuilder = PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND
)
// 使用更高的播放速度,让系统认为是真正的播放
.setState(state, 0, 1.0f, System.currentTimeMillis())
mediaSession.setPlaybackState(playbackStateBuilder.build())
// 确保MediaSession激活
if (!mediaSession.isActive) {
mediaSession.isActive = true
}
}
/**
* 启动前台服务
*/
private fun startForegroundServiceWithNotification() {
val notification = createNotification()
startForeground(NOTIFICATION_ID, notification)
}
/**
* 创建通知
*/
private fun createNotification(): Notification {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Android 8.0+ 需要创建通知渠道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"语音交互服务",
NotificationManager.IMPORTANCE_LOW
)
manager.createNotificationChannel(channel)
}
// 点击通知打开MainActivity
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("语音助手")
.setContentText("双击蓝牙耳机按键即可开始语音交互")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.build()
}
/**
* 当 Service 被启动时调用
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
//FileLogger.d(TAG, "onStartCommand: intent=$intent, action=${intent?.action}")
intent?.let {receivedIntent ->
when(receivedIntent.action) {
Intent.ACTION_MEDIA_BUTTON -> {
FileLogger.d(TAG, "onStartCommand中收到媒体按钮事件")
MediaButtonReceiver.handleIntent(mediaSession, receivedIntent)
}
ACTION_PAUSE_VOICE_INTERACTION -> {
// 处理来自 Flutter 的暂停请求
FileLogger.d(TAG, "onStartCommand收到暂停后台交互请求")
pauseVoiceInteraction()
}
else -> {
//FileLogger.d(TAG, "onStartCommand收到未知Intent: action=${receivedIntent.action}")
}
}
}
return START_STICKY
}
@Nullable
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
FileLogger.d(TAG, "onDestroy - 语音交互服务即将销毁")
// 停止服务监控
stopMonitoring()
// 停止语音识别
voiceInteractionHandler.stopRecognition()
// 停止媒体会话
mediaSession.release()
FileLogger.d(TAG, "媒体会话已释放")
// 停止TTS
voiceInteractionHandler.stopTts()
// 释放语音交互处理器资源
voiceInteractionHandler.dispose()
// 关闭音频播放器
audioPlayer?.release()
// 设置服务状态
isRunning.set(false)
FileLogger.d(TAG, "语音交互服务已销毁")
}
/**
* 暂停语音交互(停止TTS和语音识别,但保持服务运行)
* 用于从 Flutter 端控制服务
*/
private fun pauseVoiceInteraction() {
FileLogger.d(TAG, "暂停后台语音交互(来自Flutter的请求)")
// 停止当前TTS播放
voiceInteractionHandler.stopTts()
// 停止语音识别
if (voiceInteractionHandler.isRecognitionActive) {
voiceInteractionHandler.stopRecognition()
}
// 设置为暂停状态,但保持服务活跃
isTimeoutPaused = true
setPlaybackState(PlaybackStateCompat.STATE_PAUSED)
}
/**
* 通知 Flutter 端聊天记录已更新
*/
private fun notifyChatHistoryUpdated(agentId: String, userMessage: String, assistantMessage: String) {
// 创建广播 Intent
val intent = Intent(ACTION_CHAT_HISTORY_UPDATED).apply {
putExtra("agentId", agentId)
putExtra("userMessage", userMessage)
putExtra("assistantMessage", assistantMessage)
putExtra("timestamp", System.currentTimeMillis())
}
// 发送广播
sendBroadcast(intent)
}
/**
* 通知 Flutter 端语音识别已启动
*/
private fun notifyRecognitionStarted() {
FileLogger.d(TAG, "通知Flutter语音识别已启动")
// 创建广播 Intent
val intent = Intent(ACTION_RECOGNITION_STARTED).apply {
// 可以添加额外数据
putExtra("timestamp", System.currentTimeMillis())
putExtra("packageName", applicationContext.packageName)
}
// 发送广播
sendBroadcast(intent)
}
/**
* 简易媒体播放器,用于播放raw中的音频资源
*/
inner class MiniMediaPlayer(private val context: Context) {
private var mediaPlayer: MediaPlayer? = null
/**
* 播放音频资源
* @param resId 资源ID (R.raw.xxx)
* @param volume 音量 0.0-1.0
*/
fun play(resId: Int, volume: Float = 1.0f) {
try {
// 释放之前的资源
release()
// 创建播放器并设置资源
mediaPlayer = MediaPlayer().apply {
// 设置音频资源
context.resources.openRawResourceFd(resId)?.use { fd ->
setDataSource(fd.fileDescriptor, fd.startOffset, fd.length)
}
// 设置音量
setVolume(volume.coerceIn(0f, 1f), volume.coerceIn(0f, 1f))
// 播放完成后自动释放
setOnCompletionListener { release() }
// 准备并播放
prepare()
start()
}
} catch (e: Exception) {
release()
}
}
/**
* 停止播放
*/
fun stop() {
try {
mediaPlayer?.apply {
if (isPlaying) stop()
}
} catch (e: Exception) {
// 忽略异常
}
}
/**
* 释放资源
*/
fun release() {
try {
mediaPlayer?.apply {
if (isPlaying) stop()
release()
}
mediaPlayer = null
} catch (e: Exception) {
// 忽略异常
}
}
/**
* 是否正在播放
*/
fun isPlaying() = mediaPlayer?.isPlaying == true
}
}

144
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/utils.kt

@ -0,0 +1,144 @@
package com.yunqiinnovation.deepsound
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.yunqiinnovation.deepvoice_utils.FileLogger
/**
* 通用工具类,包含安全存储等功能
*/
object Utils {
private const val TAG = "Utils"
private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs"
private const val KEY_SESSION_ID = "session_id"
/**
* 安全存储数据
* @param context 上下文
* @param dataMap 要存储的键值对字典
* @return 是否成功存储
*/
fun saveSecureData(context: Context, dataMap: Map<String, String?>): Boolean {
return try {
// 创建或获取主密钥
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// 创建加密SharedPreferences
val sharedPreferences = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_FILENAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// 获取或生成会话ID
var sessionId = sharedPreferences.getString(KEY_SESSION_ID, "") ?: ""
if (sessionId.isEmpty()) {
sessionId = java.util.UUID.randomUUID().toString()
}
// 开始编辑会话
val editor = sharedPreferences.edit()
// 保存会话ID
editor.putString(KEY_SESSION_ID, sessionId)
// 保存所有键值对
for ((key, value) in dataMap) {
if (value != null) {
editor.putString(key, value)
} else {
editor.remove(key)
}
}
// 提交更改
editor.apply()
FileLogger.d(TAG, "数据已安全保存到加密存储中")
true
} catch (e: Exception) {
FileLogger.e(TAG, "保存数据到加密存储失败", e)
false
}
}
/**
* 从安全存储中读取所有数据
* @param context 上下文
* @return 包含所有数据的键值对字典
*/
fun loadAllSecureData(context: Context): Map<String, String> {
return try {
val resultMap = mutableMapOf<String, String>()
// 创建或获取主密钥
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// 创建加密SharedPreferences
val sharedPreferences = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_FILENAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// 读取所有数据
for ((key, value) in sharedPreferences.all) {
if (value is String) {
resultMap[key] = value
}
}
FileLogger.d(TAG, "已从加密存储加载所有数据")
resultMap
} catch (e: Exception) {
FileLogger.e(TAG, "从加密存储加载所有数据失败", e)
emptyMap()
}
}
/**
* 清除所有安全存储的数据
* @param context 上下文
* @return 是否成功清除
*/
fun clearAllSecureData(context: Context): Boolean {
return try {
// 创建或获取主密钥
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// 创建加密SharedPreferences
val sharedPreferences = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_FILENAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// 生成新的会话ID
val sessionId = java.util.UUID.randomUUID().toString()
// 清除所有数据
sharedPreferences.edit().clear()
.putString(KEY_SESSION_ID, sessionId)
.apply()
FileLogger.d(TAG, "已清除所有安全存储的数据")
true
} catch (e: Exception) {
FileLogger.e(TAG, "清除安全存储数据失败", e)
false
}
}
}

6
android/settings.gradle.kts

@ -33,14 +33,16 @@ plugins {
include(":app") include(":app")
include(":azure_speech") include(":azure_speech")
include(":open_ai_service") include(":open_ai_service")
include(":volcano_speech")
include(":chat_storage") include(":chat_storage")
include(":jieli") include(":jieli")
include(":spotify_sdk") include(":spotify_sdk")
include(":ble_service")
include(":deepvoice_utils")
// 设置azure_speech项目的路径 // 设置azure_speech项目的路径
project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android")
project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android") project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android")
project(":volcano_speech").projectDir = file("../local_plugins/volcano_speech/android")
project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android")
project(":jieli").projectDir = file("../local_plugins/jieli/android") project(":jieli").projectDir = file("../local_plugins/jieli/android")
project(":spotify_sdk").projectDir = file("../local_plugins/spotify_sdk/android") project(":spotify_sdk").projectDir = file("../local_plugins/spotify_sdk/android")
project(":ble_service").projectDir = file("../local_plugins/ble_service/android")
project(":deepvoice_utils").projectDir = file("../local_plugins/deepvoice_utils/android")

3
lib/data/services/speech_factory.dart

@ -6,6 +6,7 @@ import 'speech_impl/azure_tts_service.dart';
import 'speech_impl/flutter_asr_service.dart'; import 'speech_impl/flutter_asr_service.dart';
import 'speech_impl/flutter_tts_service.dart'; import 'speech_impl/flutter_tts_service.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
/// 语音服务类型 /// 语音服务类型
@ -15,6 +16,7 @@ enum SpeechServiceType {
/// Flutter 本地语音服务 /// Flutter 本地语音服务
flutter, flutter,
} }
/// 语音服务工厂类 /// 语音服务工厂类
@ -61,6 +63,7 @@ class SpeechFactory extends GetxService {
Get.lazyPut<AsrService>(() => FlutterAsrService()); Get.lazyPut<AsrService>(() => FlutterAsrService());
Get.lazyPut<TtsService>(() => FlutterTtsService()); Get.lazyPut<TtsService>(() => FlutterTtsService());
break; break;
} }
} }

72
lib/data/services/speech_impl/azure_asr_service.dart

@ -1,25 +1,30 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../asr_service.dart'; import '../asr_service.dart';
/// 音频源类型
enum AudioSourceType {
microphone, // 使用设备麦克风
external // 使用外部提供的音频数据
}
/// Azure 语音识别服务 /// Azure 语音识别服务
/// ///
/// 该服务提供了通过平台通道与 Android 上的 Microsoft Speech SDK 交互的接口 /// 该服务提供了通过平台通道与原生 Microsoft Speech SDK 交互的接口
class AzureAsrService extends GetxService implements AsrService { class AzureAsrService extends GetxService implements AsrService {
static final AzureAsrService to = Get.put(AzureAsrService()); static final AzureAsrService to = Get.put(AzureAsrService());
static const MethodChannel _channel = MethodChannel('azure_speech/asr'); static const MethodChannel _channel = MethodChannel('azure_speech/asr');
static const EventChannel _eventChannel = static const EventChannel _eventChannel = EventChannel('azure_speech/asr_events');
EventChannel('azure_speech/asr_events');
final GetStorage _storage = GetStorage(); final GetStorage _storage = GetStorage();
bool _isInitialized = false; bool _isInitialized = false;
late final String _subscriptionKey; late final String _subscriptionKey;
late final String _serviceRegion; late final String _serviceRegion;
final List<String> _defaultSupportedLanguages = ['zh-CN']; final List<String> _defaultSupportedLanguages = ['zh-CN', 'en-US'];
@override @override
List<String> get supportedLanguages => _defaultSupportedLanguages; List<String> get supportedLanguages => _defaultSupportedLanguages;
@ -36,6 +41,9 @@ class AzureAsrService extends GetxService implements AsrService {
String _latestDetectedLanguage = ''; String _latestDetectedLanguage = '';
String get latestDetectedLanguage => _latestDetectedLanguage; String get latestDetectedLanguage => _latestDetectedLanguage;
// 当前音频源类型
AudioSourceType _audioSourceType = AudioSourceType.microphone;
AzureAsrService() { AzureAsrService() {
_loadConfig(); _loadConfig();
} }
@ -54,6 +62,7 @@ class AzureAsrService extends GetxService implements AsrService {
/// 设置事件通道 /// 设置事件通道
void _setupEventChannel() { void _setupEventChannel() {
_eventSubscription?.cancel();
_eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) { _eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) {
if (event is Map) { if (event is Map) {
_handleRecognitionEvent(event); _handleRecognitionEvent(event);
@ -64,21 +73,28 @@ class AzureAsrService extends GetxService implements AsrService {
@override @override
Future<bool> initialize({ Future<bool> initialize({
List<String>? supportedLanguages, List<String>? supportedLanguages,
bool useExternalAudio = false,
bool useEchoCancellation = false,
}) async { }) async {
try { try {
final List<String> languages = final List<String> languages = supportedLanguages ?? _defaultSupportedLanguages;
supportedLanguages ?? _defaultSupportedLanguages;
// 检查是否需要重新初始化 // 检查是否需要重新初始化
if (_isInitialized) { if (_isInitialized) {
await dispose(); await dispose();
} }
// 设置音频源类型
_audioSourceType = useExternalAudio ? AudioSourceType.external : AudioSourceType.microphone;
final bool result = await _channel.invokeMethod('initialize', { final bool result = await _channel.invokeMethod('initialize', {
'subscriptionKey': _subscriptionKey, 'subscriptionKey': _subscriptionKey,
'region': _serviceRegion, 'region': _serviceRegion,
'supportedLanguages': languages, 'supportedLanguages': languages,
'audioSourceType': _audioSourceType.toString().split('.').last,
'useEchoCancellation': useEchoCancellation,
}); });
_setupEventChannel(); _setupEventChannel();
_isInitialized = result; _isInitialized = result;
@ -98,12 +114,15 @@ class AzureAsrService extends GetxService implements AsrService {
} }
try { try {
// 调用同步的recognizeOnce方法
final result = await _channel.invokeMethod('recognizeOnce'); final result = await _channel.invokeMethod('recognizeOnce');
if (result is Map) { if (result is Map) {
// 处理新格式返回
final String text = result['text'] as String? ?? ''; final String text = result['text'] as String? ?? '';
final String detectedLanguage = final String detectedLanguage = result['detectedLanguage'] as String? ?? '';
result['detectedLanguage'] as String? ?? '';
// 更新最新识别结果
_latestRecognizedText = text; _latestRecognizedText = text;
_latestDetectedLanguage = detectedLanguage; _latestDetectedLanguage = detectedLanguage;
@ -138,8 +157,7 @@ class AzureAsrService extends GetxService implements AsrService {
_eventStreamController = StreamController<RecognitionEvent>.broadcast(); _eventStreamController = StreamController<RecognitionEvent>.broadcast();
// 开始连续识别 // 开始连续识别
final bool result = final bool result = await _channel.invokeMethod('startContinuousRecognition');
await _channel.invokeMethod('startContinuousRecognition');
if (!result) { if (!result) {
_cleanupEventStream(); _cleanupEventStream();
@ -162,8 +180,7 @@ class AzureAsrService extends GetxService implements AsrService {
} }
try { try {
final bool result = final bool result = await _channel.invokeMethod('stopContinuousRecognition');
await _channel.invokeMethod('stopContinuousRecognition');
_cleanupEventStream(); _cleanupEventStream();
return result; return result;
} catch (e) { } catch (e) {
@ -191,8 +208,7 @@ class AzureAsrService extends GetxService implements AsrService {
switch (eventType) { switch (eventType) {
case 'result': case 'result':
final String text = eventMap['text'] as String? ?? ''; final String text = eventMap['text'] as String? ?? '';
final String detectedLanguage = final String detectedLanguage = eventMap['detectedLanguage'] as String? ?? '';
eventMap['detectedLanguage'] as String? ?? '';
_latestRecognizedText = text; _latestRecognizedText = text;
_latestDetectedLanguage = detectedLanguage; _latestDetectedLanguage = detectedLanguage;
_eventStreamController?.add(RecognitionEvent( _eventStreamController?.add(RecognitionEvent(
@ -204,8 +220,7 @@ class AzureAsrService extends GetxService implements AsrService {
case 'recognizing': case 'recognizing':
final String text = eventMap['text'] as String? ?? ''; final String text = eventMap['text'] as String? ?? '';
final String detectedLanguage = final String detectedLanguage = eventMap['detectedLanguage'] as String? ?? '';
eventMap['detectedLanguage'] as String? ?? '';
_eventStreamController?.add(RecognitionEvent( _eventStreamController?.add(RecognitionEvent(
type: RecognitionEventType.intermediateResult, type: RecognitionEventType.intermediateResult,
text: text, text: text,
@ -290,20 +305,29 @@ class AzureAsrService extends GetxService implements AsrService {
} }
@override @override
Future<void> pushAudioData(Uint8List data) async { Future<bool> pushAudioData(Uint8List data) async {
if (!_isInitialized) {
await initialize(useExternalAudio: true);
}
if (_audioSourceType != AudioSourceType.external) {
Logger.error('当前非外部音频模式,不能推送音频数据');
return false;
}
try { try {
// 开始连续识别
final bool result = await _channel.invokeMethod('pushAudioData', { final bool result = await _channel.invokeMethod('pushAudioData', {
'AudioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer 'audioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer
}); });
if (!result) { if (!result) {
_cleanupEventStream(); throw Exception('推送音频数据失败');
throw Exception('开始pushAudioData失败');
} }
return true;
} catch (e) { } catch (e) {
Logger.error('开始pushAudioData失败: $e'); Logger.error('推送音频数据失败: $e');
rethrow; return false;
} }
} }
} }

397
lib/data/services/speech_impl/azure_tts_service.dart

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../tts_service.dart'; import '../tts_service.dart';
@ -14,6 +13,8 @@ import '../tts_service.dart';
class AzureTtsService extends GetxService implements TtsService { class AzureTtsService extends GetxService implements TtsService {
static final AzureTtsService to = Get.put(AzureTtsService()); static final AzureTtsService to = Get.put(AzureTtsService());
static const MethodChannel _channel = MethodChannel('azure_speech/tts'); static const MethodChannel _channel = MethodChannel('azure_speech/tts');
static const EventChannel _eventChannel = EventChannel('azure_speech/tts_events');
final GetStorage _storage = GetStorage(); final GetStorage _storage = GetStorage();
bool _isInitialized = false; bool _isInitialized = false;
late final String _subscriptionKey; late final String _subscriptionKey;
@ -21,56 +22,30 @@ class AzureTtsService extends GetxService implements TtsService {
// 当前设置 // 当前设置
String _currentVoice = 'zh-CN-XiaoxiaoNeural'; String _currentVoice = 'zh-CN-XiaoxiaoNeural';
int _currentRate = 0;
int _currentPitch = 0;
int _currentVolume = 100;
// 支持的语音列表
final List<String> _supportedVoices = [
'zh-CN-XiaoxiaoNeural',
'zh-CN-YunxiNeural',
'zh-CN-YunjianNeural',
'en-US-JennyNeural',
'en-US-GuyNeural'
];
// 语音合成队列 // 语音合成队列
final List<_SpeechItem> _textQueue = []; final List<String> _textQueue = [];
bool _isProcessingQueue = false; bool _isProcessingQueue = false;
// 事件流
final StreamController<TtsEvent> _eventStreamController = StreamController<TtsEvent>.broadcast();
StreamSubscription? _eventSubscription;
// 可观察状态 // 可观察状态
final isEnabled = true.obs; final isEnabled = true.obs;
final _isSpeaking = false.obs;
// 流式文本缓冲区 // 流式文本缓冲区
String _streamBuffer = ''; String _streamBuffer = '';
/// 缓存的播放状态
bool _speakingCache = false;
DateTime _lastSpeakingCheck = DateTime.now();
@override @override
String get currentVoice => _currentVoice; String get currentVoice => _currentVoice;
@override @override
bool get isSpeaking { bool get isSpeaking => _isSpeaking.value;
// 如果未初始化,直接返回false
if (!_isInitialized) return false;
// 如果距离上次检查不到50ms,则使用缓存值
if (DateTime.now().difference(_lastSpeakingCheck).inMilliseconds < 50) {
return _speakingCache;
}
// 否则异步更新缓存(但仍返回当前缓存值)
_checkSpeakingStatus();
return _speakingCache;
}
@override
Stream<TtsEvent> get onEvent => const Stream.empty();
@override @override
List<String> get supportedVoices => _supportedVoices; Stream<TtsEvent> get onEvent => _eventStreamController.stream;
AzureTtsService() { AzureTtsService() {
_loadConfig(); _loadConfig();
@ -83,6 +58,74 @@ class AzureTtsService extends GetxService implements TtsService {
_serviceRegion = _env['AZURE_SPEECH_REGION'] ?? ''; _serviceRegion = _env['AZURE_SPEECH_REGION'] ?? '';
} }
/// 设置事件通道
void _setupEventChannel() {
_eventSubscription?.cancel();
_eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) {
if (event is Map) {
_handleTtsEvent(event);
}
}, onError: (error) {
Logger.error('TTS事件流错误: $error');
_eventStreamController.addError(error);
});
}
/// 处理TTS事件
void _handleTtsEvent(Map<dynamic, dynamic> event) {
final String eventType = event['type'] as String? ?? '';
// Logger.debug('收到TTS事件: $eventType, 数据: $event');
switch (eventType) {
case 'synthesis_started':
_isSpeaking.value = true;
_eventStreamController.add(TtsEvent(type: TtsEventType.started));
break;
case 'synthesizing':
// _isSpeaking.value = true;
// // 合成中事件暂时映射到started类型,因为TtsEventType中没有synthesizing类型
// _eventStreamController.add(TtsEvent(type: TtsEventType.started));
break;
case 'synthesis_completed':
_isSpeaking.value = false;
_eventStreamController.add(TtsEvent(type: TtsEventType.completed));
// 如果有队列在处理中,继续处理下一项
if (_isProcessingQueue) {
_processNextInQueue();
}
break;
case 'synthesis_canceled':
_isSpeaking.value = false;
final String reason = event['reason'] as String? ?? '';
_eventStreamController.add(TtsEvent.error('合成取消: $reason'));
// 如果有队列在处理中,继续处理下一项(即使出错)
if (_isProcessingQueue) {
_processNextInQueue();
}
break;
case 'error':
_isSpeaking.value = false;
final String message = event['message'] as String? ?? '未知错误';
Logger.error('TTS错误: $message');
_eventStreamController.add(TtsEvent.error(message));
// 如果有队列在处理中,继续处理下一项(即使出错)
if (_isProcessingQueue) {
_processNextInQueue();
}
break;
}
}
@override @override
Future<bool> initialize({ Future<bool> initialize({
List<String>? supportedLanguages, List<String>? supportedLanguages,
@ -98,6 +141,9 @@ class AzureTtsService extends GetxService implements TtsService {
return false; return false;
} }
// 先设置事件通道,确保在调用initialize方法前已准备好
_setupEventChannel();
// 更新支持的语音列表(如果提供) // 更新支持的语音列表(如果提供)
final String language = supportedLanguages?.first ?? 'zh-CN'; final String language = supportedLanguages?.first ?? 'zh-CN';
@ -107,7 +153,7 @@ class AzureTtsService extends GetxService implements TtsService {
'language': language, 'language': language,
}); });
_isInitialized = true; _isInitialized = result;
return result; return result;
} catch (e) { } catch (e) {
Logger.error('初始化失败: $e'); Logger.error('初始化失败: $e');
@ -133,99 +179,24 @@ class AzureTtsService extends GetxService implements TtsService {
} }
} }
/// 设置语音参数
Future<bool> setSpeechParams(
{int rate = 0, int pitch = 0, int volume = 100}) async {
if (!_isInitialized) await initialize();
try {
final result = await _channel.invokeMethod('setSpeechParams', {
'rate': rate,
'pitch': pitch,
'volume': volume,
});
if (result) {
_currentRate = rate;
_currentPitch = pitch;
_currentVolume = volume;
}
return result;
} catch (e) {
Logger.error('设置语音参数失败: $e');
return false;
}
}
@override @override
Future<bool> speakOnce(String text) async { Future<bool> speakOnce(String text) async {
if (!_isInitialized) await initialize(); if (!_isInitialized) await initialize();
if (!isEnabled.value || text.isEmpty) return false; if (!isEnabled.value || text.isEmpty) return false;
try { try {
// 主动设置缓存状态为true // 开始合成,传递参数
_speakingCache = true; final result = await _channel.invokeMethod('speakText', {
'text': text,
final result = await _channel.invokeMethod('speakText', {'text': text}); });
// 调用完成后主动检查状态 return result == true;
_checkSpeakingStatus();
return result == "OK";
} catch (e) {
// 发生错误时重置状态
_speakingCache = false;
Logger.error('1语音合成失败: $e');
return false;
}
}
/// 播放SSML
Future<bool> speakSsmlOnce(String ssml) async {
if (!_isInitialized) await initialize();
if (!isEnabled.value || ssml.isEmpty) return false;
try {
// 主动设置缓存状态为true
_speakingCache = true;
final result = await _channel.invokeMethod('speakSsml', {'ssml': ssml});
// 调用完成后主动检查状态
_checkSpeakingStatus();
return result == "OK";
} catch (e) { } catch (e) {
// 发生错误时重置状态 Logger.error('语音合成失败: $e');
_speakingCache = false;
Logger.error('SSML语音合成失败: $e');
return false; return false;
} }
} }
/// 等待语音播放完成
Future<void> waitForSpeechCompletion(
{Duration timeout = const Duration(seconds: 30)}) async {
// 首先检查当前状态
_checkSpeakingStatus();
if (!_speakingCache) return;
final stopwatch = Stopwatch()..start();
while (_speakingCache && stopwatch.elapsed < timeout) {
// 短暂等待后再次检查
await Future.delayed(const Duration(milliseconds: 100));
// 检查状态
await _channel.invokeMethod<bool>('isSpeaking').then((result) {
if (result != null) {
_speakingCache = result;
_lastSpeakingCheck = DateTime.now();
}
});
}
}
@override @override
Future<bool> speakStream(String text) async { Future<bool> speakStream(String text) async {
if (!isEnabled.value || text.isEmpty) return false; if (!isEnabled.value || text.isEmpty) return false;
@ -240,7 +211,7 @@ class AzureTtsService extends GetxService implements TtsService {
// 使用正则表达式匹配句子,包括结束符号 // 使用正则表达式匹配句子,包括结束符号
// 匹配任意字符,直到遇到句子结束符号 // 匹配任意字符,直到遇到句子结束符号
final sentenceRegex = RegExp( final sentenceRegex = RegExp(
r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』])'); r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』"])');
bool hasProcessed = false; bool hasProcessed = false;
@ -260,12 +231,7 @@ class AzureTtsService extends GetxService implements TtsService {
// 处理找到的句子 // 处理找到的句子
for (final sentence in sentences) { for (final sentence in sentences) {
_textQueue.add(_SpeechItem( _textQueue.add(sentence);
text: sentence,
rate: _currentRate,
pitch: _currentPitch,
volume: _currentVolume,
));
hasProcessed = true; hasProcessed = true;
} }
@ -296,12 +262,7 @@ class AzureTtsService extends GetxService implements TtsService {
// 如果缓冲区仍有剩余文本,将其添加到播放队列 // 如果缓冲区仍有剩余文本,将其添加到播放队列
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) {
_textQueue.add(_SpeechItem( _textQueue.add(_streamBuffer);
text: _streamBuffer,
rate: _currentRate,
pitch: _currentPitch,
volume: _currentVolume,
));
// 清空缓冲区 // 清空缓冲区
_streamBuffer = ''; _streamBuffer = '';
@ -315,14 +276,39 @@ class AzureTtsService extends GetxService implements TtsService {
Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条');
// 等待队列处理完成 // 等待队列处理完成
await _checkSpeakingStatus(); final completer = Completer<bool>();
while (_isProcessingQueue || _speakingCache) {
await Future.delayed(const Duration(milliseconds: 100)); // 监听事件流检查队列是否完成
await _checkSpeakingStatus(); final subscription = onEvent.listen((event) {
// 当队列为空且不在处理中,且当前非说话状态,完成操作
if (_textQueue.isEmpty && !_isProcessingQueue && !_isSpeaking.value && !completer.isCompleted) {
completer.complete(true);
}
});
// 如果已经处于空闲状态,直接完成
if (_textQueue.isEmpty && !_isProcessingQueue && !_isSpeaking.value) {
subscription.cancel();
return true;
} }
// 设置超时
final timeout = Timer(const Duration(seconds: 60), () {
if (!completer.isCompleted) {
Logger.warning('等待TTS队列完成超时');
completer.complete(false);
}
});
// 等待完成
final result = await completer.future;
// 清理
subscription.cancel();
timeout.cancel();
Logger.info('TTS队列播放完成'); Logger.info('TTS队列播放完成');
return true; return result;
} catch (e) { } catch (e) {
Logger.error('刷新TTS流失败: $e'); Logger.error('刷新TTS流失败: $e');
return false; return false;
@ -334,36 +320,51 @@ class AzureTtsService extends GetxService implements TtsService {
if (_textQueue.isEmpty || _isProcessingQueue) return; if (_textQueue.isEmpty || _isProcessingQueue) return;
_isProcessingQueue = true; _isProcessingQueue = true;
// 处理队列中的第一项
await _processNextInQueue();
}
/// 处理队列中的下一项
Future<void> _processNextInQueue() async {
// 检查队列是否为空或已禁用
if (_textQueue.isEmpty || !isEnabled.value) {
_textQueue.clear();
_isProcessingQueue = false;
return;
}
try { try {
while (_textQueue.isNotEmpty) { // 获取当前项但暂不从队列中移除
if (!isEnabled.value) { final text = _textQueue.first;
_textQueue.clear();
break; // 开始合成
} final success = await speakOnce(text);
final item = _textQueue.removeAt(0); // 仅当合成操作失败时才从队列移除并继续处理下一项
// 成功的话会等待合成完成事件后自动处理下一项
if (item.rate != _currentRate || if (!success) {
item.pitch != _currentPitch || _textQueue.removeAt(0);
item.volume != _currentVolume) { await _processNextInQueue();
await setSpeechParams( } else {
rate: item.rate, // 成功启动合成,从队列中移除当前项
pitch: item.pitch, // 注意:下一项的处理将在合成完成事件中触发
volume: item.volume, _textQueue.removeAt(0);
);
}
// 使用speakOnce播放当前项
await speakOnce(item.text);
// 等待播放完成
await waitForSpeechCompletion();
} }
} catch (e) { } catch (e) {
Logger.error('处理语音队列出错: $e'); Logger.error('处理语音队列出错: $e');
} finally {
_isProcessingQueue = false; // 出错时移除当前项并尝试继续
if (_textQueue.isNotEmpty) {
_textQueue.removeAt(0);
}
// 如果队列中还有项目,继续处理
if (_textQueue.isNotEmpty) {
await _processNextInQueue();
} else {
_isProcessingQueue = false;
}
} }
} }
@ -372,10 +373,11 @@ class AzureTtsService extends GetxService implements TtsService {
try { try {
_textQueue.clear(); _textQueue.clear();
if (_speakingCache) { if (_isSpeaking.value) {
final result = await _channel.invokeMethod('stopSpeaking'); final result = await _channel.invokeMethod('stopSpeaking');
// 重置缓存状态 // 重置缓存状态
_speakingCache = false; _isSpeaking.value = false;
_isProcessingQueue = false;
return result; return result;
} }
@ -386,38 +388,6 @@ class AzureTtsService extends GetxService implements TtsService {
} }
} }
/// 生成SSML文本
String generateSsml({
required String text,
String? voiceName,
int? rate,
int? pitch,
int? volume,
}) {
final voice = voiceName ?? _currentVoice;
final rateValue = (rate ?? _currentRate).clamp(-100, 100);
final pitchValue = (pitch ?? _currentPitch).clamp(-100, 100);
final volumeValue = (volume ?? _currentVolume).clamp(0, 100);
final rateStr = rateValue == 0
? '0%'
: rateValue < 0
? '${(rateValue * 0.9).round()}%'
: '$rateValue%';
final pitchStr = pitchValue == 0 ? '0%' : '${(pitchValue * 0.5).round()}%';
final volumeStr = "$volumeValue%";
return '''
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN">
<voice name="$voice">
<prosody rate="$rateStr" pitch="$pitchStr" volume="$volumeStr">
$text
</prosody>
</voice>
</speak>
''';
}
/// 切换启用状态 /// 切换启用状态
void toggleEnabled() { void toggleEnabled() {
isEnabled.toggle(); isEnabled.toggle();
@ -430,40 +400,11 @@ class AzureTtsService extends GetxService implements TtsService {
try { try {
await stop(); await stop();
await _eventSubscription?.cancel();
await _channel.invokeMethod('dispose'); await _channel.invokeMethod('dispose');
_isInitialized = false; _isInitialized = false;
} catch (e) { } catch (e) {
Logger.error('释放资源失败: $e'); Logger.error('释放资源失败: $e');
} }
} }
/// 异步检查并更新播放状态缓存
Future<bool> _checkSpeakingStatus() async {
_lastSpeakingCheck = DateTime.now();
try {
final result = await _channel.invokeMethod<bool>('isSpeaking');
if (result != null) {
_speakingCache = result;
}
return _speakingCache;
} catch (e) {
Logger.error('获取播放状态失败: $e');
return _speakingCache;
}
}
}
/// 语音合成项目
class _SpeechItem {
final String text;
final int rate;
final int pitch;
final int volume;
_SpeechItem({
required this.text,
this.rate = 0,
this.pitch = 0,
this.volume = 100,
});
} }

350
lib/data/services/speech_impl/volcano_asr_service.dart

@ -0,0 +1,350 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../asr_service.dart';
/// 火山语音识别服务实现
class VolcanoAsrService extends GetxService implements AsrService {
/// 方法通道
static const MethodChannel _channel = MethodChannel('volcano_speech/asr');
/// 事件通道
static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events');
/// 事件流控制器
final StreamController<RecognitionEvent> _eventStreamController =
StreamController<RecognitionEvent>.broadcast();
/// 事件订阅
StreamSubscription? _eventSubscription;
/// 是否初始化完成
bool _isInitialized = false;
/// 是否正在连续识别
bool _isContinuousRecognitionActive = false;
/// 当前语言
String _currentLanguage = 'zh-CN';
/// 支持的语言列表
final List<String> _supportedLanguages = ['zh-CN', 'en-US'];
@override
List<String> get supportedLanguages => _supportedLanguages;
/// 构造函数
VolcanoAsrService() {
_setupEventListener();
}
/// 初始化语音识别服务
@override
Future<bool> initialize({
List<String>? supportedLanguages,
}) async {
if (_isInitialized) return true;
try {
// 如果提供了支持的语言列表,则替换默认列表
if (supportedLanguages != null && supportedLanguages.isNotEmpty) {
_supportedLanguages.clear();
_supportedLanguages.addAll(supportedLanguages);
}
// 从.env文件读取配置
final appId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? '';
final appToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? '';
final resourceId = dotenv.env['VOLCANO_ASR_RESOURCE_ID'] ?? 'volc.bigasr.sauc.duration';
if (appId.isEmpty || appToken.isEmpty) {
debugPrint('火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量');
return false;
}
// 初始化火山语音ASR
final initResult = await _channel.invokeMethod<bool>('initialize', {
'appId': appId,
'appToken': appToken,
'resourceId': resourceId,
}) ?? false;
if (!initResult) {
debugPrint('火山语音ASR初始化失败');
return false;
}
// 设置默认语言、VAD参数和启用音量返回
await _configureAsr();
_isInitialized = true;
return true;
} catch (e) {
debugPrint('火山语音ASR初始化异常:$e');
return false;
}
}
/// 配置ASR参数
Future<void> _configureAsr() async {
// 设置默认语言
await _channel.invokeMethod<bool>('setLanguage', {
'language': _currentLanguage,
});
}
/// 设置事件监听
void _setupEventListener() {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
(dynamic event) {
if (event is! Map) return;
_handleAsrEvent(Map<String, dynamic>.from(event as Map));
},
onError: (Object error) {
debugPrint('ASR事件流错误: $error');
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.error,
error: '事件流错误: $error',
));
},
);
}
/// 处理ASR事件
void _handleAsrEvent(Map<String, dynamic> event) {
final eventType = event['type'] as String?;
switch (eventType) {
case 'sessionStarted':
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.sessionStarted,
));
break;
case 'sessionStopped':
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.sessionStopped,
));
_isContinuousRecognitionActive = false;
break;
case 'recognizing':
final text = event['text'] as String?;
final language = event['language'] as String?;
if (text != null) {
debugPrint('接收到中间识别结果: $text');
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.intermediateResult,
text: text,
detectedLanguage: language ?? _currentLanguage,
));
}
break;
case 'result':
final text = event['text'] as String?;
final language = event['language'] as String?;
if (text != null) {
debugPrint('接收到最终识别结果: $text');
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.finalResult,
text: text,
detectedLanguage: language ?? _currentLanguage,
));
}
break;
case 'error':
final errorMessage = event['message'] as String?;
debugPrint('接收到识别错误: ${errorMessage ?? "未知错误"}');
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.error,
error: errorMessage ?? '未知错误',
));
_isContinuousRecognitionActive = false;
break;
case 'volumeChanged':
// 音量变化事件仅在日志中输出,不发送到事件流
final volume = event['volume'] as int?;
if (volume != null && volume > 5) { // 仅记录有意义的音量变化
debugPrint('音量变化: $volume');
}
break;
}
}
/// 检查初始化状态
Future<bool> _ensureInitialized() async {
if (!_isInitialized) {
return await initialize();
}
return true;
}
/// 执行一次性语音识别
@override
Future<RecognitionEvent> recognizeOnce() async {
if (!await _ensureInitialized()) {
return RecognitionEvent(
type: RecognitionEventType.error,
error: '语音识别服务未初始化',
);
}
try {
// 创建一个Completer用于等待最终结果
final completer = Completer<RecognitionEvent>();
// 创建一次性事件监听
StreamSubscription? subscription;
subscription = _eventStreamController.stream.listen((event) {
// 仅处理finalResult和error事件
if (event.type == RecognitionEventType.finalResult) {
// 识别成功,返回结果
completer.complete(event);
subscription?.cancel();
} else if (event.type == RecognitionEventType.error) {
// 识别失败,返回错误
completer.complete(event);
subscription?.cancel();
}
});
// 启动一次性识别
final started = await _channel.invokeMethod<bool>('recognizeOnce') ?? false;
if (!started) {
subscription.cancel();
return RecognitionEvent(
type: RecognitionEventType.error,
error: '启动一次性识别失败',
);
}
// 设置超时,防止永久等待
Future.delayed(const Duration(seconds: 30), () {
if (!completer.isCompleted) {
completer.complete(RecognitionEvent(
type: RecognitionEventType.error,
error: '识别超时',
));
subscription?.cancel();
// 停止识别
_channel.invokeMethod('stopRecognize');
}
});
// 等待结果
return await completer.future;
} catch (e) {
// 确保停止识别
try {
await _channel.invokeMethod('stopRecognize');
} catch (_) {}
return RecognitionEvent(
type: RecognitionEventType.error,
error: e.toString(),
);
}
}
/// 开始连续语音识别
@override
Future<Stream<RecognitionEvent>> startContinuousRecognition() async {
if (!await _ensureInitialized()) {
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.error,
error: '语音识别服务未初始化',
));
return _eventStreamController.stream;
}
try {
final success = await _channel.invokeMethod<bool>('startContinuousRecognition') ?? false;
if (success) {
_isContinuousRecognitionActive = true;
} else {
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.error,
error: '启动连续识别失败',
));
}
} catch (e) {
_eventStreamController.add(RecognitionEvent(
type: RecognitionEventType.error,
error: '启动连续识别异常:$e',
));
}
return _eventStreamController.stream;
}
/// 停止连续语音识别
@override
Future<bool> stopContinuousRecognition() async {
if (!_isInitialized || !_isContinuousRecognitionActive) {
return false;
}
try {
final success = await _channel.invokeMethod<bool>('stopContinuousRecognition') ?? false;
if (success) {
_isContinuousRecognitionActive = false;
}
return success;
} catch (e) {
debugPrint('停止连续识别异常:$e');
return false;
}
}
/// 推送音频数据到连续识别中
@override
Future<void> pushAudioData(Uint8List data) async {
if (!_isInitialized || !_isContinuousRecognitionActive) {
return;
}
// 注意:此功能需要在引擎初始化时设置音频输入类型为stream
// 由于当前实现默认使用麦克风输入,此方法暂时不会实际处理数据
debugPrint('警告:当前火山语音ASR使用麦克风输入,pushAudioData方法不会起作用');
}
/// 检查连续识别是否活跃
@override
bool isContinuousRecognitionActive() {
return _isContinuousRecognitionActive;
}
/// 释放资源
@override
Future<void> dispose() async {
if (_isContinuousRecognitionActive) {
await stopContinuousRecognition();
}
await _eventSubscription?.cancel();
_eventSubscription = null;
if (!_eventStreamController.isClosed) {
await _eventStreamController.close();
}
if (_isInitialized) {
await _channel.invokeMethod<bool>('release');
_isInitialized = false;
}
}
}

285
lib/data/services/speech_impl/volcano_tts_service.dart

@ -0,0 +1,285 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../tts_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// 火山语音合成服务实现
class VolcanoTtsService extends GetxService implements TtsService {
/// 方法通道
static const MethodChannel _channel = MethodChannel('volcano_speech/tts');
/// 事件通道
static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events');
/// 音频数据通道
static const EventChannel _audioDataChannel = EventChannel('volcano_speech/tts_audio_data');
/// 事件流控制器
final StreamController<TtsEvent> _eventStreamController =
StreamController<TtsEvent>.broadcast();
/// 音频数据流控制器
final StreamController<Uint8List> _audioDataStreamController =
StreamController<Uint8List>.broadcast();
/// 事件处理器
StreamSubscription? _eventSubscription;
/// 音频数据处理器
StreamSubscription? _audioDataSubscription;
/// 是否初始化完成
bool _isInitialized = false;
/// 当前语音名称
String _currentVoice = "zh_female_qingxin";
/// 是否启用音频回调
bool _isAudioCallbackEnabled = false;
@override
String get currentVoice => _currentVoice;
@override
bool get isSpeaking => _isSpeaking;
/// 是否正在播放
bool _isSpeaking = false;
@override
Stream<TtsEvent> get onEvent => _eventStreamController.stream;
/// 音频数据流
Stream<Uint8List> get onAudioData => _audioDataStreamController.stream;
/// 构造函数
VolcanoTtsService() {
_setupEventListeners();
_setupAudioDataListeners();
}
/// 设置事件监听
void _setupEventListeners() {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
(dynamic event) {
if (event is! Map) return;
final Map<dynamic, dynamic> eventMap = event;
final String? type = eventMap['type'] as String?;
switch (type) {
case 'started':
_isSpeaking = true;
_eventStreamController.add(TtsEvent(type: TtsEventType.started));
break;
case 'completed':
_isSpeaking = false;
_eventStreamController.add(TtsEvent(type: TtsEventType.completed));
break;
case 'canceled':
_isSpeaking = false;
_eventStreamController.add(TtsEvent(type: TtsEventType.canceled));
break;
case 'error':
_isSpeaking = false;
final errorCode = eventMap['errorCode'] as int? ?? 0;
final errorMessage = eventMap['errorMessage'] as String? ?? '未知错误';
_eventStreamController.add(TtsEvent(
type: TtsEventType.error,
error: errorMessage,
));
break;
}
},
onError: (Object error) {
debugPrint('TTS事件流错误: $error');
_eventStreamController.add(TtsEvent(
type: TtsEventType.error,
error: '事件流错误: $error',
));
},
);
}
/// 设置音频数据监听
void _setupAudioDataListeners() {
_audioDataSubscription = _audioDataChannel.receiveBroadcastStream().listen(
(dynamic data) {
if (data is Uint8List) {
_audioDataStreamController.add(data);
}
},
onError: (Object error) {
debugPrint('TTS音频数据流错误: $error');
},
);
}
@override
Future<bool> initialize({List<String>? supportedLanguages}) async {
if (_isInitialized) return true;
try {
// 从.env文件读取配置
final appId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? '';
final appToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? '';
if (appId.isEmpty || appToken.isEmpty) {
debugPrint('火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量');
return false;
}
// 初始化火山语音TTS
final result = await _channel.invokeMethod<bool>('initialize', {
'appId': appId,
'appToken': appToken,
}) ?? false;
if (!result) {
debugPrint('火山语音TTS初始化失败');
return false;
}
_isInitialized = true;
return true;
} catch (e) {
debugPrint('火山语音TTS初始化异常:$e');
return false;
}
}
/// 检查初始化状态
Future<bool> _ensureInitialized() async {
if (!_isInitialized) {
return await initialize();
}
return true;
}
@override
Future<bool> setVoice(String voiceName) async {
if (!await _ensureInitialized()) return false;
try {
final result = await _channel.invokeMethod<bool>('setVoice', {
'voice': voiceName,
}) ?? false;
if (result) {
_currentVoice = voiceName;
}
return result;
} catch (e) {
debugPrint('设置语音失败:$e');
return false;
}
}
/// 设置是否启用音频数据回调
Future<bool> setEnableAudioCallback(bool enable) async {
if (!await _ensureInitialized()) return false;
try {
final result = await _channel.invokeMethod<bool>('enableAudioCallback', {
'enable': enable,
}) ?? false;
if (result) {
_isAudioCallbackEnabled = enable;
}
return result;
} catch (e) {
debugPrint('设置音频回调失败:$e');
return false;
}
}
@override
Future<bool> speakOnce(String text) async {
if (!await _ensureInitialized()) return false;
try {
// 单次播放
final result = await _channel.invokeMethod<bool>('speakOnce', {
'text': text,
}) ?? false;
return result;
} catch (e) {
debugPrint('单次播放失败:$e');
_eventStreamController.add(TtsEvent.error('单次播放失败:$e'));
return false;
}
}
@override
Future<bool> speakStream(String text) async {
if (!await _ensureInitialized()) return false;
try {
// 流式添加文本
final result = await _channel.invokeMethod<bool>('speakStream', {
'text': text,
}) ?? false;
return result;
} catch (e) {
debugPrint('流式播放失败:$e');
_eventStreamController.add(TtsEvent.error('流式播放失败:$e'));
return false;
}
}
@override
Future<bool> flushStream() async {
if (!await _ensureInitialized()) return false;
try {
// 刷新流式文本
return await _channel.invokeMethod<bool>('flushStream') ?? false;
} catch (e) {
debugPrint('刷新流式文本失败:$e');
return false;
}
}
@override
Future<bool> stop() async {
if (!_isInitialized) return false;
try {
return await _channel.invokeMethod<bool>('stop') ?? false;
} catch (e) {
debugPrint('停止播放失败:$e');
return false;
}
}
@override
Future<void> dispose() async {
if (_isSpeaking) {
await stop();
}
await _eventSubscription?.cancel();
await _audioDataSubscription?.cancel();
if (!_eventStreamController.isClosed) {
await _eventStreamController.close();
}
if (!_audioDataStreamController.isClosed) {
await _audioDataStreamController.close();
}
if (_isInitialized) {
await _channel.invokeMethod<bool>('release');
_isInitialized = false;
}
}
}

11
lib/modules/ble_test/bindings/ble_test_binding.dart

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/ble_test_controller.dart';
class BleTestBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<BleTestController>(
() => BleTestController(),
);
}
}

433
lib/modules/ble_test/controllers/ble_test_controller.dart

@ -0,0 +1,433 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:ble_service/ble_service.dart';
/// BLE测试控制器
/// 用于测试BLE服务插件的功能
class BleTestController extends GetxController {
// 设备列表
final deviceList = <BleDevice>[].obs;
// 扫描状态
final isScanning = false.obs;
// 连接状态
final isConnected = false.obs;
final connectionState = 0.obs;
// 设备信息
final connectedDeviceAddress = ''.obs;
final batteryLevel = 0.obs;
final firmwareVersion = ''.obs;
// 状态消息
final statusMessage = ''.obs;
// BLE服务
final _bleService = BleService();
// 订阅
late StreamSubscription _scanSubscription;
late StreamSubscription _connectionSubscription;
late StreamSubscription _dataSubscription;
late StreamSubscription _statusSubscription;
@override
void onInit() {
super.onInit();
_initBleService();
}
/// 初始化BLE服务
Future<void> _initBleService() async {
try {
// 初始化BLE服务
final result = await _bleService.initialize();
if (result) {
statusMessage.value = 'BLE服务初始化成功';
// 注册回调
await _bleService.registerCallback();
// 订阅扫描结果
_scanSubscription = _bleService.onScanResults.listen((devices) {
if (devices.isEmpty) {
deviceList.clear();
return;
}
bool hasChanges = false;
for (final newDevice in devices) {
int existingIndex = deviceList.indexWhere((d) => d.id == newDevice.id);
if (existingIndex >= 0) {
BleDevice existing = deviceList[existingIndex];
if (existing.rssi != newDevice.rssi || existing.name != newDevice.name) {
deviceList[existingIndex] = newDevice;
hasChanges = true;
}
} else {
deviceList.add(newDevice);
hasChanges = true;
}
}
if (hasChanges) {
deviceList.refresh();
}
});
// 订阅连接状态
_connectionSubscription = _bleService.onConnectionChanged.listen((state) {
if (state is int) {
connectionState.value = state;
isConnected.value = state == BleConnectionState.connected;
statusMessage.value = '连接状态变更: ${_getStateString(state)}';
}
});
// 订阅数据
_dataSubscription = _bleService.onDataReceived.listen((data) {
if (data is Map) {
final type = data['type'];
if (type == 'wakeup') {
statusMessage.value = '收到唤醒信号';
} else if (type == 'audioData') {
statusMessage.value = '收到音频数据: ${(data['data'] as Uint8List).length} 字节';
} else if (type == 'rawData') {
statusMessage.value = '收到原始数据: ${(data['data'] as Uint8List).length} 字节';
}
}
});
// 订阅状态更新
_statusSubscription = _bleService.onStatusUpdated.listen((status) {
if (status is Map) {
final type = status['type'];
if (type == 'statusUpdate') {
// 更新状态
connectionState.value = status['connectionState'] as int? ?? 0;
isConnected.value = connectionState.value == BleConnectionState.connected;
batteryLevel.value = status['batteryLevel'] as int? ?? 0;
firmwareVersion.value = status['firmwareVersion'] as String? ?? '';
connectedDeviceAddress.value = status['connectedDevice'] as String? ?? '';
statusMessage.value = '状态更新: ${_getStateString(connectionState.value)}';
} else if (type == 'deviceInfo') {
// 设备信息
final infoType = status['infoType'] as int;
final infoData = status['infoData'] as String;
statusMessage.value = '设备信息: $infoData';
// 处理不同类型的设备信息
if (infoType == 0x80) { // 版本信息
firmwareVersion.value = infoData;
}
}
}
});
// 获取连接状态
_updateConnectionState();
} else {
statusMessage.value = 'BLE服务初始化失败';
}
} catch (e) {
statusMessage.value = '初始化异常: $e';
Get.snackbar('初始化失败', '无法初始化BLE服务: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 获取状态字符串
String _getStateString(int state) {
switch (state) {
case BleConnectionState.disconnected:
return '已断开';
case BleConnectionState.connecting:
return '连接中';
case BleConnectionState.connected:
return '已连接';
case BleConnectionState.disconnecting:
return '断开中';
case BleConnectionState.error:
return '连接错误';
default:
return '未知状态($state)';
}
}
/// 更新连接状态
Future<void> _updateConnectionState() async {
connectionState.value = await _bleService.getConnectionState();
isConnected.value = connectionState.value == BleConnectionState.connected;
if (isConnected.value) {
connectedDeviceAddress.value = await _bleService.getConnectedDeviceAddress() ?? '';
batteryLevel.value = await _bleService.getBatteryLevel();
firmwareVersion.value = await _bleService.getFirmwareVersion() ?? '';
}
}
/// 开始扫描设备
Future<void> startScan() async {
try {
// 清空设备列表
deviceList.clear();
// 设置扫描状态
isScanning.value = true;
statusMessage.value = '开始扫描...';
// 开始扫描
final result = await _bleService.startScan(timeout: 15000);
if (!result) {
statusMessage.value = '扫描失败';
Get.snackbar('扫描失败', '无法启动BLE扫描',
snackPosition: SnackPosition.BOTTOM);
}
} catch (e) {
statusMessage.value = '扫描异常: $e';
Get.snackbar('扫描错误', '扫描过程中出错: $e',
snackPosition: SnackPosition.BOTTOM);
} finally {
// 5秒后自动停止扫描
Future.delayed(const Duration(seconds: 15), () {
if (isScanning.value) {
stopScan();
}
});
}
}
/// 停止扫描设备
Future<void> stopScan() async {
try {
await _bleService.stopScan();
isScanning.value = false;
statusMessage.value = '扫描已停止';
} catch (e) {
statusMessage.value = '停止扫描异常: $e';
Get.snackbar('停止扫描失败', '无法停止BLE扫描: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 连接到设备
Future<void> connectToDevice(String address) async {
try {
statusMessage.value = '正在连接到 $address...';
final result = await _bleService.connect(address);
if (!result) {
statusMessage.value = '连接失败';
Get.snackbar('连接失败', '无法连接到设备',
snackPosition: SnackPosition.BOTTOM);
} else {
connectedDeviceAddress.value = address;
statusMessage.value = '连接成功,正在获取设备信息...';
// 获取设备信息
await _bleService.getDeviceVersion();
await _bleService.getBatteryInfo();
}
} catch (e) {
statusMessage.value = '连接异常: $e';
Get.snackbar('连接错误', '连接过程中出错: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 断开设备连接
Future<void> disconnect() async {
try {
statusMessage.value = '正在断开连接...';
await _bleService.disconnect();
connectedDeviceAddress.value = '';
isConnected.value = false;
statusMessage.value = '已断开连接';
} catch (e) {
statusMessage.value = '断开连接异常: $e';
Get.snackbar('断开连接失败', '无法断开设备连接: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 获取设备版本信息
Future<void> getDeviceVersion() async {
try {
statusMessage.value = '正在获取设备版本...';
await _bleService.getDeviceVersion();
statusMessage.value = '版本获取命令已发送';
} catch (e) {
statusMessage.value = '获取版本异常: $e';
Get.snackbar('获取版本失败', '无法获取设备版本: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 获取电池信息
Future<void> getBatteryInfo() async {
try {
statusMessage.value = '正在获取电池信息...';
await _bleService.getBatteryInfo();
statusMessage.value = '电池信息获取命令已发送';
} catch (e) {
statusMessage.value = '获取电池信息异常: $e';
Get.snackbar('获取电池信息失败', '无法获取电池信息: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 打开编码器
Future<void> openEncoder() async {
try {
statusMessage.value = '正在打开编码器...';
await _bleService.openEncoder();
statusMessage.value = '编码器打开命令已发送';
} catch (e) {
statusMessage.value = '打开编码器异常: $e';
Get.snackbar('操作失败', '无法打开编码器: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 打开解码器
Future<void> openDecoder() async {
try {
statusMessage.value = '正在打开解码器...';
await _bleService.openDecoder();
statusMessage.value = '解码器打开命令已发送';
} catch (e) {
statusMessage.value = '打开解码器异常: $e';
Get.snackbar('操作失败', '无法打开解码器: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 关闭编解码器
Future<void> closeCodec() async {
try {
statusMessage.value = '正在关闭编解码器...';
await _bleService.closeCodec();
statusMessage.value = '编解码器关闭命令已发送';
} catch (e) {
statusMessage.value = '关闭编解码器异常: $e';
Get.snackbar('操作失败', '无法关闭编解码器: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 播放A2DP
Future<void> playA2DP() async {
try {
statusMessage.value = '正在播放A2DP...';
await _bleService.playA2DP();
statusMessage.value = 'A2DP播放命令已发送';
} catch (e) {
statusMessage.value = '播放A2DP异常: $e';
Get.snackbar('操作失败', '无法播放A2DP: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 音量增加
Future<void> volumeUp() async {
try {
statusMessage.value = '正在增加音量...';
await _bleService.volumeUp();
statusMessage.value = '音量增加命令已发送';
} catch (e) {
statusMessage.value = '增加音量异常: $e';
Get.snackbar('操作失败', '无法增加音量: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 音量减小
Future<void> volumeDown() async {
try {
statusMessage.value = '正在减小音量...';
await _bleService.volumeDown();
statusMessage.value = '音量减小命令已发送';
} catch (e) {
statusMessage.value = '减小音量异常: $e';
Get.snackbar('操作失败', '无法减小音量: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 发送音频数据
Future<void> sendAudioData(Uint8List data) async {
try {
statusMessage.value = '正在发送音频数据...';
await _bleService.sendAudioData(data);
statusMessage.value = '音频数据发送成功: ${data.length} 字节';
} catch (e) {
statusMessage.value = '发送音频数据异常: $e';
Get.snackbar('发送失败', '无法发送音频数据: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
/// 清理资源
Future<void> cleanup() async {
try {
await _bleService.cleanup();
} catch (e) {
Get.snackbar('清理失败', '无法清理BLE资源: $e',
snackPosition: SnackPosition.BOTTOM);
}
}
@override
void onClose() {
// 取消所有订阅
_scanSubscription.cancel();
_connectionSubscription.cancel();
_dataSubscription.cancel();
_statusSubscription.cancel();
// 停止扫描
if (isScanning.value) {
stopScan();
}
// 断开连接
if (isConnected.value) {
disconnect();
}
// 清理资源
cleanup();
super.onClose();
}
}

279
lib/modules/ble_test/views/ble_test_view.dart

@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/ble_test_controller.dart';
class BleTestView extends GetView<BleTestController> {
const BleTestView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('BLE测试'),
actions: [
Obx(() => IconButton(
icon: const Icon(Icons.refresh),
onPressed: controller.isScanning.value ? null : controller.startScan,
)),
],
),
body: Column(
children: [
// 状态消息 - 使用AnimatedContainer让过渡更平滑
Obx(() => AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.all(8),
width: double.infinity,
color: Colors.grey[200],
child: Text(
controller.statusMessage.value,
style: const TextStyle(fontSize: 14),
),
)),
// 根据连接状态显示不同的内容
Expanded(
child: Obx(() {
// 保存连接状态到局部变量,避免多次访问反应式变量
final isConnected = controller.isConnected.value;
return isConnected ? _buildConnectedView() : _buildScanView();
}),
),
],
),
);
}
// 扫描视图
Widget _buildScanView() {
return Column(
children: [
// 扫描控制
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Obx(() {
final isScanning = controller.isScanning.value;
return ElevatedButton(
onPressed: isScanning ? null : controller.startScan,
child: Text(isScanning ? '扫描中...' : '开始扫描'),
);
}),
Obx(() => ElevatedButton(
onPressed: controller.isScanning.value
? controller.stopScan
: null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('停止扫描'),
)),
],
),
),
// 设备列表 - 使用RepaintBoundary提高性能
Expanded(
child: RepaintBoundary(
child: Obx(() {
if (controller.deviceList.isEmpty) {
return const Center(
child: Text('没有找到设备,请开始扫描'),
);
}
// 仅在需要时创建列表视图
return ListView.builder(
// 使用常量key避免整体重建
key: const ValueKey('device_list'),
itemCount: controller.deviceList.length,
itemBuilder: (context, index) {
final device = controller.deviceList[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
title: Text(device.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${device.id}'),
if (device.uuids.isNotEmpty)
Text(
'UUID: ${device.uuids.length > 0 ? device.uuids.first : "无"}',
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (device.uuids.length > 1)
Text(
'其他UUID: ${device.uuids.length - 1}个',
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
],
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('RSSI: ${device.rssi}'),
const SizedBox(height: 4),
const Icon(Icons.bluetooth, size: 16),
],
),
onTap: () => controller.connectToDevice(device.id),
),
);
},
);
}),
),
),
],
);
}
// 已连接设备视图
Widget _buildConnectedView() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 设备信息卡片
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() => Text(
'设备地址: ${controller.connectedDeviceAddress.value}',
style: const TextStyle(fontWeight: FontWeight.bold),
)),
const SizedBox(height: 8),
Obx(() => Text('电池电量: ${controller.batteryLevel.value}%')),
const SizedBox(height: 4),
Obx(() => Text('固件版本: ${controller.firmwareVersion.value}')),
const SizedBox(height: 16),
Wrap(
spacing: 8, // 水平间距
runSpacing: 8, // 垂直间距
alignment: WrapAlignment.spaceBetween,
children: [
SizedBox(
width: 110,
child: ElevatedButton(
onPressed: controller.getDeviceVersion,
child: const Text('获取版本'),
),
),
SizedBox(
width: 110,
child: ElevatedButton(
onPressed: controller.getBatteryInfo,
child: const Text('获取电量'),
),
),
SizedBox(
width: 110,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
onPressed: controller.disconnect,
child: const Text('断开连接'),
),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// 音频控制
const Text('音频控制', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.start,
children: [
SizedBox(
width: 140,
child: ElevatedButton(
onPressed: controller.openEncoder,
child: const Text('打开编码器'),
),
),
SizedBox(
width: 140,
child: ElevatedButton(
onPressed: controller.openDecoder,
child: const Text('打开解码器'),
),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.start,
children: [
SizedBox(
width: 140,
child: ElevatedButton(
onPressed: controller.closeCodec,
child: const Text('关闭编解码器'),
),
),
SizedBox(
width: 140,
child: ElevatedButton(
onPressed: controller.playA2DP,
child: const Text('播放A2DP'),
),
),
],
),
const SizedBox(height: 24),
// 音量控制
const Text('音量控制', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.start,
children: [
SizedBox(
width: 140,
child: ElevatedButton(
onPressed: controller.volumeUp,
child: const Text('音量+'),
),
),
SizedBox(
width: 140,
child: ElevatedButton(
onPressed: controller.volumeDown,
child: const Text('音量-'),
),
),
],
),
],
),
);
}
}

6
lib/modules/pairing/controllers/pairing_controller.dart

@ -16,10 +16,6 @@ class PairingController extends GetxController {
final ClassicBluetoothService _bluetoothService = final ClassicBluetoothService _bluetoothService =
Get.find<ClassicBluetoothService>(); Get.find<ClassicBluetoothService>();
// 语音交互服务
final VoiceInteractionService _voiceInteractionService =
Get.find<VoiceInteractionService>();
// 是否开始搜索 // 是否开始搜索
final _isSearching = false.obs; final _isSearching = false.obs;
bool get isSearching => _isSearching.value; bool get isSearching => _isSearching.value;
@ -111,8 +107,6 @@ class PairingController extends GetxController {
Future<void> _initialize() async { Future<void> _initialize() async {
try { try {
// 初始化语音交互服务
_voiceInteractionService.initialize();
isLoading.value = true; isLoading.value = true;
statusDescription.value = 'initializing'.tr; // 正在初始化...; statusDescription.value = 'initializing'.tr; // 正在初始化...;

45
lib/modules/settings/views/settings_view.dart

@ -526,6 +526,45 @@ class SettingsView extends GetView<SettingsController> {
? Colors.white.withOpacity(0.1) ? Colors.white.withOpacity(0.1)
: Colors.grey[200]), : Colors.grey[200]),
// BLE测试
_buildSimpleNavigationSetting(
title: 'BLE蓝牙测试',
subtitle: '测试BLE蓝牙设备扫描、连接和功能',
icon: Icons.bluetooth_searching,
iconBgColor: isDarkMode
? Colors.green[900]!.withOpacity(0.3)
: Colors.green[100]!,
iconColor:
isDarkMode ? Colors.green[300]! : Colors.green[600]!,
titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null,
onTap: () {
Get.toNamed(Routes.bleTest);
},
isDarkMode: isDarkMode,
),
Divider(
height: 1,
color: isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
// 火山语音测试
_buildSimpleNavigationSetting(
title: '语音测试',
subtitle: '测试语音识别和合成功能,支持多种服务实现',
icon: Icons.record_voice_over,
iconBgColor: isDarkMode ? Colors.orange[900]!.withOpacity(0.3) : Colors.orange[100]!,
iconColor: isDarkMode ? Colors.orange[300]! : Colors.orange[600]!,
onTap: () => Get.toNamed(Routes.speechTest),
isDarkMode: isDarkMode,
),
Divider(
height: 1,
color: isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
// 退出登录 // 退出登录
_buildSimpleNavigationSetting( _buildSimpleNavigationSetting(
title: 'logout'.tr, // 退出登录 title: 'logout'.tr, // 退出登录
@ -1152,8 +1191,10 @@ class SettingsView extends GetView<SettingsController> {
final serviceType = controller.speechServiceType.value; final serviceType = controller.speechServiceType.value;
final serviceName = controller.availableSpeechServices final serviceName = controller.availableSpeechServices
.firstWhere( .firstWhere(
(service) => service['type'] == serviceType)['name']; (service) => service['type'] == serviceType,
return Flexible( orElse: () => {'name': 'unknown'})['name'];
return Container(
constraints: BoxConstraints(maxWidth: 120.w), // 限制最大宽度
child: Text( child: Text(
'$serviceName'.tr, '$serviceName'.tr,
style: TextStyle( style: TextStyle(

12
lib/modules/speech_test/bindings/speech_test_binding.dart

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/speech_test_controller.dart';
class SpeechTestBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<SpeechTestController>(
() => SpeechTestController(),
);
}
}

416
lib/modules/speech_test/controllers/speech_test_controller.dart

@ -0,0 +1,416 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/tts_service.dart';
import '../../../data/services/speech_factory.dart';
import '../../../core/utils/logger.dart';
/// 语音测试控制器
class SpeechTestController extends GetxController {
// 语音服务工厂
final SpeechFactory _speechFactory = Get.find<SpeechFactory>();
// 当前使用的TTS服务
late TtsService _ttsService;
// 当前使用的ASR服务
late AsrService _asrService;
// 状态变量
final RxBool isInitialized = false.obs;
final RxBool isProcessing = false.obs;
final RxBool isListening = false.obs;
final RxBool isSpeaking = false.obs;
// 识别结果
final RxString intermediateResult = ''.obs; // 中间结果
final RxString finalResult = ''.obs; // 最终结果
final RxString recognitionStatus = '准备就绪'.obs;
final RxString detectedLanguage = ''.obs; // 检测到的语言
// TTS事件
final RxString ttsStatus = '准备就绪'.obs;
// 当前选择的语音服务类型
final Rx<SpeechServiceType> selectedServiceType = SpeechServiceType.azure.obs;
// 连续识别结果流
StreamSubscription<RecognitionEvent>? _recognitionSubscription;
// TTS事件流
StreamSubscription<TtsEvent>? _ttsEventSubscription;
// 文本控制器
final TextEditingController textController = TextEditingController();
// 连续播放的测试句子
final List<String> testSentences = [
'这是第一句测试语音。',
'这是第二句测试语音,语音合成支持连续播放。',
'这是第三句测试语音,将在连续模式下播放完毕。'
];
@override
void onInit() {
super.onInit();
// 设置默认待播放文本
textController.text = '欢迎使用语音测试,这是一段默认的测试文本。您可以修改此文本,然后点击播放按钮测试语音合成效果。';
_initServices();
}
/// 初始化服务
Future<void> _initServices() async {
try {
isProcessing.value = true;
// 获取服务实例
_ttsService = _speechFactory.getTtsService();
_asrService = _speechFactory.getAsrService();
// 初始化服务
await _ttsService.initialize();
await _asrService.initialize();
// 监听TTS事件
_setupTtsEventListener();
isInitialized.value = true;
ttsStatus.value = '准备就绪';
recognitionStatus.value = '准备就绪';
} catch (e) {
ttsStatus.value = '初始化失败';
recognitionStatus.value = '初始化失败';
Logger.error('语音测试初始化失败: $e');
} finally {
isProcessing.value = false;
}
}
/// 设置TTS事件监听
void _setupTtsEventListener() {
// 取消旧的订阅
_ttsEventSubscription?.cancel();
// 设置新的订阅
_ttsEventSubscription = _ttsService.onEvent.listen((event) {
switch (event.type) {
case TtsEventType.started:
isSpeaking.value = true;
ttsStatus.value = '正在播放...';
break;
case TtsEventType.completed:
isSpeaking.value = false;
ttsStatus.value = '播放完成';
break;
case TtsEventType.canceled:
isSpeaking.value = false;
ttsStatus.value = '播放已取消';
break;
case TtsEventType.error:
isSpeaking.value = false;
ttsStatus.value = '播放错误: ${event.error}';
break;
}
});
}
/// 设置ASR事件监听
void _setupAsrEventListener(Stream<RecognitionEvent> stream) {
// 取消旧的订阅
_recognitionSubscription?.cancel();
// 重置识别结果
intermediateResult.value = '';
finalResult.value = '';
detectedLanguage.value = '';
recognitionStatus.value = '正在等待语音输入...';
// 设置新的订阅
_recognitionSubscription = stream.listen((event) {
switch (event.type) {
case RecognitionEventType.sessionStarted:
isListening.value = true;
recognitionStatus.value = '开始聆听...';
break;
case RecognitionEventType.sessionStopped:
isListening.value = false;
recognitionStatus.value = '聆听已结束';
// 清空中间结果,保留最终结果
intermediateResult.value = '';
break;
case RecognitionEventType.intermediateResult:
// 更新中间结果,显示为斜体或不同颜色
intermediateResult.value = event.text;
if (event.detectedLanguage.isNotEmpty) {
detectedLanguage.value = event.detectedLanguage;
}
recognitionStatus.value = '正在识别...';
break;
case RecognitionEventType.finalResult:
// 更新最终结果,显示为正常文本
finalResult.value = event.text;
intermediateResult.value = ''; // 清空中间结果
if (event.detectedLanguage.isNotEmpty) {
detectedLanguage.value = event.detectedLanguage;
}
recognitionStatus.value = '识别完成';
break;
case RecognitionEventType.error:
isListening.value = false;
recognitionStatus.value = '识别错误: ${event.error}';
break;
case RecognitionEventType.canceled:
isListening.value = false;
recognitionStatus.value = '识别已取消';
// 清空中间结果
intermediateResult.value = '';
break;
}
}, onError: (error) {
isListening.value = false;
recognitionStatus.value = '识别错误: $error';
intermediateResult.value = '';
});
}
/// 切换语音服务类型
void switchServiceType(SpeechServiceType type) async {
if (selectedServiceType.value == type) return;
try {
isProcessing.value = true;
// 停止当前的所有服务状态
await stopAll();
// 切换服务类型
_speechFactory.setServiceType(type);
selectedServiceType.value = type;
// 重新初始化服务
await _initServices();
} catch (e) {
Logger.error('切换语音服务类型失败: $e');
} finally {
isProcessing.value = false;
}
}
/// 停止所有活动
Future<void> stopAll() async {
// 停止ASR
if (isListening.value) {
await stopContinuousRecognition();
}
// 停止TTS
if (isSpeaking.value) {
await stopSpeaking();
}
// 取消所有订阅
await _recognitionSubscription?.cancel();
_recognitionSubscription = null;
}
/// 单次播放文本
Future<void> speakOnce() async {
if (!isInitialized.value || isProcessing.value) return;
final text = textController.text.trim();
if (text.isEmpty) {
ttsStatus.value = '请输入要播放的文本';
return;
}
try {
isProcessing.value = true;
ttsStatus.value = '准备播放...';
final result = await _ttsService.speakOnce(text);
if (!result) {
ttsStatus.value = '播放失败';
}
} catch (e) {
ttsStatus.value = '播放异常: $e';
Logger.error('单次播放异常: $e');
} finally {
isProcessing.value = false;
}
}
/// 连续播放测试
Future<void> speakContinuously() async {
if (!isInitialized.value || isProcessing.value) return;
try {
isProcessing.value = true;
ttsStatus.value = '准备连续播放...';
// 为避免阻塞,使用一个句子接一个句子的方式添加到队列
for (final sentence in testSentences) {
if (!await _ttsService.speakStream(sentence)) {
ttsStatus.value = '添加句子到队列失败';
break;
}
// 给前一个句子一点处理时间
await Future.delayed(const Duration(milliseconds: 50));
}
// 刷新队列,确保播放完成
await _ttsService.flushStream();
} catch (e) {
ttsStatus.value = '连续播放异常: $e';
Logger.error('连续播放异常: $e');
} finally {
isProcessing.value = false;
}
}
/// 停止播放
Future<void> stopSpeaking() async {
if (!isInitialized.value) return;
try {
await _ttsService.stop();
} catch (e) {
ttsStatus.value = '停止播放异常: $e';
Logger.error('停止播放异常: $e');
}
}
/// 一次性识别
Future<void> recognizeOnce() async {
if (!isInitialized.value || isProcessing.value || isListening.value) return;
try {
isProcessing.value = true;
recognitionStatus.value = '开始识别...';
// 清空之前的结果
intermediateResult.value = '';
finalResult.value = '';
detectedLanguage.value = '';
// 显示正在进行的状态
recognitionStatus.value = '正在聆听...';
// 执行一次性识别 - 这是同步等待结果的操作
final result = await _asrService.recognizeOnce();
if (result.isError) {
recognitionStatus.value = '识别错误: ${result.error}';
Logger.error('一次性识别错误: ${result.error}');
} else {
// 更新最终结果
finalResult.value = result.text;
// 更新语言信息
if (result.detectedLanguage.isNotEmpty) {
detectedLanguage.value = result.detectedLanguage;
Logger.debug('检测到语言: ${result.detectedLanguage}');
}
recognitionStatus.value = '识别完成';
}
} catch (e) {
recognitionStatus.value = '识别异常: $e';
Logger.error('一次性识别异常: $e');
} finally {
isProcessing.value = false;
}
}
/// 开始连续识别
Future<void> startContinuousRecognition() async {
if (!isInitialized.value || isProcessing.value || isListening.value) return;
try {
isProcessing.value = true;
// 清空之前的结果
intermediateResult.value = '';
finalResult.value = '';
detectedLanguage.value = '';
recognitionStatus.value = '准备连续识别...';
// 获取识别事件流 - 这启动了异步识别过程
final stream = await _asrService.startContinuousRecognition();
// 设置事件监听 - 异步处理识别结果
_setupAsrEventListener(stream);
} catch (e) {
recognitionStatus.value = '启动连续识别异常: $e';
Logger.error('启动连续识别异常: $e');
isListening.value = false;
intermediateResult.value = '';
finalResult.value = '';
} finally {
isProcessing.value = false;
}
}
/// 停止连续识别
Future<void> stopContinuousRecognition() async {
if (!isInitialized.value || !isListening.value) return;
try {
// 首先更新状态,防止重复调用
isListening.value = false;
recognitionStatus.value = '正在停止识别...';
// 停止识别过程
await _asrService.stopContinuousRecognition();
// 更新UI状态
recognitionStatus.value = '连续识别已停止';
intermediateResult.value = '';
} catch (e) {
recognitionStatus.value = '停止连续识别异常: $e';
Logger.error('停止连续识别异常: $e');
}
}
// 获取识别结果(用于UI显示)
String getDisplayResult() {
// 如果有最终结果,优先显示
String displayText = '';
if (finalResult.value.isNotEmpty) {
displayText = finalResult.value;
}
// 如果有中间结果,添加到最终结果后(可在UI中使用不同样式显示)
if (intermediateResult.value.isNotEmpty) {
if (displayText.isNotEmpty) {
displayText = '$displayText\n\n正在识别: ${intermediateResult.value}';
} else {
displayText = '正在识别: ${intermediateResult.value}';
}
}
return displayText;
}
@override
void onClose() {
// 取消所有订阅
_recognitionSubscription?.cancel();
_ttsEventSubscription?.cancel();
// 释放控制器
textController.dispose();
super.onClose();
}
}

349
lib/modules/speech_test/views/speech_test_view.dart

@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/speech_test_controller.dart';
import '../../../data/services/speech_factory.dart';
class SpeechTestView extends GetView<SpeechTestController> {
const SpeechTestView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('语音测试'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => controller.switchServiceType(controller.selectedServiceType.value),
tooltip: '重新初始化',
),
],
),
body: Obx(() {
return controller.isInitialized.value
? _buildTestContent()
: _buildLoadingView();
}),
);
}
Widget _buildLoadingView() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('初始化语音服务中...'),
],
),
);
}
Widget _buildTestContent() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildServiceTypeSelector(),
const SizedBox(height: 24),
_buildTtsSection(),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
_buildAsrSection(),
],
),
);
}
Widget _buildServiceTypeSelector() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'选择语音服务',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Obx(() {
return Wrap(
spacing: 8,
children: [
for (final type in SpeechServiceType.values)
ChoiceChip(
label: Text(_getServiceTypeName(type)),
selected: controller.selectedServiceType.value == type,
onSelected: (selected) {
if (selected) {
controller.switchServiceType(type);
}
},
),
],
);
}),
],
),
),
);
}
String _getServiceTypeName(SpeechServiceType type) {
switch (type) {
case SpeechServiceType.azure:
return 'Azure语音';
case SpeechServiceType.flutter:
return 'Flutter本地';
}
}
Widget _buildTtsSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'语音合成测试',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextField(
controller: controller.textController,
decoration: const InputDecoration(
labelText: '输入要合成的文本',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 16),
Obx(() {
return Text(
'状态: ${controller.ttsStatus}',
style: TextStyle(
color: _getStatusColor(controller.ttsStatus.value),
fontWeight: FontWeight.bold,
),
);
}),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('单次播放'),
onPressed: controller.isProcessing.value ||
controller.isSpeaking.value
? null
: controller.speakOnce,
);
}),
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.playlist_play),
label: const Text('连续播放测试'),
onPressed: controller.isProcessing.value ||
controller.isSpeaking.value
? null
: controller.speakContinuously,
);
}),
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.stop),
label: const Text('停止'),
onPressed: controller.isSpeaking.value
? controller.stopSpeaking
: null,
);
}),
],
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
const Text(
'连续播放测试会依次播放以下句子:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
for (int i = 0; i < controller.testSentences.length; i++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('${i + 1}. ${controller.testSentences[i]}'),
),
],
),
),
);
}
Widget _buildAsrSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'语音识别测试',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Obx(() {
return Text(
'状态: ${controller.recognitionStatus}',
style: TextStyle(
color: _getStatusColor(controller.recognitionStatus.value),
fontWeight: FontWeight.bold,
),
);
}),
const SizedBox(height: 8),
Obx(() {
final hasLanguage = controller.detectedLanguage.value.isNotEmpty;
return Visibility(
visible: hasLanguage,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'检测语言: ${controller.detectedLanguage.value}',
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.blue,
),
),
),
);
}),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
constraints: const BoxConstraints(minHeight: 100),
width: double.infinity,
child: Obx(() {
final displayText = controller.getDisplayResult();
return RichText(
text: TextSpan(
style: const TextStyle(fontSize: 16, color: Colors.black),
children: [
if (displayText.isEmpty)
const TextSpan(
text: '识别结果将显示在这里',
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
)
else if (displayText.contains('正在识别:'))
TextSpan(
text: displayText.split('正在识别:')[0],
children: [
const TextSpan(
text: '\n\n正在识别: ',
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic,
),
),
TextSpan(
text: displayText.split('正在识别:')[1].trim(),
style: const TextStyle(
color: Colors.blue,
fontStyle: FontStyle.italic,
),
),
],
)
else
TextSpan(text: displayText),
],
),
);
}),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.mic),
label: const Text('一次性识别'),
onPressed: controller.isProcessing.value ||
controller.isListening.value ||
controller.isSpeaking.value
? null
: controller.recognizeOnce,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
),
);
}),
Obx(() {
return ElevatedButton.icon(
icon: Icon(
controller.isListening.value
? Icons.mic_off
: Icons.mic_none,
),
label: Text(
controller.isListening.value ? '停止连续识别' : '开始连续识别',
),
onPressed: controller.isProcessing.value ||
controller.isSpeaking.value
? null
: controller.isListening.value
? controller.stopContinuousRecognition
: controller.startContinuousRecognition,
style: ElevatedButton.styleFrom(
backgroundColor: controller.isListening.value
? Colors.red
: Colors.blue,
),
);
}),
],
),
],
),
),
);
}
Color _getStatusColor(String status) {
if (status.contains('错误') || status.contains('失败')) {
return Colors.red;
} else if (status.contains('完成') || status.contains('就绪')) {
return Colors.green;
} else if (status.contains('正在')) {
return Colors.blue;
} else {
return Colors.black;
}
}
}

14
lib/routes/app_pages.dart

@ -37,6 +37,10 @@ import '../modules/jieli_test/views/jieli_test_view.dart';
import '../modules/jieli_test/bindings/jieli_test_binding.dart'; import '../modules/jieli_test/bindings/jieli_test_binding.dart';
import '../modules/opus_test/views/opus_test_view.dart'; import '../modules/opus_test/views/opus_test_view.dart';
import '../modules/opus_test/bindings/opus_test_binding.dart'; import '../modules/opus_test/bindings/opus_test_binding.dart';
import '../modules/ble_test/bindings/ble_test_binding.dart';
import '../modules/ble_test/views/ble_test_view.dart';
import '../modules/speech_test/views/speech_test_view.dart';
import '../modules/speech_test/bindings/speech_test_binding.dart';
abstract class AppPages { abstract class AppPages {
static final pages = [ static final pages = [
@ -140,5 +144,15 @@ abstract class AppPages {
page: () => const OpusTestView(), page: () => const OpusTestView(),
binding: OpusTestBinding(), binding: OpusTestBinding(),
), ),
GetPage(
name: Routes.bleTest,
page: () => const BleTestView(),
binding: BleTestBinding(),
),
GetPage(
name: Routes.speechTest,
page: () => const SpeechTestView(),
binding: SpeechTestBinding(),
),
]; ];
} }

2
lib/routes/app_routes.dart

@ -27,4 +27,6 @@ abstract class Routes {
static const feedback = '/feedback'; static const feedback = '/feedback';
static const jieliTest = '/jieli_test'; static const jieliTest = '/jieli_test';
static const opusTest = '/opus_test'; static const opusTest = '/opus_test';
static const bleTest = '/ble_test';
static const speechTest = '/speech_test';
} }

42
local_plugins/agent_service/android/build.gradle.kts

@ -0,0 +1,42 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.yunqiinnovation.agent_service"
compileSdk = 33
defaultConfig {
minSdk = 21
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
sourceSets {
getByName("main") {
manifest.srcFile("src/main/AndroidManifest.xml")
java.srcDirs("src/main/kotlin")
}
}
// 添加lint选项
lintOptions {
isCheckReleaseBuilds = false
}
}
dependencies {
add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10")
add("implementation", "androidx.core:core-ktx:1.8.0")
add("implementation", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
// 依赖本地其他插件
add("compileOnly", project(":azure_speech"))
add("compileOnly", project(":open_ai_service"))
add("compileOnly", project(":common_utils"))
add("compileOnly", project(":chat_storage"))
}

13
local_plugins/agent_service/android/settings.gradle.kts

@ -0,0 +1,13 @@
rootProject.name = "agent_service"
// 包含项目依赖
include(":azure_speech")
include(":open_ai_service")
include(":common_utils")
include(":chat_storage")
// 设置项目路径
project(":azure_speech").projectDir = file("../../azure_speech/android")
project(":open_ai_service").projectDir = file("../../open_ai_service/android")
project(":common_utils").projectDir = file("../../common_utils/android")
project(":chat_storage").projectDir = file("../../chat_storage/android")

5
local_plugins/agent_service/android/src/main/AndroidManifest.xml

@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yunqiinnovation.agent_service">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

577
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -0,0 +1,577 @@
package com.yunqiinnovation.agent_service
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import com.yunqiinnovation.azure_speech.AzureAsrHelper
import com.yunqiinnovation.azure_speech.AzureTtsHelper
import com.yunqiinnovation.open_ai_service.OpenAIService
import com.yunqiinnovation.chat_storage.ChatStorageHelper
import com.yunqiinnovation.common_utils.FileLogger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
/**
* 代理服务事件监听接口
*/
interface AgentServiceListener {
/**
* 当有事件发生时调用此方法
*
* @param eventName 事件名称
* @param data 事件数据
*/
fun onEvent(eventName: String, data: Map<String, Any>)
}
/**
* 代理服务 - 处理语音识别、TTS和AI对话相关逻辑
*/
class AgentService(
private val context: Context,
private var listener: AgentServiceListener? = null
) : CoroutineScope {
private val TAG = "AgentService"
// 协程相关
private val job = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
// 配置参数
private var azureSpeechKey: String = ""
private var azureSpeechRegion: String = ""
private var openaiApiKey: String = ""
private var openaiBaseUrl: String = ""
private var openaiModel: String = "gpt-3.5-turbo"
// Azure服务
private var azureAsrHelper: AzureAsrHelper? = null
private var azureTtsHelper: AzureTtsHelper? = null
// OpenAI服务
private val openAIService = OpenAIService(context.applicationContext)
// 聊天存储服务
private val chatStorageHelper = ChatStorageHelper(context)
// 会话ID,用于区分不同聊天上下文
private val sessionId = "default_session"
// 历史聊天消息缓存
private val historyMessages = JSONArray()
// 状态
private var isInitialized = false
var isRecognitionActive = false
private set
var isTtsSpeaking = false
private set
var hasSpeechDetected = false
private set
/**
* 设置事件监听器
*/
fun setListener(listener: AgentServiceListener?) {
this.listener = listener
}
/**
* 初始化
* @param config 配置参数,包含所需的所有API密钥和设置
*/
fun initialize(config: Map<String, Any>): Boolean {
if (isInitialized) return true
try {
// 从配置中获取参数
azureSpeechKey = config["azureSpeechKey"] as? String ?: ""
azureSpeechRegion = config["azureSpeechRegion"] as? String ?: ""
openaiApiKey = config["openaiApiKey"] as? String ?: ""
openaiBaseUrl = config["openaiBaseUrl"] as? String ?: ""
openaiModel = config["openaiModel"] as? String ?: "gpt-3.5-turbo"
// 检查必要参数
if (azureSpeechKey.isEmpty() || azureSpeechRegion.isEmpty() || openaiApiKey.isEmpty()) {
FileLogger.e(TAG, "初始化失败:关键配置参数缺失")
return false
}
// 初始化Azure ASR
azureAsrHelper = AzureAsrHelper(context).apply {
initialize(
subscriptionKey = azureSpeechKey,
region = azureSpeechRegion,
audioSourceType = AzureAsrHelper.AudioSourceType.MICROPHONE
)
}
// 初始化Azure TTS
azureTtsHelper = AzureTtsHelper(context).apply {
initialize(
subscriptionKey = azureSpeechKey,
region = azureSpeechRegion
)
// 设置TTS回调,更新状态
setTtsCallback(object : AzureTtsHelper.TtsCallback {
override fun onSynthesisStarted() {
sendEvent("tts_started", mapOf("status" to "started"))
}
override fun onSynthesizing() {}
override fun onAudioDataReceived(audioData: ByteArray) {}
override fun onSynthesisCompleted() {
isTtsSpeaking = false
sendEvent("tts_completed", mapOf("status" to "completed"))
}
override fun onSynthesisCanceled() {
isTtsSpeaking = false
sendEvent("tts_canceled", mapOf("status" to "canceled"))
}
})
}
// 初始化OpenAI服务
openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel)
// 加载最近的聊天记录
loadChatHistory()
isInitialized = true
FileLogger.d(TAG, "代理服务初始化成功")
return true
} catch (e: Exception) {
FileLogger.e(TAG, "初始化失败", e)
return false
}
}
/**
* 开始语音识别
*/
fun startRecognition(): Boolean {
if (!isInitialized) {
FileLogger.e(TAG, "服务未初始化")
return false
}
if (isRecognitionActive) return true
// 检查录音权限
if (!checkRecordAudioPermission()) {
FileLogger.e(TAG, "需要录音权限,请在设置中授予权限")
sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限"))
return false
}
isRecognitionActive = true
hasSpeechDetected = false
try {
azureAsrHelper?.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback {
override fun onRecognizing(recognizing: String, detectedLanguage: String) {
if (recognizing.isNotEmpty()) {
hasSpeechDetected = true
sendEvent("recognizing", mapOf(
"text" to recognizing,
"language" to detectedLanguage
))
if (isTtsSpeaking) {
stopTts()
}
}
}
override fun onResult(result: String, detectedLanguage: String) {
if (result.isNotEmpty()) {
sendEvent("recognition_result", mapOf(
"text" to result,
"language" to detectedLanguage
))
processWithOpenAI(result)
}
// 重置状态,继续识别
hasSpeechDetected = false
}
override fun onSessionStarted() {
sendEvent("recognition_started", mapOf("status" to "started"))
}
override fun onSessionStopped() {
isRecognitionActive = false
sendEvent("recognition_stopped", mapOf("status" to "stopped"))
}
override fun onCanceled(reason: String, errorDetails: String) {
isRecognitionActive = false
sendEvent("recognition_canceled", mapOf(
"reason" to reason,
"details" to errorDetails
))
}
override fun onError(error: String) {
isRecognitionActive = false
FileLogger.e(TAG, "语音识别出错: $error")
sendEvent("error", mapOf(
"code" to "RECOGNITION_ERROR",
"message" to error
))
}
})
return true
} catch (e: Exception) {
isRecognitionActive = false
FileLogger.e(TAG, "启动语音识别失败", e)
sendEvent("error", mapOf(
"code" to "RECOGNITION_START_ERROR",
"message" to e.message.toString()
))
return false
}
}
/**
* 停止语音识别
*/
fun stopRecognition() {
if (!isRecognitionActive) return
FileLogger.d(TAG, "停止语音识别")
try {
azureAsrHelper?.stopContinuousRecognition()
isRecognitionActive = false
FileLogger.d(TAG, "语音识别已停止")
} catch (e: Exception) {
FileLogger.e(TAG, "停止语音识别异常", e)
isRecognitionActive = false
}
}
/**
* 处理文本输入
* 作为语音输入的补充,直接处理文本并通过事件返回结果
*
* @param text 用户输入文本
* @param speakResponse 是否朗读回复,默认为false
* @return 是否成功开始处理
*/
fun processTextInput(text: String, speakResponse: Boolean = false): Boolean {
if (!isInitialized) {
FileLogger.e(TAG, "服务未初始化")
sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化"))
return false
}
if (text.isEmpty()) {
FileLogger.e(TAG, "文本输入不能为空")
sendEvent("error", mapOf("code" to "EMPTY_TEXT", "message" to "文本输入不能为空"))
return false
}
// 发送与语音识别结果相同格式的事件
sendEvent("recognition_result", mapOf(
"text" to text,
"language" to "zh-CN", // 默认语言,未来可以从配置或检测中获取
"inputType" to "text"
))
// 使用OpenAI处理文本
processWithOpenAI(text, speakResponse)
return true
}
/**
* 使用OpenAI处理语音识别结果
*/
private fun processWithOpenAI(text: String) {
processWithOpenAI(text, true)
}
/**
* 使用OpenAI处理文本消息
*
* @param text 用户输入文本
* @param speakResponse 是否使用TTS朗读回复
*/
private fun processWithOpenAI(
text: String,
speakResponse: Boolean = true
) {
FileLogger.d(TAG, "用户问题: $text")
launch(Dispatchers.IO) {
try {
// 创建用户消息
val userMessage = openAIService.createUserMessage(text)
// 将用户消息添加到历史记录
addToHistoryMessages(userMessage)
// 使用历史记录作为上下文发送到OpenAI
val responseBuilder = StringBuilder()
openAIService.sendMessageStream(
messages = historyMessages,
callback = object : OpenAIService.StreamCallback {
override fun onToken(token: String) {
responseBuilder.append(token)
// 发送流式回复token
sendEvent("assistant_token", mapOf("token" to token))
}
override fun onComplete() {
val response = responseBuilder.toString()
if (response.isNotEmpty()) {
launch {
FileLogger.d(TAG, "AI回复: $response")
// 发送完整回复
sendEvent("assistant_response", mapOf(
"text" to response,
"userInput" to text
))
// 添加AI回复到历史记录
addToHistoryMessages(openAIService.createAssistantMessage(response))
// 保存聊天记录
saveChatMessage(text, response)
// 视情况决定是否朗读回复
if (speakResponse) {
withContext(Dispatchers.Main) {
speakText(response)
}
}
}
}
}
override fun onError(e: Exception) {
launch {
FileLogger.e(TAG, "AI处理出错", e)
sendEvent("error", mapOf(
"code" to "AI_ERROR",
"message" to e.message.toString()
))
}
}
override fun onFunctionCall(call: JSONObject) {
FileLogger.d(TAG, "收到函数调用: ${call.getString("name")}")
sendEvent("function_call", mapOf(
"name" to call.getString("name"),
"arguments" to call.toString()
))
}
}
)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
FileLogger.e(TAG, "AI处理出错", e)
sendEvent("error", mapOf(
"code" to "AI_PROCESS_ERROR",
"message" to e.message.toString()
))
}
}
}
}
/**
* 加载最近的聊天历史记录
*/
private fun loadChatHistory() {
// 清空当前历史记录
historyMessages.put(JSONArray())
try {
// 使用新的ChatStorageHelper直接获取最近消息
val recentMessages = chatStorageHelper.getRecentMessages(sessionId, 10)
if (recentMessages.isEmpty()) {
FileLogger.d(TAG, "没有找到历史记录")
return
}
// 清空当前历史并转换为OpenAI消息格式
historyMessages.put(JSONArray())
// 将消息添加到历史记录
for (message in recentMessages) {
val sender = message["sender"] as String
val content = message["message"] as String
if (sender == "user") {
addToHistoryMessages(openAIService.createUserMessage(content))
} else if (sender == "assistant") {
addToHistoryMessages(openAIService.createAssistantMessage(content))
}
}
FileLogger.d(TAG, "已加载${recentMessages.size}条历史记录")
} catch (e: Exception) {
FileLogger.e(TAG, "加载聊天历史失败", e)
}
}
/**
* 添加消息到历史记录,保持最近10条
*/
private fun addToHistoryMessages(message: JSONObject) {
// 添加新消息
historyMessages.put(message)
// 如果超过10条,删除最早的消息
if (historyMessages.length() > 10) {
// 创建新数组
val newHistory = JSONArray()
// 保留最近的10条消息
for (i in historyMessages.length() - 10 until historyMessages.length()) {
newHistory.put(historyMessages.getJSONObject(i))
}
// 清空并重新添加
historyMessages.put(JSONArray())
for (i in 0 until newHistory.length()) {
historyMessages.put(newHistory.getJSONObject(i))
}
}
}
/**
* TTS播放函数
*
* @param text 要播放的文本
*/
fun speakText(text: String) {
if (text.isEmpty()) return
// 更新状态
isTtsSpeaking = true
// 播放文本
azureTtsHelper?.speakText(text)
}
/**
* 停止TTS播放
*/
fun stopTts() {
if (isTtsSpeaking) {
azureTtsHelper?.stopSpeaking()
isTtsSpeaking = false
sendEvent("tts_stopped", mapOf("status" to "stopped"))
}
}
/**
* 保存聊天消息
*/
private fun saveChatMessage(userMessage: String, assistantMessage: String) {
CoroutineScope(Dispatchers.IO).launch {
try {
// 保存用户消息
val userMessageId = chatStorageHelper.saveMessage(
sessionId = sessionId,
message = userMessage,
sender = "user"
)
if (userMessageId != -1L) {
// 保存AI回复
val assistantMessageId = chatStorageHelper.saveMessage(
sessionId = sessionId,
message = assistantMessage,
sender = "assistant"
)
if (assistantMessageId != -1L) {
FileLogger.d(TAG, "聊天记录已保存:用户消息ID=$userMessageId, 助手消息ID=$assistantMessageId")
} else {
FileLogger.e(TAG, "保存助手消息失败")
}
} else {
FileLogger.e(TAG, "保存用户消息失败")
}
} catch (e: Exception) {
FileLogger.e(TAG, "保存聊天记录异常", e)
}
}
}
/**
* 清除聊天历史
*/
fun clearChatHistory(callback: (Boolean) -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
try {
// 清除指定会话的所有消息
val success = chatStorageHelper.deleteMessages(sessionId)
if (success) {
// 清空内存中的历史记录
historyMessages.put(JSONArray())
FileLogger.d(TAG, "聊天历史已清除")
} else {
FileLogger.e(TAG, "清除聊天历史失败")
}
withContext(Dispatchers.Main) {
callback(success)
}
} catch (e: Exception) {
FileLogger.e(TAG, "清除聊天历史异常", e)
withContext(Dispatchers.Main) {
callback(false)
}
}
}
}
/**
* 发送事件
*/
private fun sendEvent(eventName: String, data: Map<String, Any>) {
// 使用监听器回调发送事件
listener?.onEvent(eventName, data)
}
/**
* 释放资源
*/
fun dispose() {
stopRecognition()
stopTts()
azureAsrHelper?.dispose()
azureTtsHelper?.dispose()
job.cancel()
FileLogger.d(TAG, "代理服务资源已释放")
}
/**
* 检查录音权限
*/
private fun checkRecordAudioPermission(): Boolean {
val permission = android.Manifest.permission.RECORD_AUDIO
val result = context.checkCallingOrSelfPermission(permission)
return result == android.content.pm.PackageManager.PERMISSION_GRANTED
}
}

212
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt

@ -0,0 +1,212 @@
package com.yunqiinnovation.agent_service
import android.content.Context
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.EventChannel
import com.yunqiinnovation.common_utils.FileLogger
import org.json.JSONObject
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler, AgentServiceListener {
private val TAG = "AgentServicePlugin"
private lateinit var context: Context
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private var agentService: AgentService? = null
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.agent_service")
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.agent_service/events")
eventChannel.setStreamHandler(this)
FileLogger.d(TAG, "插件已加载")
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"initialize" -> {
try {
val config = call.argument<Map<String, Any>>("config")
if (config == null) {
result.error("INVALID_ARGUMENTS", "配置参数不能为空", null)
return
}
// 创建代理服务实例
if (agentService == null) {
agentService = AgentService(context, this)
} else {
// 更新监听器
agentService?.setListener(this)
}
// 调用初始化方法,传入配置参数
val isInitialized = agentService?.initialize(config) ?: false
result.success(isInitialized)
} catch (e: Exception) {
FileLogger.e(TAG, "初始化失败", e)
result.error("INIT_ERROR", "初始化失败: ${e.message}", null)
}
}
"startConversation" -> {
if (agentService == null) {
result.error("NOT_INITIALIZED", "服务未初始化", null)
return
}
try {
val isStarted = agentService?.startRecognition() ?: false
result.success(isStarted)
} catch (e: Exception) {
FileLogger.e(TAG, "启动对话失败", e)
result.error("START_ERROR", "启动对话失败: ${e.message}", null)
}
}
"stopConversation" -> {
if (agentService == null) {
result.error("NOT_INITIALIZED", "服务未初始化", null)
return
}
try {
agentService?.stopRecognition()
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "停止对话失败", e)
result.error("STOP_ERROR", "停止对话失败: ${e.message}", null)
}
}
"processTextInput" -> {
if (agentService == null) {
result.error("NOT_INITIALIZED", "服务未初始化", null)
return
}
val text = call.argument<String>("text")
if (text.isNullOrEmpty()) {
result.error("INVALID_ARGUMENTS", "文本不能为空", null)
return
}
val speakResponse = call.argument<Boolean>("speakResponse") ?: false
try {
val isProcessing = agentService?.processTextInput(
text = text,
speakResponse = speakResponse
) ?: false
result.success(isProcessing)
} catch (e: Exception) {
FileLogger.e(TAG, "处理文本输入失败", e)
result.error("TEXT_PROCESS_ERROR", "处理文本输入失败: ${e.message}", null)
}
}
"speakText" -> {
if (agentService == null) {
result.error("NOT_INITIALIZED", "服务未初始化", null)
return
}
val text = call.argument<String>("text")
if (text.isNullOrEmpty()) {
result.error("INVALID_ARGUMENTS", "文本不能为空", null)
return
}
try {
agentService?.speakText(text)
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "语音合成失败", e)
result.error("TTS_ERROR", "语音合成失败: ${e.message}", null)
}
}
"stopTts" -> {
if (agentService == null) {
result.error("NOT_INITIALIZED", "服务未初始化", null)
return
}
try {
agentService?.stopTts()
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "停止语音合成失败", e)
result.error("STOP_TTS_ERROR", "停止语音合成失败: ${e.message}", null)
}
}
"clearChatHistory" -> {
if (agentService == null) {
result.error("NOT_INITIALIZED", "服务未初始化", null)
return
}
try {
agentService?.clearChatHistory { success ->
result.success(success)
}
} catch (e: Exception) {
FileLogger.e(TAG, "清除聊天历史失败", e)
result.error("CLEAR_HISTORY_ERROR", "清除聊天历史失败: ${e.message}", null)
}
}
"dispose" -> {
try {
agentService?.dispose()
agentService = null
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "释放资源失败", e)
result.error("DISPOSE_ERROR", "释放资源失败: ${e.message}", null)
}
}
else -> {
result.notImplemented()
}
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
this.eventSink = events
}
override fun onCancel(arguments: Any?) {
this.eventSink = null
}
/**
* 实现AgentServiceListener接口
* 接收来自AgentService的事件,并转发到Flutter
*/
override fun onEvent(eventName: String, data: Map<String, Any>) {
// 将事件转换为JSON字符串
val eventData = JSONObject().apply {
put("event", eventName)
put("data", JSONObject(data))
}
// 在主线程发送事件到Flutter
MainScope().launch {
eventSink?.success(eventData.toString())
}
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
agentService?.setListener(null)
agentService?.dispose()
agentService = null
}
}

34
local_plugins/agent_service/pubspec.yaml

@ -0,0 +1,34 @@
name: agent_service
description: 语音代理服务插件,集成了OpenAI对话和Azure语音能力
version: 0.0.1
homepage:
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: ">=2.5.0"
dependencies:
flutter:
sdk: flutter
# 依赖本地插件
open_ai_service:
path: ../open_ai_service
azure_speech:
path: ../azure_speech
common_utils:
path: ../common_utils
chat_storage:
path: ../chat_storage
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
# 插件平台配置
flutter:
plugin:
platforms:
android:
package: com.yunqiinnovation.agent_service
pluginClass: AgentServicePlugin

752
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt

File diff suppressed because it is too large

338
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -1,25 +1,30 @@
package com.yunqiinnovation.azure_speech package com.yunqiinnovation.azure_speech
import android.content.Context import android.content.Context
import android.app.Activity
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import androidx.annotation.NonNull import androidx.annotation.NonNull
import com.yunqiinnovation.azure_speech.utils.FileLogger import com.yunqiinnovation.azure_speech.utils.FileLogger
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
import com.microsoft.cognitiveservices.speech.audio.*
/** AzureSpeechPlugin */ /** AzureSpeechPlugin */
class AzureSpeechPlugin: FlutterPlugin { class AzureSpeechPlugin: FlutterPlugin, CoroutineScope {
private val TAG = "AzureSpeechPlugin" private val tag = "AzureSpeechPlugin"
private lateinit var context: Context private lateinit var context: Context
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
// 协程相关
private val job = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
// ASR相关 // ASR相关
private lateinit var asrChannel : MethodChannel private lateinit var asrChannel : MethodChannel
@ -29,26 +34,44 @@ class AzureSpeechPlugin: FlutterPlugin {
// TTS相关 // TTS相关
private lateinit var ttsChannel : MethodChannel private lateinit var ttsChannel : MethodChannel
private lateinit var ttsEventChannel: EventChannel
private var ttsEventSink: EventChannel.EventSink? = null
private lateinit var azureTtsHelper: AzureTtsHelper private lateinit var azureTtsHelper: AzureTtsHelper
// ASR 事件发送方法 // ASR 事件发送方法
private fun sendAsrEvent(event: Map<String, Any>) { private fun sendAsrEvent(event: Map<String, Any>) {
FileLogger.d(TAG, "发送ASR事件: $event")
if (asrEventSink == null) { if (asrEventSink == null) {
FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") FileLogger.w(tag, "无法发送ASR事件:事件通道未准备好")
return return
} }
mainHandler.post { mainHandler.post {
try { try {
asrEventSink?.success(event) asrEventSink?.success(event)
FileLogger.d(TAG, "ASR事件发送成功")
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "发送ASR事件失败: ${e.message}") FileLogger.e(tag, "发送ASR事件失败: ${e.message}")
} }
} }
} }
// TTS 事件发送方法
private fun sendTtsEvent(event: Map<String, Any>) {
if (ttsEventSink == null) {
FileLogger.w(tag, "无法发送TTS事件:事件通道未准备好")
return
}
// 确保在主线程上发送事件
mainHandler.post {
try {
ttsEventSink?.success(event)
} catch (e: Exception) {
FileLogger.e(tag, "发送TTS事件失败: ${e.message}")
}
}
}
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext context = flutterPluginBinding.applicationContext
@ -71,6 +94,18 @@ class AzureSpeechPlugin: FlutterPlugin {
asrEventSink = null asrEventSink = null
} }
}) })
// 初始化TTS事件通道
ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/tts_events")
ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
ttsEventSink = events
}
override fun onCancel(arguments: Any?) {
ttsEventSink = null
}
})
// 初始化Azure语音服务 // 初始化Azure语音服务
azureTtsHelper = AzureTtsHelper(context) azureTtsHelper = AzureTtsHelper(context)
@ -85,46 +120,62 @@ class AzureSpeechPlugin: FlutterPlugin {
val subscriptionKey = call.argument<String>("subscriptionKey") ?: "" val subscriptionKey = call.argument<String>("subscriptionKey") ?: ""
val region = call.argument<String>("region") ?: "" val region = call.argument<String>("region") ?: ""
val supportedLanguages = call.argument<List<String>>("supportedLanguages") ?: listOf("zh-CN") val supportedLanguages = call.argument<List<String>>("supportedLanguages") ?: listOf("zh-CN")
val useExternalAudio = call.argument<Boolean>("useExternalAudio") ?: false
val useEchoCancellation = call.argument<Boolean>("useEchoCancellation") ?: true
try { try {
val success = azureAsrHelper.initialize(subscriptionKey, region, supportedLanguages.toTypedArray()) // 选择音频源类型
val audioSourceType = if (useExternalAudio) {
AzureAsrHelper.AudioSourceType.EXTERNAL
} else {
AzureAsrHelper.AudioSourceType.MICROPHONE
}
val success = azureAsrHelper.initialize(
subscriptionKey = subscriptionKey,
region = region,
supportedLanguages = supportedLanguages.toTypedArray(),
audioSourceType = audioSourceType,
useEchoCancellation = useEchoCancellation
)
result.success(success) result.success(success)
} catch (e: Exception) { } catch (e: Exception) {
result.error("INITIALIZATION_ERROR", e.message, null) result.error("INITIALIZATION_ERROR", e.message, null)
} }
} }
"pushAudioData" -> { "pushAudioData" -> {
val audioData = call.argument<ByteArray>("audioData")
val audioData = call.argument<ByteArray>("AudioData") try {
if (audioData != null) {
try { azureAsrHelper.pushAudioData(audioData)
Log.d("","AsrMethodHandler,pushAudioData${audioData}") result.success(true)
if (audioData != null) { } else {
result.error("INVALID_ARGUMENTS", "音频数据不能为空", null)
val success = azureAsrHelper.pushAudioData(audioData) }
result.success(success) } catch (e: Exception) {
} result.error("PUSH_AUDIO_ERROR", e.message, null)
} catch (e: Exception) { }
result.error("pushAudioData_ERROR", e.message, null)
}
} }
"recognizeOnce" -> { "recognizeOnce" -> {
azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { try {
override fun onResult(text: String, detectedLanguage: String) { // 直接使用同步方式进行一次性识别
mainHandler.post { azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback {
override fun onResult(text: String, detectedLanguage: String) {
// 直接返回结果,不发送事件
result.success(mapOf( result.success(mapOf(
"text" to text, "text" to text,
"detectedLanguage" to detectedLanguage "detectedLanguage" to detectedLanguage
)) ))
} }
}
override fun onError(error: String) {
override fun onError(error: String) {
mainHandler.post {
result.error("RECOGNITION_ERROR", error, null) result.error("RECOGNITION_ERROR", error, null)
} }
} })
}) } catch (e: Exception) {
result.error("RECOGNITION_ERROR", "识别过程异常: ${e.message}", null)
}
} }
"startContinuousRecognition" -> { "startContinuousRecognition" -> {
// 确保事件通道已准备好 // 确保事件通道已准备好
@ -133,70 +184,56 @@ try {
return return
} }
val success = azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback {
override fun onResult(text: String, detectedLanguage: String) {
sendAsrEvent(mapOf(
"type" to "result",
"text" to text,
"detectedLanguage" to detectedLanguage
))
}
override fun onRecognizing(recognizing: String, detectedLanguage: String) {
sendAsrEvent(mapOf(
"type" to "recognizing",
"text" to recognizing,
"detectedLanguage" to detectedLanguage
))
}
override fun onSessionStarted() {
sendAsrEvent(mapOf("type" to "sessionStarted"))
}
override fun onSessionStopped() {
sendAsrEvent(mapOf("type" to "sessionStopped"))
}
override fun onCanceled(reason: String, errorDetails: String) {
sendAsrEvent(mapOf(
"type" to "canceled",
"reason" to reason,
"errorDetails" to errorDetails
))
}
override fun onError(error: String) {
sendAsrEvent(mapOf("type" to "error", "message" to error))
}
override fun onSuccess(message: String) {
sendAsrEvent(mapOf("type" to "success", "message" to message))
}
})
result.success(success)
}
"stopContinuousRecognition" -> {
try { try {
if (!azureAsrHelper.isContinuousRecognitionActive()) { val success = azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback {
result.success(true) override fun onResult(text: String, detectedLanguage: String) {
return sendAsrEvent(mapOf(
} "type" to "result",
"text" to text,
val success = azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { "detectedLanguage" to detectedLanguage
override fun onResult(text: String, detectedLanguage: String) {} ))
override fun onRecognizing(recognizing: String, detectedLanguage: String) {} }
override fun onSessionStarted() {}
override fun onSessionStopped() {} override fun onRecognizing(recognizing: String, detectedLanguage: String) {
override fun onCanceled(reason: String, errorDetails: String) {} sendAsrEvent(mapOf(
"type" to "recognizing",
"text" to recognizing,
"detectedLanguage" to detectedLanguage
))
}
override fun onSessionStarted() {
sendAsrEvent(mapOf("type" to "sessionStarted"))
}
override fun onSessionStopped() {
sendAsrEvent(mapOf("type" to "sessionStopped"))
}
override fun onCanceled(reason: String, errorDetails: String) {
sendAsrEvent(mapOf(
"type" to "canceled",
"reason" to reason,
"errorDetails" to errorDetails
))
}
override fun onError(error: String) { override fun onError(error: String) {
mainHandler.post { sendAsrEvent(mapOf(
result.error("STOP_ERROR", error, null) "type" to "error",
} "message" to error
))
} }
override fun onSuccess(message: String) {}
}) })
result.success(success) result.success(success)
} catch (e: Exception) {
result.error("START_RECOGNITION_ERROR", e.message, null)
}
}
"stopContinuousRecognition" -> {
try {
val success = azureAsrHelper.stopContinuousRecognition()
result.success(success)
} catch (e: Exception) { } catch (e: Exception) {
result.error("STOP_ERROR", e.message, null) result.error("STOP_ERROR", e.message, null)
} }
@ -222,64 +259,45 @@ try {
"initialize" -> { "initialize" -> {
val subscriptionKey = call.argument<String>("subscriptionKey") ?: "" val subscriptionKey = call.argument<String>("subscriptionKey") ?: ""
val region = call.argument<String>("region") ?: "" val region = call.argument<String>("region") ?: ""
val language = call.argument<String>("language") ?: "zh-CN" val useCustomAudioOutput = call.argument<Boolean>("useCustomAudioOutput") ?: true // 默认使用自定义播放器
// 先释放现有资源
azureTtsHelper.dispose()
// 创建TTS回调
val ttsCallback = createTtsCallback()
// 初始化TTS引擎
val success = azureTtsHelper.initialize(
subscriptionKey = subscriptionKey,
region = region,
callback = ttsCallback
)
// 如果初始化成功且需要使用自定义音频输出,则设置自定义音频播放器
if (success && useCustomAudioOutput) {
// 创建SimpleAudioPlayer实例
val customPlayer = SimpleAudioPlayer(context)
// 获取并设置音频输出流
val outputStream = customPlayer.getAudioOutputStream()
if (outputStream != null) {
azureTtsHelper.setCustomAudioOutputStream(outputStream)
}
}
val success = azureTtsHelper.initialize(subscriptionKey, region, language)
result.success(success) result.success(success)
} }
"setVoice" -> { "setVoice" -> {
val voiceName = call.argument<String>("voiceName") ?: return result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) val voiceName = call.argument<String>("voiceName") ?: return result.error("INVALID_ARGUMENTS", "语音名称不能为空", null)
result.success(azureTtsHelper.setVoice(voiceName)) result.success(azureTtsHelper.setVoice(voiceName))
} }
"setSpeechParams" -> {
val rate = call.argument<Int>("rate") ?: 0
val pitch = call.argument<Int>("pitch") ?: 0
val volume = call.argument<Int>("volume") ?: 100
result.success(azureTtsHelper.setSpeechParams(rate, pitch, volume))
}
"setAudioOutputType" -> {
val outputTypeStr = call.argument<String>("outputType") ?: "speaker"
val outputType = when (outputTypeStr.lowercase()) {
"speaker" -> AzureTtsHelper.AudioOutputType.SPEAKER
"earpiece" -> AzureTtsHelper.AudioOutputType.EARPIECE
"auto" -> AzureTtsHelper.AudioOutputType.AUTO
else -> AzureTtsHelper.AudioOutputType.SPEAKER
}
result.success(azureTtsHelper.setAudioOutputType(outputType))
}
"speakText" -> { "speakText" -> {
val text = call.argument<String>("text") ?: return result.error("INVALID_ARGUMENTS", "文本不能为空", null) val text = call.argument<String>("text") ?: return result.error("INVALID_ARGUMENTS", "文本不能为空", null)
azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { // 直接传递参数给speakText方法
override fun onSuccess(message: String) { val success = azureTtsHelper.speakText(text)
mainHandler.post { result.success(success)
result.success(true)
}
}
override fun onError(error: String) {
mainHandler.post {
result.error("SPEAK_ERROR", error, null)
}
}
})
}
"speakSsml" -> {
val ssml = call.argument<String>("ssml") ?: return result.error("INVALID_ARGUMENTS", "SSML不能为空", null)
azureTtsHelper.speakSsml(ssml, object : AzureTtsHelper.TTSCallback {
override fun onSuccess(message: String) {
mainHandler.post {
result.success(true)
}
}
override fun onError(error: String) {
mainHandler.post {
result.error("SPEAK_ERROR", error, null)
}
}
})
} }
"stopSpeaking" -> { "stopSpeaking" -> {
result.success(azureTtsHelper.stopSpeaking()) result.success(azureTtsHelper.stopSpeaking())
@ -296,18 +314,54 @@ try {
} }
} }
} }
/**
* 创建TTS回调
*/
private fun createTtsCallback(): AzureTtsHelper.TtsCallback {
return object : AzureTtsHelper.TtsCallback {
override fun onSynthesisStarted() {
sendTtsEvent(mapOf(
"type" to "synthesis_started"
))
}
override fun onSynthesizing() {
sendTtsEvent(mapOf(
"type" to "synthesizing"
))
}
override fun onSynthesisCompleted() {
sendTtsEvent(mapOf(
"type" to "synthesis_completed"
))
}
override fun onSynthesisCanceled() {
sendTtsEvent(mapOf(
"type" to "synthesis_canceled"
))
}
}
}
} }
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
asrChannel.setMethodCallHandler(null) asrChannel.setMethodCallHandler(null)
ttsChannel.setMethodCallHandler(null) ttsChannel.setMethodCallHandler(null)
asrEventChannel.setStreamHandler(null) asrEventChannel.setStreamHandler(null)
ttsEventChannel.setStreamHandler(null)
try { try {
// 取消所有协程
job.cancel()
// 释放资源
azureTtsHelper.dispose() azureTtsHelper.dispose()
azureAsrHelper.dispose() azureAsrHelper.dispose()
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "Dispose resources error: ${e.message}") FileLogger.e(tag, "释放资源失败: ${e.message}")
} }
} }
} }

489
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt

@ -1,407 +1,307 @@
package com.yunqiinnovation.azure_speech package com.yunqiinnovation.azure_speech
import android.content.Context import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import com.yunqiinnovation.azure_speech.utils.FileLogger
import com.microsoft.cognitiveservices.speech.* import com.microsoft.cognitiveservices.speech.*
import com.microsoft.cognitiveservices.speech.audio.* import com.microsoft.cognitiveservices.speech.audio.*
import java.util.concurrent.Future import com.yunqiinnovation.azure_speech.utils.FileLogger
import java.util.concurrent.Semaphore import kotlinx.coroutines.*
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlin.coroutines.CoroutineContext
/** /**
* Microsoft Text-to-Speech Helper * Azure TTS Helper
* *
* 该类封装了微软语音 SDK 的 TTS 功能,提供简单的接口供 Flutter 调用 * 基于微软Azure语音服务的TTS实现
* 基于微软官方文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis * 参考文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis
*/ */
class AzureTtsHelper(private val context: Context) { class AzureTtsHelper(private val context: Context){
private val TAG = "AzureTtsHelper" companion object {
private const val TAG = "AzureTtsHelper"
private const val DEFAULT_LANGUAGE = "zh-CN"
private const val DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"
}
// Azure语音服务配置
private var speechConfig: SpeechConfig? = null private var speechConfig: SpeechConfig? = null
private var synthesizer: SpeechSynthesizer? = null private var synthesizer: SpeechSynthesizer? = null
private var isInitialized = false private var isInitialized = false
private var audioManager: AudioManager? = null private var isSpeaking = false
// 跟踪当前是否正在播放语音
private var speaking = false
// 音频输出设备类型 // 当前配置
enum class AudioOutputType { private var currentVoice = DEFAULT_VOICE
SPEAKER, // 扬声器 private var currentRate = "0%"
EARPIECE, // 听筒 private var currentPitch = "0%"
AUTO // 自动选择(有耳机用耳机,没有则用听筒) private var currentVolume = "100%"
}
// 当前音频输出设备类型 // 全局回调
private var currentAudioOutputType = AudioOutputType.AUTO private var ttsCallback: TtsCallback? = null
// 用于异步操作的信号量 // 自定义音频输出流
private val semaphore = Semaphore(0) private var customAudioOutputStream: PushAudioOutputStream? = null
// 当前语音设置 /**
private var currentVoiceName = "zh-CN-XiaoxiaoNeural" * 简化的TTS回调接口
private var currentSpeechRate = "0%" */
private var currentPitch = "0%" interface TtsCallback {
private var currentVolume = "100%" /**
* 合成开始
*/
fun onSynthesisStarted() {}
/**
* 合成中
*/
fun onSynthesizing() {}
/**
* 合成完成
*/
fun onSynthesisCompleted() {}
/**
* 合成取消
*/
fun onSynthesisCanceled() {}
}
/** /**
* 初始化 TTS 引擎 * 初始化TTS引擎
* *
* @param subscriptionKey Azure 语音服务订阅密钥 * @param subscriptionKey Azure语音服务订阅密钥
* @param region Azure 语音服务区域 * @param region Azure语音服务区域
* @param language 可选,默认语言,默认为 "zh-CN" * @param callback 可选,全局TTS回调接口
* @return 是否初始化成功
*/ */
fun initialize(subscriptionKey: String, region: String, language: String = "zh-CN"): Boolean { fun initialize(
subscriptionKey: String,
region: String,
callback: TtsCallback? = null
): Boolean {
try { try {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager // 释放之前的资源
dispose()
// 存储全局回调
ttsCallback = callback
// 创建语音配置 // 创建语音配置
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region) speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region)
// 设置语音合成输出格式为高质量音频
speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm) speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm)
speechConfig?.setSpeechSynthesisVoiceName(currentVoice)
// 设置默认语言 // 创建音频配置
speechConfig?.setSpeechSynthesisLanguage(language) val audioConfig = if (customAudioOutputStream != null) {
// 使用自定义音频输出流
// 设置默认语音 AudioConfig.fromStreamOutput(customAudioOutputStream)
speechConfig?.setSpeechSynthesisVoiceName(currentVoiceName) } else {
// 使用默认扬声器
// 创建音频配置 - 使用默认扬声器 AudioConfig.fromDefaultSpeakerOutput()
val audioConfig = AudioConfig.fromDefaultSpeakerOutput() }
// 创建语音合成器 // 创建合成器
synthesizer = SpeechSynthesizer(speechConfig, audioConfig) synthesizer = SpeechSynthesizer(speechConfig, audioConfig)
// 设置事件监听 // 设置事件监听
synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> setupEventListeners()
speaking = false
semaphore.release()
}
synthesizer?.SynthesisCanceled?.addEventListener { _, _ ->
speaking = false
semaphore.release()
}
isInitialized = true isInitialized = true
FileLogger.d(TAG, "TTS 引擎初始化成功") FileLogger.d(TAG, "TTS引擎初始化成功: 区域=$region")
// 设置默认音频输出类型为自动
if (audioManager != null) {
setAudioOutputType(AudioOutputType.AUTO)
}
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "TTS 引擎初始化失败: ${e.message}, ${region}") FileLogger.e(TAG, "TTS引擎初始化失败: ${e.message}")
e.printStackTrace()
return false return false
} }
} }
/** /**
* 设置音频输出设备类型 * 设置自定义音频输出流
* *
* @param outputType 音频输出设备类型 * @param outputStream 自定义音频输出流,如果为null则使用默认音频输出
* @return 是否设置成功 * @return 是否设置成功
*/ */
fun setAudioOutputType(outputType: AudioOutputType): Boolean { fun setCustomAudioOutputStream(outputStream: PushAudioOutputStream?): Boolean {
if (!isInitialized) {
FileLogger.e(TAG, "TTS 引擎尚未初始化")
return false
}
if (audioManager == null) {
FileLogger.e(TAG, "AudioManager 未初始化,无法设置音频输出设备")
return false
}
try { try {
currentAudioOutputType = outputType // 保存引用
customAudioOutputStream = outputStream
when (outputType) { // 如果已初始化,需要重新创建合成器以应用新的音频输出流
AudioOutputType.SPEAKER -> { if (isInitialized) {
// 使用扬声器 recreateSynthesizer()
audioManager?.mode = AudioManager.MODE_NORMAL
audioManager?.isSpeakerphoneOn = true
FileLogger.d(TAG, "已设置音频输出设备为扬声器")
}
AudioOutputType.EARPIECE -> {
// 使用听筒
audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager?.isSpeakerphoneOn = false
FileLogger.d(TAG, "已设置音频输出设备为听筒")
}
AudioOutputType.AUTO -> {
// 检查是否有耳机连接
if (isHeadsetConnected()) {
// 有耳机,使用耳机
audioManager?.mode = AudioManager.MODE_NORMAL
audioManager?.isSpeakerphoneOn = false
FileLogger.d(TAG, "已设置音频输出设备为耳机")
} else {
// 无耳机,使用听筒
audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager?.isSpeakerphoneOn = false
FileLogger.d(TAG, "已设置音频输出设备为听筒")
}
}
} }
// 重新创建合成器以应用新的音频设置
recreateSynthesizer()
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置音频输出设备失败: ${e.message}") FileLogger.e(TAG, "设置自定义音频输出流失败: ${e.message}")
e.printStackTrace()
return false return false
} }
} }
/** /**
* 检查是否有耳机连接 * 设置TTS回调
*
* @param callback TTS回调接口
*/ */
private fun isHeadsetConnected(): Boolean { fun setTtsCallback(callback: TtsCallback?) {
if (audioManager == null) return false ttsCallback = callback
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// 使用新的 API 检查音频设备
val devices = audioManager?.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
devices?.any { device ->
device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} ?: false
} else {
// 使用旧的 API 检查耳机
audioManager?.isWiredHeadsetOn == true || audioManager?.isBluetoothA2dpOn == true
}
} }
/** /**
* 重新创建语音合成器 * 设置事件监听器
*/ */
private fun recreateSynthesizer() { private fun setupEventListeners() {
try { synthesizer?.apply {
// 关闭旧的合成器 // 合成开始事件
synthesizer?.close() SynthesisStarted?.addEventListener { _, _ ->
FileLogger.d(TAG, "语音合成开始")
// 创建音频配置 ttsCallback?.onSynthesisStarted()
val audioConfig = AudioConfig.fromDefaultSpeakerOutput() }
// 创建新的合成器
synthesizer = SpeechSynthesizer(speechConfig, audioConfig)
// 重新设置事件监听 // 合成中事件
synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> Synthesizing?.addEventListener { _, _ ->
speaking = false ttsCallback?.onSynthesizing()
semaphore.release()
} }
synthesizer?.SynthesisCanceled?.addEventListener { _, _ ->
speaking = false // 合成完成事件
semaphore.release() SynthesisCompleted?.addEventListener { _, _ ->
FileLogger.d(TAG, "语音合成完成")
isSpeaking = false
ttsCallback?.onSynthesisCompleted()
} }
FileLogger.d(TAG, "已重新创建语音合成器") // 合成取消事件
} catch (e: Exception) { SynthesisCanceled?.addEventListener { _, _ ->
FileLogger.e(TAG, "重新创建语音合成器失败: ${e.message}") FileLogger.d(TAG, "语音合成取消")
e.printStackTrace() isSpeaking = false
ttsCallback?.onSynthesisCanceled()
}
} }
} }
/** /**
* 设置语音 * 设置语音
*
* @param voiceName 语音名称,例如 "zh-CN-XiaoxiaoNeural"
*/ */
fun setVoice(voiceName: String): Boolean { fun setVoice(voiceName: String): Boolean {
if (!isInitialized) { if (!isInitialized) return false
FileLogger.e(TAG, "TTS 引擎尚未初始化")
return false
}
if (voiceName == currentVoiceName) {
FileLogger.d(TAG, "已设置语音: $voiceName")
return true
}
try { try {
currentVoiceName = voiceName currentVoice = voiceName
speechConfig?.setSpeechSynthesisVoiceName(voiceName) speechConfig?.setSpeechSynthesisVoiceName(voiceName)
// 重新创建合成器以应用新的语音设置
recreateSynthesizer() recreateSynthesizer()
FileLogger.d(TAG, "已设置语音: $voiceName")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置语音失败: ${e.message}") FileLogger.e(TAG, "设置语音失败: ${e.message}")
e.printStackTrace()
return false return false
} }
} }
/** /**
* 设置语音合成参数 * 重新创建合成器
*
* @param rate 语速,范围 -100 到 100,默认为 0
* @param pitch 音调,范围 -100 到 100,默认为 0
* @param volume 音量,范围 0 到 100,默认为 100
*/ */
fun setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100): Boolean { private fun recreateSynthesizer() {
if (!isInitialized) { try {
FileLogger.e(TAG, "TTS 引擎尚未初始化") synthesizer?.close()
return false
// 创建音频配置
val audioConfig = if (customAudioOutputStream != null) {
// 使用自定义音频输出流
AudioConfig.fromStreamOutput(customAudioOutputStream!!)
} else {
// 使用默认扬声器
AudioConfig.fromDefaultSpeakerOutput()
}
// 使用新的音频配置创建合成器
synthesizer = SpeechSynthesizer(speechConfig, audioConfig)
setupEventListeners()
} catch (e: Exception) {
FileLogger.e(TAG, "重新创建合成器失败: ${e.message}")
} }
}
/**
* 设置语音参数
*/
private fun setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100): Boolean {
if (!isInitialized) return false
try { try {
currentSpeechRate = if (rate == 0) "0%" else if (rate < 0) "${(rate * 0.9).toInt()}%" else "$rate%" currentRate = formatPercentage(rate)
currentPitch = if (pitch == 0) "0%" else "${(pitch * 0.5).toInt()}%" currentPitch = formatPercentage(pitch)
currentVolume = "${volume.coerceIn(0, 100)}%" currentVolume = "${volume.coerceIn(0, 100)}%"
FileLogger.d(TAG, "已设置语音参数: 语速=$currentSpeechRate, 音调=$currentPitch, 音量=$currentVolume")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置语音参数失败: ${e.message}") FileLogger.e(TAG, "设置语音参数失败: ${e.message}")
e.printStackTrace()
return false return false
} }
} }
/** /**
* 合成文本为语音并播放 * 格式化百分比值
*
* @param text 要合成的文本
* @param callback 回调接口,用于返回结果或错误
*/ */
fun speakText(text: String, callback: TTSCallback) { private fun formatPercentage(value: Int): String {
if (!isInitialized) { return if (value >= 0) "+$value%" else "$value%"
callback.onError("TTS 引擎尚未初始化===")
return
}
try {
FileLogger.d(TAG, "开始合成文本: $text")
// 生成 SSML
val ssml = generateSsml(text)
// 使用 SSML 合成语音
speakSsml(ssml, callback)
} catch (e: Exception) {
FileLogger.e(TAG, "语音合成异常: ${e.message}")
e.printStackTrace()
callback.onError("语音合成异常: ${e.message}")
}
} }
/** /**
* 生成 SSML 文本 * 生成SSML
*
* @param text 要转换的文本
* @return SSML 格式的文本
*/ */
private fun generateSsml(text: String): String { private fun generateSsml(text: String): String {
return """ return """
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN"> <speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN">
<voice name="$currentVoiceName"> <voice name="$currentVoice">
<prosody rate="$currentSpeechRate" pitch="$currentPitch" volume="$currentVolume"> <prosody rate="$currentRate" pitch="$currentPitch" volume="$currentVolume">
$text $text
</prosody> </prosody>
</voice> </voice>
</speak> </speak>
""".trimIndent() """.trimIndent()
} }
/** /**
* 合成 SSML 为语音并播放 * 合成文本为语音
* *
* @param ssml SSML 格式的文本 * @param text 要合成的文本
* @param callback 回调接口,用于返回结果或错误 * @return 是否成功开始合成
*/ */
fun speakSsml(ssml: String, callback: TTSCallback) { fun speakText(text: String): Boolean {
if (!isInitialized) { if (!isInitialized) {
callback.onError("TTS 引擎尚未初始化") return false
return
} }
try { try {
FileLogger.d(TAG, "开始合成 SSML") // 生成SSML并播放
val ssml = generateSsml(text)
// 重置信号量
semaphore.drainPermits()
// 异步合成语音 isSpeaking = true
val task: Future<SpeechSynthesisResult> = synthesizer!!.SpeakSsmlAsync(ssml)
// 标记为正在播放 // 使用纯异步方式
speaking = true synthesizer?.SpeakSsmlAsync(ssml)
// 等待合成完成 return true
Thread {
try {
// 等待合成完成信号
semaphore.acquire()
// 获取结果
val result = task.get()
when (result.reason) {
ResultReason.SynthesizingAudioCompleted -> {
FileLogger.d(TAG, "语音合成完成,音频长度: ${result.audioLength / 10000}ms,音频数据大小: ${result.audioData?.size ?: 0} 字节")
callback.onSuccess("语音合成完成")
}
ResultReason.Canceled -> {
val cancellation = SpeechSynthesisCancellationDetails.fromResult(result)
FileLogger.e(TAG, "语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}")
callback.onError("语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}")
}
else -> {
FileLogger.e(TAG, "语音合成失败kt: ${result.reason}")
callback.onError("语音合成失败kt: ${result.reason}")
}
}
result.close()
} catch (e: Exception) {
FileLogger.e(TAG, "处理语音合成结果异常: ${e.message}")
e.printStackTrace()
callback.onError("处理语音合成结果异常: ${e.message}")
}
}.start()
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "语音合成异常: ${e.message}") isSpeaking = false
e.printStackTrace() return false
callback.onError("语音合成异常: ${e.message}")
} }
} }
/** /**
* 停止当前语音合成 * 停止当前语音合成
*/ */
fun stopSpeaking(): Boolean { fun stopSpeaking(): Boolean {
if (!isInitialized) { if (!isInitialized) return false
FileLogger.e(TAG, "TTS 引擎尚未初始化")
return false
}
try { try {
synthesizer?.StopSpeakingAsync() synthesizer?.StopSpeakingAsync()
speaking = false isSpeaking = false
FileLogger.d(TAG, "已停止语音合成")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "停止语音合成失败: ${e.message}")
e.printStackTrace()
return false return false
} }
} }
/** /**
* 释放资源 * 释放资源
*/ */
@ -411,35 +311,28 @@ class AzureTtsHelper(private val context: Context) {
synthesizer?.close() synthesizer?.close()
speechConfig?.close() speechConfig?.close()
// 恢复音频模式 // 关闭自定义音频输出流
if (audioManager != null) { try {
audioManager?.mode = AudioManager.MODE_NORMAL customAudioOutputStream?.close()
audioManager?.isSpeakerphoneOn = false customAudioOutputStream = null
} catch (e: Exception) {
// 忽略关闭异常
} }
// 清理引用
ttsCallback = null
isInitialized = false isInitialized = false
speaking = false isSpeaking = false
FileLogger.d(TAG, "TTS 引擎已释放")
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "释放 TTS 引擎失败: ${e.message}") // 即使发生异常,也确保重置状态
e.printStackTrace() isInitialized = false
isSpeaking = false
} }
} }
/**
* 检查当前是否正在播放语音
*
* @return 是否正在播放语音
*/
fun isSpeaking(): Boolean {
return speaking
}
/** /**
* TTS 回调接口 * 是否正在播放
*/ */
interface TTSCallback { fun isSpeaking(): Boolean = isSpeaking
fun onSuccess(message: String)
fun onError(error: String)
}
} }

345
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt

@ -0,0 +1,345 @@
package com.yunqiinnovation.azure_speech
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import com.microsoft.cognitiveservices.speech.audio.AudioOutputStream
import com.microsoft.cognitiveservices.speech.audio.PushAudioOutputStream
import com.microsoft.cognitiveservices.speech.audio.PushAudioOutputStreamCallback
import com.yunqiinnovation.azure_speech.utils.FileLogger
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* 简单音频播放器
* 管理音频播放并提供与Azure SDK兼容的音频输出流
*/
class SimpleAudioPlayer(private val context: Context? = null) {
companion object {
private const val TAG = "SimpleAudioPlayer"
// 音频参数 - 与Azure TTS输出匹配
private const val SAMPLE_RATE = 24000
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_OUT_MONO
// 缓冲区大小
private const val BUFFER_SIZE_MULTIPLIER = 5
}
// 音频播放器
private var audioTrack: AudioTrack? = null
// 音频管理器
private var audioManager: AudioManager? = null
// 线程同步锁
private val lock = ReentrantLock()
// 状态标志
private var isPlaying = false
private var isReleased = false
// 缓冲区大小
private val bufferSize: Int
// 推送流
private var pushOutputStream: PushAudioOutputStream? = null
// 自定义推送流回调
private inner class AudioOutputCallback : PushAudioOutputStreamCallback() {
override fun write(dataBuffer: ByteArray): Int {
return writeData(dataBuffer, 0, dataBuffer.size)
}
override fun close() {
release()
}
}
init {
// 初始化音频管理器
if (context != null) {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
}
// 计算缓冲区大小
val minBufferSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT
)
bufferSize = minBufferSize * BUFFER_SIZE_MULTIPLIER
// 初始化AudioTrack
initAudioTrack()
// 创建推送流
pushOutputStream = AudioOutputStream.createPushStream(AudioOutputCallback())
}
/**
* 获取PushAudioOutputStream实例
* 返回一个可以与AzureTtsHelper配合使用的音频输出流
*/
fun getAudioOutputStream(): PushAudioOutputStream? {
return pushOutputStream
}
/**
* 检查蓝牙设备连接状态
* @return 如果连接了蓝牙音频设备则返回true
*/
private fun isBluetoothDeviceConnected(): Boolean {
if (audioManager == null) return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val devices = audioManager?.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
devices?.any { device ->
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} ?: false
} else {
audioManager?.isBluetoothA2dpOn == true
}
}
/**
* 初始化AudioTrack
*/
private fun initAudioTrack() {
try {
lock.withLock {
// 如果已存在AudioTrack实例,先释放
releaseAudioTrack()
// 检查蓝牙连接状态
val isBluetoothConnected = isBluetoothDeviceConnected()
// 创建新的AudioTrack实例
audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val audioFormat = AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setEncoding(AUDIO_FORMAT)
.setChannelMask(CHANNEL_CONFIG)
.build()
AudioTrack.Builder()
.setAudioAttributes(audioAttributes)
.setAudioFormat(audioFormat)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
} else {
AudioTrack(
AudioManager.STREAM_MUSIC,
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
bufferSize,
AudioTrack.MODE_STREAM
)
}
// 设置音频路由
configureAudioRouting(isBluetoothConnected)
FileLogger.d(TAG, "AudioTrack初始化成功,缓冲区大小:$bufferSize 字节,蓝牙:${if (isBluetoothConnected) "已连接" else "未连接"}")
}
} catch (e: Exception) {
FileLogger.e(TAG, "AudioTrack初始化失败: ${e.message}")
}
}
/**
* 配置音频路由
* @param useBluetoothIfAvailable 如果可用,是否使用蓝牙
*/
private fun configureAudioRouting(useBluetoothIfAvailable: Boolean) {
if (audioManager == null) return
try {
if (useBluetoothIfAvailable) {
// 尝试路由到蓝牙设备
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// 较新的API可以依赖系统自动路由
audioManager?.mode = AudioManager.MODE_NORMAL
} else {
// 较旧的API需要明确启用蓝牙
audioManager?.mode = AudioManager.MODE_NORMAL
audioManager?.startBluetoothSco()
audioManager?.isBluetoothScoOn = true
}
FileLogger.d(TAG, "已配置蓝牙音频路由")
} else {
// 使用扬声器
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
}
audioManager?.mode = AudioManager.MODE_NORMAL
audioManager?.isSpeakerphoneOn = true
FileLogger.d(TAG, "已配置扬声器音频路由")
}
} catch (e: Exception) {
FileLogger.e(TAG, "配置音频路由失败: ${e.message}")
}
}
/**
* 释放AudioTrack
*/
private fun releaseAudioTrack() {
audioTrack?.let {
try {
if (it.state == AudioTrack.STATE_INITIALIZED) {
it.stop()
}
it.release()
} catch (e: Exception) {
FileLogger.e(TAG, "释放AudioTrack失败: ${e.message}")
}
}
audioTrack = null
}
/**
* 开始播放
*/
fun start() {
try {
lock.withLock {
if (isReleased) return
// 检查蓝牙状态
val isBluetoothConnected = isBluetoothDeviceConnected()
// 检查并初始化AudioTrack
if (audioTrack == null || audioTrack?.state != AudioTrack.STATE_INITIALIZED) {
initAudioTrack()
} else {
// 如果AudioTrack已存在但蓝牙状态发生变化,重新配置路由
configureAudioRouting(isBluetoothConnected)
}
// 开始播放
audioTrack?.play()
isPlaying = true
FileLogger.d(TAG, "AudioTrack开始播放")
}
} catch (e: Exception) {
FileLogger.e(TAG, "开始播放失败: ${e.message}")
}
}
/**
* 停止播放
*/
fun stop() {
try {
lock.withLock {
if (isReleased) return
audioTrack?.pause()
audioTrack?.flush()
isPlaying = false
FileLogger.d(TAG, "AudioTrack停止播放")
}
} catch (e: Exception) {
FileLogger.e(TAG, "停止播放失败: ${e.message}")
}
}
/**
* 释放资源
*/
fun release() {
try {
lock.withLock {
if (isReleased) return
releaseAudioTrack()
// 重置音频路由
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M && audioManager != null) {
try {
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
} catch (e: Exception) {
// 忽略异常
}
}
audioManager?.mode = AudioManager.MODE_NORMAL
audioManager?.isSpeakerphoneOn = false
// 关闭推送流
try {
pushOutputStream?.close()
pushOutputStream = null
} catch (e: Exception) {
// 忽略异常
}
isPlaying = false
isReleased = true
FileLogger.d(TAG, "AudioTrack释放资源")
}
} catch (e: Exception) {
FileLogger.e(TAG, "释放资源失败: ${e.message}")
}
}
/**
* 是否正在播放
*/
fun isPlaying(): Boolean = isPlaying
/**
* 写入音频数据
* 内部方法,由PushAudioOutputStreamCallback调用
*/
private fun writeData(buffer: ByteArray?, offset: Int, size: Int): Int {
if (buffer == null || size <= 0 || isReleased) return 0
try {
lock.withLock {
// 检查播放状态
if (!isPlaying) {
start()
}
// 写入音频数据
val written = audioTrack?.write(buffer, offset, size) ?: 0
// 如果写入失败,尝试重新初始化
if (written <= 0) {
FileLogger.w(TAG, "写入AudioTrack失败,尝试重新初始化")
initAudioTrack()
start()
return audioTrack?.write(buffer, offset, size) ?: 0
}
return written
}
} catch (e: Exception) {
FileLogger.e(TAG, "写入音频数据失败: ${e.message}")
return 0
}
}
}

35
local_plugins/ble_service/android/build.gradle.kts

@ -0,0 +1,35 @@
import org.gradle.api.JavaVersion
group = "com.yunqiinnovation.ble_service"
version = "1.0.0"
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.yunqiinnovation.ble_service"
compileSdk = 34
defaultConfig {
minSdk = 26
targetSdk = 34
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
implementation("com.jakewharton.timber:timber:5.0.1")
implementation(project(":deepvoice_utils"))
}

38
local_plugins/ble_service/android/src/main/AndroidManifest.xml

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yunqiinnovation.ble_service">
<!-- Android 12+ 新蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 旧版兼容 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:usesPermissionFlags="neverForLocation" />
<!-- 前台服务 & 通知 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- 前台服务:dataSync + connectedDevice 表示同时传数据与保持连接 -->
<service
android:name=".BleForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|connectedDevice" />
<!-- 被系统 PendingIntent 触发的 Receiver -->
<receiver
android:name=".BleWakeReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.yunqiinnovation.ble_service.ACTION_BLE_SCAN" />
</intent-filter>
</receiver>
</application>
</manifest>

89
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt

@ -0,0 +1,89 @@
package com.yunqiinnovation.ble_service
import java.util.*
/**
* BLE服务常量
*/
object BleConst {
// ================== 协议相关UUID ==================
/** 主服务UUID - 文档中定义为0000ABC0-0000-1111-2222-123456789ABC */
val PRIMARY_SERVICE_UUID: UUID = UUID.fromString("0000fd7b-0000-1000-8000-00805f9b34fb")
/** 写入特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */
val WRITE_CHAR_UUID: UUID = UUID.fromString("0000fd7b-0000-1000-8000-00805f9b34fb")
/** 通知特征UUID - 文档中定义为0000ABC2-0000-1111-2222-123456789ABC */
val NOTIFY_CHAR_UUID: UUID = UUID.fromString("0000fd7b-0000-1000-8000-00805f9b34fb")
/** 客户端特征配置描述符UUID */
val CLIENT_CHAR_CONFIG_UUID: UUID = UUID.fromString("0000fd7b-0000-1000-8000-00805f9b34fb")
/** BLE扫描Action */
const val ACTION_SCAN_RESULT = "com.yunqiinnovation.ble_service.ACTION_BLE_SCAN"
/** 通知渠道和前台服务ID */
const val CHANNEL_ID = "ble_foreground_chn"
const val FOREGROUND_ID = 0xBEE
// ================== 连接状态 ==================
const val STATE_DISCONNECTED = 0
const val STATE_CONNECTING = 1
const val STATE_CONNECTED = 2
const val STATE_DISCONNECTING = 3
const val STATE_ERROR = 4
// ================== 广播厂商信息 ==================
/** 厂商ID - 文档中定义为0xABEF */
const val MANUFACTURER_ID = 0xABEF
/** 客户ID - 文档中定义为0xA001 */
const val CUSTOMER_ID = 0xA001
/** 产品ID - 文档中定义为0xA001 */
const val PRODUCT_ID = 0xA001
/** 蓝牙状态-未连接 */
const val BT_STATE_DISCONNECTED = 0x00
/** 蓝牙状态-已连接 */
const val BT_STATE_CONNECTED = 0x0C
// ================== 协议格式 ==================
/** APP请求帧头 */
const val APP_REQUEST_HEADER = 0xAA.toByte()
/** 设备响应帧头 */
const val DEVICE_RESPONSE_HEADER = 0xBB.toByte()
/** 设备主动上报帧头 */
const val DEVICE_NOTIFY_HEADER = 0xCC.toByte()
// ================== 协议指令 (CMD) ==================
/** 获取耳机版本号 */
const val CMD_GET_VERSION = 0x01
/** 获取耳机产品ID */
const val CMD_GET_PRODUCT_ID = 0x02
/** 获取耳机颜色ID */
const val CMD_GET_COLOR_ID = 0x03
/** 获取耳机电量ID */
const val CMD_GET_BATTERY_INFO = 0x04
/** APP控制编解码指令 */
const val CMD_CONTROL_CODEC = 0x05
/** 音量增大 */
const val CMD_VOLUME_UP = 0x06
/** 音量减小 */
const val CMD_VOLUME_DOWN = 0x07
/** A2DP播放 */
const val CMD_PLAY_A2DP = 0x08
// ================== 编解码控制值 ==================
/** 关闭编解码指令 */
const val CODEC_CONTROL_CLOSE = 0x00
/** 打开解码指令 */
const val CODEC_CONTROL_DECODE_ON = 0xA1
/** 通过A2DP播放 */
const val CODEC_CONTROL_A2DP_PLAY = 0xA2
/** 打开编码指令 */
const val CODEC_CONTROL_ENCODE_ON = 0xB1
/** 左声道 */
const val AUDIO_CHANNEL_LEFT = 0x01
/** 右声道 */
const val AUDIO_CHANNEL_RIGHT = 0x02
/** 立体声 */
const val AUDIO_CHANNEL_STEREO = 0x03
}

311
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt

@ -0,0 +1,311 @@
package com.yunqiinnovation.ble_service
import android.app.*
import android.bluetooth.BluetoothManager
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.yunqiinnovation.deepvoice_utils.FileLogger
import android.content.Context
import android.content.SharedPreferences
/**
* BLE前台服务
* 1. 在onCreate中启动前台服务并注册后台扫描
* 2. 在onStartCommand中处理被BleWakeReceiver拉起的重连请求
* 3. 在onDestroy中清理资源
* 4. 自动扫描连接上一次连接的设备
* 5. 响应唤醒命令,自动开启录音功能
*/
@RequiresApi(26)
class BleForegroundService : Service(), BleService.Callback {
private lateinit var bleManager: BleManagerWrapper
private val TAG = "BleForegroundService"
private val handler = Handler(Looper.getMainLooper())
private lateinit var prefs: SharedPreferences
// 扫描相关
private var isAutoConnecting = false
private var scanRetryCount = 0
private val MAX_SCAN_RETRY = 3
private val SCAN_TIMEOUT = 10000L // 10秒
private val AUTO_CONNECT_DELAY = 2000L // 2秒
override fun onCreate() {
super.onCreate()
FileLogger.init(this)
FileLogger.i(TAG, "BleForegroundService正在创建...")
// 初始化偏好设置存储
prefs = getSharedPreferences("ble_service_prefs", Context.MODE_PRIVATE)
// 使用全局BleManagerWrapper单例,共享BleService实例
bleManager = BleManagerWrapper.instance.apply {
init(this@BleForegroundService)
addCallback(this@BleForegroundService) // 注册回调
}
// 使用正确的前台服务类型启动
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Android 14+
startForeground(
BleConst.FOREGROUND_ID,
buildNotification("BLE服务运行中..."),
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10-13
startForeground(
BleConst.FOREGROUND_ID,
buildNotification("BLE服务运行中..."),
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
)
} else { // 较旧版本
startForeground(BleConst.FOREGROUND_ID, buildNotification("BLE服务运行中..."))
}
// 启动自动连接流程
startAutoConnect()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// 若由Receiver带MAC拉起,则立即重连
val mac = intent?.getStringExtra("mac")
FileLogger.d(TAG, "onStartCommand: $mac")
mac?.let {
FileLogger.i(TAG, "收到连接请求,正在连接设备: $it")
// 保存最后连接的设备地址
saveLastConnectedDevice(it)
// 连接设备
bleManager.connect(it)
}
return START_STICKY
}
override fun onBind(i: Intent?): IBinder? = null
override fun onDestroy() {
FileLogger.i(TAG, "BleForegroundService正在销毁...")
// 移除回调
bleManager.removeCallback(this)
// 取消所有待处理的操作
handler.removeCallbacksAndMessages(null)
super.onDestroy()
}
/**
* 启动自动连接流程
*/
private fun startAutoConnect() {
// 如果已经连接则不需要自动连接
if (bleManager.isConnected()) {
FileLogger.i(TAG, "已连接到设备,无需自动连接")
return
}
// 获取上次连接的设备
val lastDevice = getLastConnectedDevice()
if (lastDevice.isNullOrEmpty()) {
FileLogger.i(TAG, "无上次连接设备记录,开始扫描新设备")
startScanForDevices()
return
}
// 尝试连接上次的设备
FileLogger.i(TAG, "尝试连接上次设备: $lastDevice")
bleManager.connect(lastDevice)
// 设置连接超时,如果超时则开始扫描
handler.postDelayed({
if (!bleManager.isConnected()) {
FileLogger.i(TAG, "连接上次设备超时,开始扫描新设备")
startScanForDevices()
}
}, AUTO_CONNECT_DELAY)
}
/**
* 开始扫描设备
*/
private fun startScanForDevices() {
if (isAutoConnecting) return
isAutoConnecting = true
scanRetryCount = 0
FileLogger.i(TAG, "开始扫描BLE设备...")
bleManager.startScan(SCAN_TIMEOUT)
// 设置扫描超时
handler.postDelayed({
if (isAutoConnecting && scanRetryCount < MAX_SCAN_RETRY) {
scanRetryCount++
FileLogger.i(TAG, "扫描超时,重试 $scanRetryCount/$MAX_SCAN_RETRY")
bleManager.startScan(SCAN_TIMEOUT)
} else if (isAutoConnecting) {
FileLogger.i(TAG, "已达最大重试次数,停止自动连接")
isAutoConnecting = false
}
}, SCAN_TIMEOUT + 1000) // 比扫描超时多1秒
}
/**
* 保存最后连接的设备
*/
private fun saveLastConnectedDevice(address: String) {
prefs.edit().putString("last_connected_device", address).apply()
}
/**
* 获取最后连接的设备
*/
private fun getLastConnectedDevice(): String? {
return prefs.getString("last_connected_device", null)
}
/** 构建低优先级通知,满足5秒前台规则 */
private fun buildNotification(msg: String): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(BleConst.CHANNEL_ID) == null) {
nm.createNotificationChannel(
NotificationChannel(
BleConst.CHANNEL_ID, "BLE前台", NotificationManager.IMPORTANCE_LOW
)
)
}
return NotificationCompat.Builder(this, BleConst.CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("DeepSound")
.setContentText(msg)
.build()
}
/**
* 更新通知内容
*/
private fun updateNotification(msg: String) {
val nm = getSystemService(NotificationManager::class.java)
nm.notify(BleConst.FOREGROUND_ID, buildNotification(msg))
}
// ============================== BleService.Callback 接口实现 ==============================
override fun onScanResult(devices: List<Map<String, Any>>) {
if (!isAutoConnecting || devices.isEmpty()) return
// 自动连接第一个找到的设备
val device = devices.firstOrNull()
device?.let {
val address = it["id"] as String
val name = it["name"] as String
FileLogger.i(TAG, "扫描到设备: $name ($address),正在自动连接...")
saveLastConnectedDevice(address)
bleManager.connect(address)
// 停止扫描
bleManager.stopScan()
// 延迟一段时间检查连接状态
handler.postDelayed({
if (!bleManager.isConnected()) {
FileLogger.i(TAG, "自动连接失败,继续扫描...")
if (scanRetryCount < MAX_SCAN_RETRY) {
startScanForDevices()
} else {
FileLogger.i(TAG, "已达最大重试次数,停止自动连接")
isAutoConnecting = false
}
}
}, AUTO_CONNECT_DELAY)
}
}
override fun onConnectionStateChanged(state: Int) {
when (state) {
BleConst.STATE_CONNECTED -> {
FileLogger.i(TAG, "设备已连接")
updateNotification("已连接到BLE设备")
isAutoConnecting = false
// 获取设备信息
// bleManager.getDeviceVersion()
// bleManager.getBatteryInfo()
}
BleConst.STATE_DISCONNECTED -> {
FileLogger.i(TAG, "设备已断开")
updateNotification("BLE设备已断开")
// 如果不是主动断开,尝试重新连接
// if (!isAutoConnecting) {
// handler.postDelayed({
// startAutoConnect()
// }, 3000) // 延迟3秒重连
// }
}
BleConst.STATE_ERROR -> {
FileLogger.e(TAG, "设备连接错误")
updateNotification("BLE设备连接错误")
// 连接错误,尝试重新扫描连接
// if (!isAutoConnecting) {
// handler.postDelayed({
// startAutoConnect()
// }, 5000) // 延迟5秒重连
// }
}
}
}
override fun onWakeupSignalReceived() {
FileLogger.i(TAG, "收到唤醒信号,开启设备录音功能")
// 更新通知
updateNotification("已唤醒,正在录音...")
}
override fun onCommandResult(commandType: Int, success: Boolean) {
FileLogger.i(TAG, "命令结果:类型=$commandType, 成功=$success")
}
override fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean) {
when (commandType) {
BleConst.CMD_CONTROL_CODEC -> {
if (success) {
FileLogger.i(TAG, "编解码控制命令执行成功")
} else {
FileLogger.e(TAG, "编解码控制命令执行失败")
}
}
}
}
override fun onDeviceInfoReceived(infoType: Int, infoData: String) {
when (infoType) {
BleConst.CMD_GET_VERSION -> {
FileLogger.i(TAG, "设备版本: $infoData")
updateNotification("已连接:版本 $infoData")
}
BleConst.CMD_GET_BATTERY_INFO -> {
val parts = infoData.split("|")
val batteryLevel = parts[0]
val isCharging = parts.getOrNull(1) == "1"
FileLogger.i(TAG, "电池电量: $batteryLevel%, 充电中: $isCharging")
updateNotification("已连接:电量 $batteryLevel%${if (isCharging) "(充电中)" else ""}")
}
}
}
// 以下是必须实现但当前服务不需要处理的回调方法
override fun onDataReceived(data: ByteArray) {}
override fun onAudioDataReceived(data: ByteArray) {}
override fun onStatusUpdate(status: Map<String, Any>) {}
}

234
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt

@ -0,0 +1,234 @@
package com.yunqiinnovation.ble_service
import android.content.Context
import com.yunqiinnovation.deepvoice_utils.FileLogger
import android.util.Log
import java.util.concurrent.CopyOnWriteArrayList
/**
* BLE管理器封装类
* 单例模式,确保原生层和Flutter层共用同一个BleService实例
*/
class BleManagerWrapper private constructor() {
companion object {
private const val TAG = "BleManagerWrapper"
@JvmStatic
val instance by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { BleManagerWrapper() }
}
// BleService实例
var bleService: BleService? = null
private set
// 回调分发器
private val callbackDispatcher = CallbackDispatcher()
// 存储所有注册的回调
private val registeredCallbacks = CopyOnWriteArrayList<BleService.Callback>()
/**
* 回调分发器类 - 实现BleService.Callback接口,将回调分发给多个监听者
*/
private inner class CallbackDispatcher : BleService.Callback {
override fun onScanResult(devices: List<Map<String, Any>>) {
for (callback in registeredCallbacks) {
try {
callback.onScanResult(devices)
} catch (e: Exception) {
FileLogger.e(TAG, "分发扫描结果回调异常", e)
}
}
}
override fun onConnectionStateChanged(state: Int) {
for (callback in registeredCallbacks) {
try {
callback.onConnectionStateChanged(state)
} catch (e: Exception) {
FileLogger.e(TAG, "分发连接状态回调异常", e)
}
}
}
override fun onDataReceived(data: ByteArray) {
for (callback in registeredCallbacks) {
try {
callback.onDataReceived(data)
} catch (e: Exception) {
FileLogger.e(TAG, "分发数据回调异常", e)
}
}
}
override fun onAudioDataReceived(data: ByteArray) {
for (callback in registeredCallbacks) {
try {
callback.onAudioDataReceived(data)
} catch (e: Exception) {
FileLogger.e(TAG, "分发音频数据回调异常", e)
}
}
}
override fun onWakeupSignalReceived() {
for (callback in registeredCallbacks) {
try {
callback.onWakeupSignalReceived()
} catch (e: Exception) {
FileLogger.e(TAG, "分发唤醒信号回调异常", e)
}
}
}
override fun onCommandResult(commandType: Int, success: Boolean) {
for (callback in registeredCallbacks) {
try {
callback.onCommandResult(commandType, success)
} catch (e: Exception) {
FileLogger.e(TAG, "分发命令结果回调异常", e)
}
}
}
override fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean) {
for (callback in registeredCallbacks) {
try {
callback.onProtocolCommandResult(commandType, data, success)
} catch (e: Exception) {
FileLogger.e(TAG, "分发协议命令结果回调异常", e)
}
}
}
override fun onDeviceInfoReceived(infoType: Int, infoData: String) {
for (callback in registeredCallbacks) {
try {
callback.onDeviceInfoReceived(infoType, infoData)
} catch (e: Exception) {
FileLogger.e(TAG, "分发设备信息回调异常", e)
}
}
}
override fun onStatusUpdate(status: Map<String, Any>) {
for (callback in registeredCallbacks) {
try {
callback.onStatusUpdate(status)
} catch (e: Exception) {
FileLogger.e(TAG, "分发状态更新回调异常", e)
}
}
}
}
/**
* 初始化BLE服务
*/
fun init(context: Context) {
FileLogger.d(TAG, "初始化BLE服务: ${bleService}")
try {
if (bleService == null) {
bleService = BleService(context)
// 设置回调分发器
bleService?.callback = callbackDispatcher
FileLogger.d(TAG, "BLE服务初始化成功")
}
// 注册后台扫描,确保可被唤醒
bleService?.registerBackgroundScan()
} catch (e: Exception) {
FileLogger.e(TAG, "BLE服务初始化失败", e)
}
}
/**
* 添加回调接口
* @param callback 回调接口实现
* @return 是否添加成功
*/
fun addCallback(callback: BleService.Callback): Boolean {
if (!registeredCallbacks.contains(callback)) {
registeredCallbacks.add(callback)
FileLogger.d(TAG, "已添加BLE回调, 当前回调数: ${registeredCallbacks.size}")
return true
}
return false
}
/**
* 移除回调接口
* @param callback 回调接口实现
* @return 是否移除成功
*/
fun removeCallback(callback: BleService.Callback): Boolean {
val removed = registeredCallbacks.remove(callback)
if (removed) {
FileLogger.d(TAG, "已移除BLE回调, 当前回调数: ${registeredCallbacks.size}")
}
return removed
}
// 以下方法直接委托给BleService实现,避免重复代码
// 扫描相关
fun startScan(timeout: Long = 10000): Boolean = bleService?.startScan(timeout) ?: false
fun stopScan(): Boolean = bleService?.stopScan() ?: false
// 连接相关
fun connect(address: String): Boolean {
bleService?.connect(address)
return true
}
fun disconnect(): Boolean {
bleService?.disconnect()
return true
}
fun isConnected(): Boolean = bleService?.isConnected() ?: false
fun getConnectionState(): Int = bleService?.connectionState?.value ?: BleConst.STATE_DISCONNECTED
// 设备信息
fun getConnectedDeviceAddress(): String? = bleService?.getConnectedDeviceAddress()
fun getBatteryLevel(): Int = bleService?.batteryLevel ?: 0
fun getFirmwareVersion(): String? = bleService?.firmwareVersion
// 查询命令
fun getDeviceVersion(): Boolean = bleService?.getDeviceVersion() ?: false
fun getBatteryInfo(): Boolean = bleService?.getBatteryInfo() ?: false
// 控制命令
fun openEncoder(): Boolean = bleService?.openEncoder() ?: false
fun openDecoder(): Boolean = bleService?.openDecoder() ?: false
fun closeCodec(): Boolean = bleService?.closeCodec() ?: false
fun playA2DP(): Boolean = bleService?.playA2DP() ?: false
fun volumeUp(): Boolean = bleService?.volumeUp() ?: false
fun volumeDown(): Boolean = bleService?.volumeDown() ?: false
// 数据传输
fun sendAudioData(data: ByteArray?): Boolean {
if (data == null) return false
return bleService?.sendAudioData(data) ?: false
}
// 清理资源
fun cleanup() {
bleService?.cleanup()
}
// 获取扫描结果的简化实现
fun getScannedDevices(): List<Map<String, Any>> {
// 简单返回一个转换后的设备列表
val devices = mutableListOf<Map<String, Any>>()
bleService?.getBluetoothAdapter()?.bondedDevices?.forEach { device ->
devices.add(mapOf(
"id" to device.address,
"name" to (device.name ?: "未知设备"),
"rssi" to 0 // 已配对设备没有RSSI信息
))
}
return devices
}
}

905
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -0,0 +1,905 @@
package com.yunqiinnovation.ble_service
import android.Manifest
import android.app.PendingIntent
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.Intent
import android.os.*
import androidx.lifecycle.MutableLiveData
import com.yunqiinnovation.deepvoice_utils.FileLogger
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.*
import android.os.ParcelUuid
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.os.SystemClock
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import java.util.concurrent.ConcurrentHashMap
/**
* BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能
* 支持:
* 1. App前台存活时主动扫描+连接
* 2. App被杀后依旧保持PendingIntent扫描并可被UUID广播唤醒
* 3. 内置命令及协议解析
*/
class BleService(private val context: Context) {
// ======================================================================================================
// 常量定义
// ======================================================================================================
companion object {
private const val TAG = "BleService"
}
// ======================================================================================================
// 回调接口
// ======================================================================================================
interface Callback {
// 扫描相关回调
fun onScanResult(devices: List<Map<String, Any>>)
// 连接状态相关回调
fun onConnectionStateChanged(state: Int)
// 数据相关回调
fun onDataReceived(data: ByteArray)
fun onAudioDataReceived(data: ByteArray)
fun onWakeupSignalReceived()
// 命令响应相关回调
fun onCommandResult(commandType: Int, success: Boolean)
fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean)
// 设备信息相关回调
fun onDeviceInfoReceived(infoType: Int, infoData: String)
// 状态更新相关回调
fun onStatusUpdate(status: Map<String, Any>)
}
// ======================================================================================================
// 成员变量
// ======================================================================================================
// 日志与数据存储
private var outputFile: File? = null
private var fos: FileOutputStream? = null
// 蓝牙相关组件
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
// GATT连接相关
private var bluetoothGatt: BluetoothGatt? = null
private var notifyChar: BluetoothGattCharacteristic? = null
private var writeChar: BluetoothGattCharacteristic? = null
// 扫描相关
private val scanHandler = Handler(Looper.getMainLooper())
private val scanResults = ArrayList<ScanResult>()
private var isScanning = false
// 连接状态
val connectionState = MutableLiveData(BleConst.STATE_DISCONNECTED)
// 回调
var callback: Callback? = null
// 设备信息
var firmwareVersion: String? = null
private set
var batteryLevel: Int = 0
private set
// ======================================================================================================
// 扫描功能
// ======================================================================================================
/**
* 开始主动扫描BLE设备
* @param timeout 扫描超时时间(毫秒)
* @return 是否成功开始扫描
*/
fun startScan(timeout: Long = 15000): Boolean {
if (bluetoothLeScanner == null || isScanning) return false
FileLogger.i(TAG, "开始主动扫描BLE设备...")
unregisterBackgroundScan() // 避免双重扫描
scanResults.clear()
val filter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(BleConst.PRIMARY_SERVICE_UUID))
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) // 使用ALL_MATCHES获取所有结果
.setReportDelay(0) // 立即报告结果
.build()
try {
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
isScanning = true
// 设置扫描超时
scanHandler.postDelayed({
FileLogger.d(TAG, "扫描超时,停止扫描")
stopScan()
}, timeout)
return true
} catch (e: Exception) {
FileLogger.e(TAG, "启动扫描失败: ${e.message}", e)
return false
}
}
/**
* 停止主动扫描
* @return 是否成功停止扫描
*/
fun stopScan(): Boolean {
if (bluetoothLeScanner == null || !isScanning) return false
FileLogger.i(TAG, "停止主动扫描")
try {
bluetoothLeScanner.stopScan(scanCallback)
scanHandler.removeCallbacksAndMessages(null)
isScanning = false
// 重新注册后台扫描,保证唤醒能力
registerBackgroundScan()
return true
} catch (e: Exception) {
FileLogger.e(TAG, "停止扫描失败: ${e.message}", e)
return false
}
}
/**
* 扫描回调
*/
private val scanCallback = object : ScanCallback() {
override fun onScanResult(type: Int, result: ScanResult) {
val dev = result.device ?: return
// 打印设备信息和服务UUID
printDeviceInfo(result)
// 直接上报每个扫描结果
try {
// 将单个扫描结果转换为需要的格式
val uuids = result.scanRecord?.serviceUuids?.map { it.uuid.toString() } ?: emptyList()
val deviceMap = mapOf(
"id" to dev.address,
"name" to (dev.name ?: "未知"),
"rssi" to result.rssi,
"uuids" to uuids
)
// 直接回调单个设备结果
callback?.onScanResult(listOf(deviceMap))
// 更新缓存
val idx = scanResults.indexOfFirst { it.device.address == dev.address }
if (idx >= 0) scanResults[idx] = result else scanResults.add(result)
} catch (e: Exception) {
FileLogger.e(TAG, "上报扫描结果异常: ${e.message}", e)
}
}
override fun onScanFailed(errorCode: Int) {
FileLogger.e(TAG, "扫描失败 error=$errorCode")
isScanning = false
}
}
/**
* 打印设备信息和服务UUID
*/
private fun printDeviceInfo(result: ScanResult) {
val device = result.device
val scanRecord = result.scanRecord
val serviceUuids = scanRecord?.serviceUuids?.joinToString(", ") { it.uuid.toString() } ?: "无"
FileLogger.d(TAG, "扫描到设备: 名称=${device.name ?: "未知"}, 地址=${device.address}, " +
"RSSI=${result.rssi}, 服务UUID=[$serviceUuids]")
// 打印广播数据的详细信息
val scanData = scanRecord?.bytes
if (scanData != null) {
val hexString = scanData.joinToString("") { String.format("%02X", it) }
FileLogger.d(TAG, "广播数据: $hexString")
// 解析服务数据
scanRecord.serviceData?.forEach { (uuid, data) ->
val dataHex = data.joinToString("") { String.format("%02X", it) }
FileLogger.d(TAG, "服务数据: UUID=${uuid.uuid}, 数据=$dataHex")
}
}
}
// ======================================================================================================
// 设备连接与通信
// ======================================================================================================
/**
* 连接到指定地址的BLE设备
* @param address 设备MAC地址
*/
fun connect(address: String) {
if (connectionState.value == BleConst.STATE_CONNECTED) {
FileLogger.w(TAG, "已经连接中,忽略连接请求")
return
}
if (bluetoothAdapter == null) {
updateConnectionState(BleConst.STATE_ERROR)
return
}
// 关闭现有连接
disconnectGatt()
try {
// 创建新连接
val device = bluetoothAdapter.getRemoteDevice(address)
updateConnectionState(BleConst.STATE_CONNECTING)
bluetoothGatt = device.connectGatt(
context, false, gattCallback, BluetoothDevice.TRANSPORT_LE
)
} catch (e: Exception) {
FileLogger.e(TAG, "连接设备失败: ${e.message}", e)
updateConnectionState(BleConst.STATE_ERROR)
}
}
/**
* 断开当前连接
*/
fun disconnect() {
bluetoothGatt?.disconnect()
updateConnectionState(BleConst.STATE_DISCONNECTING)
}
/**
* 安全关闭GATT连接
*/
private fun disconnectGatt() {
bluetoothGatt?.let { gatt ->
try {
gatt.disconnect()
gatt.close()
} catch (e: Exception) {
FileLogger.e(TAG, "关闭GATT连接异常: ${e.message}", e)
}
bluetoothGatt = null
notifyChar = null
writeChar = null
}
}
/**
* 更新连接状态并通知回调
*/
private fun updateConnectionState(state: Int) {
connectionState.postValue(state)
callback?.onConnectionStateChanged(state)
}
/**
* GATT回调
*/
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
when {
status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED -> {
updateConnectionState(BleConst.STATE_CONNECTED)
g.discoverServices()
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
updateConnectionState(BleConst.STATE_DISCONNECTED)
disconnectGatt()
}
else -> {
updateConnectionState(BleConst.STATE_ERROR)
disconnectGatt()
}
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
updateConnectionState(BleConst.STATE_ERROR)
return
}
val svc = g.getService(BleConst.PRIMARY_SERVICE_UUID)
notifyChar = svc?.getCharacteristic(BleConst.NOTIFY_CHAR_UUID)
writeChar = svc?.getCharacteristic(BleConst.WRITE_CHAR_UUID)
if (notifyChar == null || writeChar == null) {
FileLogger.e(TAG, "未找到所需特征")
updateConnectionState(BleConst.STATE_ERROR)
return
}
writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
setupNotifications(g)
}
override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) {
val data = c.value ?: return
// 通用数据回调
callback?.onDataReceived(data)
// 判断数据类型
when {
// OPUS音频帧
data.isNotEmpty() && (data[0].toInt() and 0xFF == 0x4B ||
data[0].toInt() and 0xFF == 0x48) -> {
processOpusData(data)
}
// 设备响应 (0xBB开头)
data.isNotEmpty() && data[0] == BleConst.DEVICE_RESPONSE_HEADER -> {
processDeviceResponse(data)
}
// 设备主动上报 (0xCC开头)
data.isNotEmpty() && data[0] == BleConst.DEVICE_NOTIFY_HEADER -> {
processDeviceNotification(data)
}
// 唤醒信号 (0x01开头)
data.isNotEmpty() && data[0].toInt() == 0x01 -> {
FileLogger.i(TAG, "收到唤醒信号")
callback?.onWakeupSignalReceived()
// 接收到唤醒信号,打开编码器
openEncoder()
}
// 其他未知数据
else -> {
FileLogger.d(TAG, "收到未知数据: ${data.joinToString(", ") { "0x%02X".format(it) }}")
}
}
}
override fun onCharacteristicWrite(
g: BluetoothGatt, c: BluetoothGattCharacteristic, status: Int
) {
FileLogger.i(TAG, "写入 ${if (status == BluetoothGatt.GATT_SUCCESS) "成功" else "失败"}")
}
override fun onDescriptorWrite(
g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int
) {
if (status == BluetoothGatt.GATT_SUCCESS)
FileLogger.i(TAG, "通知描述符写入成功")
}
}
/**
* 设置通知功能
*/
private fun setupNotifications(g: BluetoothGatt) {
if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0) return
g.setCharacteristicNotification(notifyChar, true)
val desc = notifyChar!!.getDescriptor(BleConst.CLIENT_CHAR_CONFIG_UUID)
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(desc)
}
/**
* 处理OPUS音频数据
*/
private fun processOpusData(data: ByteArray) {
try {
// 保存原始数据到文件
fos?.write(data)
// 判断是否为OPUS格式数据
val isOpus = data.size > 2 &&
(data[0].toInt() and 0xFF == 0x4B ||
data[0].toInt() and 0xFF == 0x48)
if (isOpus) {
// 回调给上层
callback?.onAudioDataReceived(data)
} else {
FileLogger.w(TAG, "收到非OPUS格式数据")
}
} catch (e: Exception) {
FileLogger.e(TAG, "处理OPUS数据异常: ${e.message}", e)
}
}
// ======================================================================================================
// 后台扫描与唤醒
// ======================================================================================================
/**
* 注册后台持久扫描,APP被杀死后也能唤醒
*/
fun registerBackgroundScan() {
val scanner = bluetoothLeScanner ?: return
try {
val filter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(BleConst.PRIMARY_SERVICE_UUID))
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH)
.build()
val pi = PendingIntent.getBroadcast(
context, 0,
Intent(BleConst.ACTION_SCAN_RESULT)
.setClass(context, BleWakeReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
scanner.startScan(listOf(filter), settings, pi)
FileLogger.i(TAG, "后台持久扫描已注册")
} catch (e: Exception) {
FileLogger.e(TAG, "注册后台扫描失败: ${e.message}", e)
}
}
/**
* 注销后台持久扫描
*/
fun unregisterBackgroundScan() {
val scanner = bluetoothLeScanner ?: return
try {
val pi = PendingIntent.getBroadcast(
context, 0,
Intent(BleConst.ACTION_SCAN_RESULT)
.setClass(context, BleWakeReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
scanner.stopScan(pi)
FileLogger.i(TAG, "后台持久扫描已注销")
} catch (e: Exception) {
FileLogger.e(TAG, "注销后台扫描失败: ${e.message}", e)
}
}
// ======================================================================================================
// 协议命令实现
// ======================================================================================================
/**
* 发送命令到设备 - 通用方法
* @param cmdType 命令类型
* @param cmdData 命令数据
* @return 是否成功发送
*/
private fun sendCommand(cmdType: Byte, cmdData: ByteArray = byteArrayOf()): Boolean {
if (!checkConn()) {
FileLogger.e(TAG, "发送命令失败: 设备未连接")
return false
}
try {
// 计算命令长度
val cmdLength = cmdData.size
// 创建命令包
val cmdPacket = ByteArray(4 + cmdLength) // 帧头 + 命令 + 长度 + 数据 + CRC
// 帧头
cmdPacket[0] = BleConst.APP_REQUEST_HEADER
// 命令类型
cmdPacket[1] = cmdType
// 命令长度
cmdPacket[2] = cmdLength.toByte()
// 填充命令数据(如果有)
if (cmdLength > 0) {
System.arraycopy(cmdData, 0, cmdPacket, 3, cmdLength)
}
// 计算CRC - 对命令类型、长度和数据进行校验
val crcData = ByteArray(2 + cmdLength)
System.arraycopy(cmdPacket, 1, crcData, 0, crcData.size)
val crc = crc8Maxim(crcData, crcData.size)
// 设置CRC
cmdPacket[3 + cmdLength] = crc
// 记录命令详情
FileLogger.d(TAG, "发送命令: CMD=0x${(cmdType.toInt() and 0xFF).toString(16)}, 长度=$cmdLength, " +
"数据=${cmdData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}, " +
"CRC=0x${(crc.toInt() and 0xFF).toString(16)}")
// 发送数据
writeChar!!.value = cmdPacket
return bluetoothGatt?.writeCharacteristic(writeChar) ?: false
} catch (e: Exception) {
FileLogger.e(TAG, "发送命令异常: ${e.message}", e)
return false
}
}
/**
* 计算CRC8/MAXIM校验值
*/
private fun crc8Maxim(data: ByteArray, length: Int): Byte {
var crc = 0
val polynomial = 0x31
for (i in 0 until length) {
crc = crc xor (data[i].toInt() and 0xFF)
for (j in 0 until 8) {
if (crc and 0x80 != 0) {
crc = ((crc shl 1) xor polynomial) and 0xFF
} else {
crc = (crc shl 1) and 0xFF
}
}
}
return crc.toByte()
}
// ======================================================================================================
// 协议解析
// ======================================================================================================
/**
* 处理设备响应数据 - 帧头为0xBB
*/
private fun processDeviceResponse(data: ByteArray) {
if (data.size < 3) {
FileLogger.e(TAG, "设备响应数据格式错误:数据长度过短")
return
}
val header = data[0] // 0xBB
val cmdType = data[1].toInt() and 0xFF
// 判断命令失败响应(长度为0)
if (data.size == 4 && data[2].toInt() and 0xFF == 0) {
FileLogger.w(TAG, "命令 0x${cmdType.toString(16)} 执行失败")
callback?.onProtocolCommandResult(cmdType, null, false)
return
}
// 正常响应处理
val length = data[2].toInt() and 0xFF
// 检查数据长度
if (data.size != length + 4) { // 帧头 + 命令 + 长度 + 数据 + CRC
FileLogger.e(TAG, "设备响应数据长度不匹配: 期望=${length+4}, 实际=${data.size}")
return
}
// 计算CRC校验
val crcData = ByteArray(2 + length) // 命令 + 长度 + 数据
System.arraycopy(data, 1, crcData, 0, crcData.size)
val calculatedCrc = crc8Maxim(crcData, crcData.size)
val receivedCrc = data[data.size - 1]
if (calculatedCrc != receivedCrc) {
FileLogger.e(TAG, "设备响应CRC校验失败: 计算=${calculatedCrc.toInt() and 0xFF}, 接收=${receivedCrc.toInt() and 0xFF}")
return
}
// 提取响应数据
val responseData = if (length > 0) ByteArray(length) else ByteArray(0)
if (length > 0) {
System.arraycopy(data, 3, responseData, 0, length)
}
FileLogger.d(TAG, "收到设备响应: CMD=0x${cmdType.toString(16)}, 数据=${responseData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}")
// 根据命令类型处理不同的响应
when (cmdType) {
BleConst.CMD_GET_VERSION -> {
if (responseData.isNotEmpty()) {
// 转换为字符串前,确认是否是有效的ASCII字符
val version = responseData.joinToString(".") { (it.toInt() and 0xFF).toString() }
firmwareVersion = version
callback?.onDeviceInfoReceived(BleConst.CMD_GET_VERSION, version)
FileLogger.i(TAG, "接收到目标版本号: $version")
}
callback?.onProtocolCommandResult(cmdType, responseData, true)
}
BleConst.CMD_GET_PRODUCT_ID -> {
if (responseData.isNotEmpty() && responseData.size >= 2) {
val productId = "0x${(responseData[0].toInt() and 0xFF).toString(16)}${(responseData[1].toInt() and 0xFF).toString(16)}"
FileLogger.i(TAG, "接收到产品ID: $productId")
callback?.onDeviceInfoReceived(BleConst.CMD_GET_PRODUCT_ID, productId)
}
callback?.onProtocolCommandResult(cmdType, responseData, true)
}
BleConst.CMD_GET_COLOR_ID -> {
if (responseData.isNotEmpty()) {
val colorId = (responseData[0].toInt() and 0xFF).toString()
FileLogger.i(TAG, "接收到耳机颜色ID: $colorId")
callback?.onDeviceInfoReceived(BleConst.CMD_GET_COLOR_ID, colorId)
}
callback?.onProtocolCommandResult(cmdType, responseData, true)
}
BleConst.CMD_GET_BATTERY_INFO -> {
if (responseData.isNotEmpty() && responseData.size >= 3) {
val batteryLevel = responseData[0].toInt() and 0x7F
val isCharging = (responseData[0].toInt() and 0x80) != 0
FileLogger.i(TAG, "接收到电量信息: ${batteryLevel}%, ${if(isCharging) "充电中" else "未充电"}")
// 更新电池电量
this.batteryLevel = batteryLevel
callback?.onDeviceInfoReceived(BleConst.CMD_GET_BATTERY_INFO, "$batteryLevel|${if(isCharging) "1" else "0"}")
}
callback?.onProtocolCommandResult(cmdType, responseData, true)
}
BleConst.CMD_CONTROL_CODEC -> {
if (responseData.isNotEmpty() && responseData.size >= 2) {
val codecStatus = responseData[0].toInt() and 0xFF
val channelMode = responseData[1].toInt() and 0xFF
// 解码状态描述
val statusDesc = when (codecStatus) {
BleConst.CODEC_CONTROL_CLOSE -> "已关闭编解码"
BleConst.CODEC_CONTROL_DECODE_ON -> "已打开解码"
BleConst.CODEC_CONTROL_A2DP_PLAY -> "A2DP播放模式"
BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码"
else -> "未知状态($codecStatus)"
}
// 声道模式描述
val channelDesc = when (channelMode) {
BleConst.AUDIO_CHANNEL_LEFT -> "左声道"
BleConst.AUDIO_CHANNEL_RIGHT -> "右声道"
BleConst.AUDIO_CHANNEL_STEREO -> "立体声"
else -> "未知声道($channelMode)"
}
FileLogger.i(TAG, "编解码控制响应: $statusDesc, $channelDesc")
}
callback?.onProtocolCommandResult(cmdType, responseData, true)
}
else -> {
FileLogger.d(TAG, "收到未知命令响应: 0x${cmdType.toString(16)}")
callback?.onProtocolCommandResult(cmdType, responseData, true)
}
}
}
/**
* 处理设备主动上报数据 - 帧头为0xCC
*/
private fun processDeviceNotification(data: ByteArray) {
if (data.size < 3) {
FileLogger.e(TAG, "设备主动上报数据格式错误:数据长度过短")
return
}
val header = data[0] // 0xCC
val infoType = data[1].toInt() and 0xFF
val length = data[2].toInt() and 0xFF
// 检查数据长度
if (data.size != length + 4) { // 帧头 + 类型 + 长度 + 数据 + CRC
FileLogger.e(TAG, "设备主动上报数据长度不匹配: 期望=${length+4}, 实际=${data.size}")
return
}
// 计算CRC校验
val crcData = ByteArray(2 + length) // 类型 + 长度 + 数据
System.arraycopy(data, 1, crcData, 0, crcData.size)
val calculatedCrc = crc8Maxim(crcData, crcData.size)
val receivedCrc = data[data.size - 1]
if (calculatedCrc != receivedCrc) {
FileLogger.e(TAG, "设备主动上报CRC校验失败: 计算=${calculatedCrc.toInt() and 0xFF}, 接收=${receivedCrc.toInt() and 0xFF}")
return
}
// 提取上报数据
val notifyData = if (length > 0) ByteArray(length) else ByteArray(0)
if (length > 0) {
System.arraycopy(data, 3, notifyData, 0, length)
}
FileLogger.d(TAG, "收到设备主动上报: 类型=0x${infoType.toString(16)}, 数据=${notifyData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}")
// 根据上报类型处理
when (infoType) {
BleConst.CMD_GET_BATTERY_INFO -> { // 电量信息
if (notifyData.isNotEmpty() && notifyData.size >= 3) {
val batteryLevel = notifyData[0].toInt() and 0x7F
val isCharging = (notifyData[0].toInt() and 0x80) != 0
FileLogger.i(TAG, "接收到电量信息: ${batteryLevel}%, ${if(isCharging) "充电中" else "未充电"}")
// 更新电池电量
this.batteryLevel = batteryLevel
callback?.onDeviceInfoReceived(BleConst.CMD_GET_BATTERY_INFO, "$batteryLevel|${if(isCharging) "1" else "0"}")
}
}
// 可以在这里添加其他主动上报类型的处理
else -> {
FileLogger.d(TAG, "收到未知主动上报类型: 0x${infoType.toString(16)}")
}
}
}
// ======================================================================================================
// 公开的命令接口
// ======================================================================================================
/**
* 获取耳机版本号
*/
fun getDeviceVersion(): Boolean {
FileLogger.i(TAG, "获取耳机版本号...")
return sendCommand(BleConst.CMD_GET_VERSION.toByte(), byteArrayOf(0xFF.toByte()))
}
/**
* 获取产品ID
*/
fun getProductId(): Boolean {
FileLogger.i(TAG, "获取耳机产品ID...")
return sendCommand(BleConst.CMD_GET_PRODUCT_ID.toByte(), byteArrayOf(0xFF.toByte()))
}
/**
* 获取耳机颜色ID
*/
fun getColorId(): Boolean {
FileLogger.i(TAG, "获取耳机颜色ID...")
return sendCommand(BleConst.CMD_GET_COLOR_ID.toByte(), byteArrayOf(0xFF.toByte()))
}
/**
* 获取耳机电量信息
*/
fun getBatteryInfo(): Boolean {
FileLogger.i(TAG, "获取耳机电量信息...")
return sendCommand(BleConst.CMD_GET_BATTERY_INFO.toByte(), byteArrayOf(0xFF.toByte()))
}
/**
* 控制编解码 - 关闭编解码
*/
fun closeCodec(): Boolean {
FileLogger.i(TAG, "关闭编解码...")
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_CLOSE.toByte()))
}
/**
* 控制编解码 - 打开解码
*/
fun openDecoder(): Boolean {
FileLogger.i(TAG, "打开解码...")
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_DECODE_ON.toByte()))
}
/**
* 控制编解码 - A2DP播放
*/
fun playA2DP(): Boolean {
FileLogger.i(TAG, "切换至A2DP播放模式...")
return sendCommand(BleConst.CMD_PLAY_A2DP.toByte())
}
/**
* 控制编解码 - 打开编码
*/
fun openEncoder(): Boolean {
FileLogger.i(TAG, "打开编码...")
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_ENCODE_ON.toByte()))
}
/**
* 设置声道模式
* @param channelMode 声道模式:AUDIO_CHANNEL_LEFT/AUDIO_CHANNEL_RIGHT/AUDIO_CHANNEL_STEREO
*/
fun setChannelMode(channelMode: Int): Boolean {
when (channelMode) {
BleConst.AUDIO_CHANNEL_LEFT -> FileLogger.i(TAG, "设置左声道模式...")
BleConst.AUDIO_CHANNEL_RIGHT -> FileLogger.i(TAG, "设置右声道模式...")
BleConst.AUDIO_CHANNEL_STEREO -> FileLogger.i(TAG, "设置立体声模式...")
else -> {
FileLogger.e(TAG, "无效的声道模式: $channelMode")
return false
}
}
// 高级控制:设置命令类型、长度和两个参数
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(
0xA1.toByte(), // 状态码 - 使用解码状态
channelMode.toByte() // 声道模式
))
}
/**
* 音量增大
*/
fun volumeUp(): Boolean {
FileLogger.i(TAG, "设备音量增加...")
return sendCommand(BleConst.CMD_VOLUME_UP.toByte())
}
/**
* 音量减小
*/
fun volumeDown(): Boolean {
FileLogger.i(TAG, "设备音量减小...")
return sendCommand(BleConst.CMD_VOLUME_DOWN.toByte())
}
/**
* 发送音频数据
* @param data 音频数据
*/
fun sendAudioData(data: ByteArray): Boolean {
if (!checkConn()) {
FileLogger.e(TAG, "发送音频数据失败: 设备未连接")
return false
}
if (writeChar == null) {
FileLogger.e(TAG, "发送音频数据失败: 写特征为空")
return false
}
try {
writeChar?.value = data
return bluetoothGatt?.writeCharacteristic(writeChar) ?: false
} catch (e: Exception) {
FileLogger.e(TAG, "发送音频数据异常: ${e.message}", e)
return false
}
}
// ======================================================================================================
// 工具方法
// ======================================================================================================
/**
* 检查是否已连接
*/
fun isConnected(): Boolean {
return connectionState.value == BleConst.STATE_CONNECTED
}
/**
* 连接状态检查
*/
private fun checkConn(): Boolean =
bluetoothGatt != null && writeChar != null &&
connectionState.value == BleConst.STATE_CONNECTED
/**
* 获取已连接设备地址
*/
fun getConnectedDeviceAddress(): String? {
return bluetoothGatt?.device?.address
}
/**
* 获取蓝牙适配器
*/
fun getBluetoothAdapter(): BluetoothAdapter? {
return bluetoothAdapter
}
// ======================================================================================================
// 资源清理
// ======================================================================================================
/**
* 清理所有资源
*/
fun cleanup() {
stopScan()
scanHandler.removeCallbacksAndMessages(null)
disconnectGatt()
fos?.close()
fos = null
outputFile = null
}
}

425
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt

@ -0,0 +1,425 @@
package com.yunqiinnovation.ble_service
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.PluginRegistry
import com.yunqiinnovation.deepvoice_utils.FileLogger
/**
* BleServicePlugin
* 作为Flutter与原生BLE通信的插件接口
*/
class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, PluginRegistry.RequestPermissionsResultListener, BleService.Callback {
private val TAG = "BleServicePlugin"
// 添加主线程Handler
private val mainHandler = Handler(Looper.getMainLooper())
// 方法通道
private lateinit var methodChannel: MethodChannel
// 事件通道
private var scanEventChannel: EventChannel? = null
private var connectionEventChannel: EventChannel? = null
private var dataEventChannel: EventChannel? = null
private var statusEventChannel: EventChannel? = null
// 应用上下文
private lateinit var context: Context
// BleManager包装器
private val bleManager = BleManagerWrapper.instance
// 事件接收器
private var scanEventSink: EventChannel.EventSink? = null
private var connectionEventSink: EventChannel.EventSink? = null
private var dataEventSink: EventChannel.EventSink? = null
private var statusEventSink: EventChannel.EventSink? = null
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
FileLogger.init(flutterPluginBinding.applicationContext)
FileLogger.d(TAG, "BleServicePlugin onAttachedToEngine")
// 设置方法通道
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.ble_service")
methodChannel.setMethodCallHandler(this)
// 设置事件通道
setupEventChannels(flutterPluginBinding)
context = flutterPluginBinding.applicationContext
// 初始化BleManagerWrapper
try {
bleManager.init(context)
bleManager.addCallback(this)
// 开始定期状态更新
startPeriodicStatusUpdates()
FileLogger.d(TAG, "BleManagerWrapper initialized successfully")
} catch (e: Exception) {
FileLogger.e(TAG, "Failed to initialize BleManagerWrapper: ${e.message}", e)
}
}
// 设置事件通道
private fun setupEventChannels(binding: FlutterPlugin.FlutterPluginBinding) {
// 扫描结果事件通道
scanEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/scan_events").apply {
setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
scanEventSink = events
}
override fun onCancel(arguments: Any?) {
scanEventSink = null
}
})
}
// 连接状态事件通道
connectionEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/connection_events").apply {
setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
connectionEventSink = events
}
override fun onCancel(arguments: Any?) {
connectionEventSink = null
}
})
}
// 数据接收事件通道
dataEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/data_events").apply {
setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
dataEventSink = events
}
override fun onCancel(arguments: Any?) {
dataEventSink = null
}
})
}
// 状态更新事件通道
statusEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/status_events").apply {
setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
statusEventSink = events
// 立即发送一次状态更新
sendStatusUpdate()
}
override fun onCancel(arguments: Any?) {
statusEventSink = null
}
})
}
}
// 开始定期状态更新
private fun startPeriodicStatusUpdates() {
// 添加节流标志
var lastUpdateTime = 0L
Thread {
try {
while (!Thread.interrupted()) {
val currentTime = System.currentTimeMillis()
// 确保至少间隔5秒才发送一次状态更新
if (currentTime - lastUpdateTime >= 5000) {
sendStatusUpdate()
lastUpdateTime = currentTime
}
Thread.sleep(3000) // 保持同样的休眠时间
}
} catch (e: InterruptedException) {
FileLogger.d(TAG, "状态更新线程已停止")
}
}.apply {
isDaemon = true
start()
}
}
// 添加sendEvent函数
private fun <T> sendEvent(eventSink: EventChannel.EventSink?, data: T, errorPrefix: String) {
if (eventSink == null) return
// 确保在主线程执行
if (Looper.myLooper() == Looper.getMainLooper()) {
try {
eventSink.success(data)
} catch (e: Exception) {
FileLogger.e(TAG, "$errorPrefix: ${e.message}", e)
}
} else {
mainHandler.post {
try {
eventSink.success(data)
} catch (e: Exception) {
FileLogger.e(TAG, "$errorPrefix: ${e.message}", e)
}
}
}
}
// 修改sendStatusUpdate方法,使用新的sendEvent函数
private fun sendStatusUpdate() {
try {
val connectionState = bleManager.getConnectionState()
val stateStr = when(connectionState) {
BleConst.STATE_DISCONNECTED -> "已断开"
BleConst.STATE_CONNECTING -> "连接中"
BleConst.STATE_CONNECTED -> "已连接"
BleConst.STATE_DISCONNECTING -> "断开中"
BleConst.STATE_ERROR -> "连接错误"
else -> "未知状态($connectionState)"
}
val stateMap = mapOf(
"type" to "statusUpdate",
"connectionState" to connectionState,
"connectionStateStr" to stateStr,
"batteryLevel" to bleManager.getBatteryLevel(),
"firmwareVersion" to (bleManager.getFirmwareVersion() ?: "未知"),
"connectedDevice" to (bleManager.getConnectedDeviceAddress() ?: "")
)
// 使用sendEvent函数发送事件
sendEvent(statusEventSink, stateMap, "发送状态更新失败")
} catch (e: Exception) {
FileLogger.e(TAG, "准备状态更新数据失败: ${e.message}", e)
}
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
// 移除回调
bleManager.removeCallback(this)
// 解除事件通道
scanEventChannel?.setStreamHandler(null)
connectionEventChannel?.setStreamHandler(null)
dataEventChannel?.setStreamHandler(null)
statusEventChannel?.setStreamHandler(null)
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
FileLogger.d(TAG, "Method call: ${call.method}")
when (call.method) {
"initialize" -> {
try {
bleManager.init(context)
result.success(true)
} catch (e: Exception) {
result.error("INIT_ERROR", "初始化BLE服务失败: ${e.message}", null)
}
}
"registerCallback" -> {
try {
bleManager.addCallback(this)
result.success(true)
} catch (e: Exception) {
result.error("CALLBACK_ERROR", "设置回调失败: ${e.message}", null)
}
}
"getPlatformVersion" -> {
result.success("Android ${Build.VERSION.RELEASE}")
}
"startScan" -> {
val timeout = call.argument<Number>("timeout")?.toLong() ?: 10000L
val success = bleManager.startScan(timeout)
result.success(success)
}
"stopScan" -> {
val success = bleManager.stopScan()
result.success(success)
}
"connect" -> {
val address = call.argument<String>("address")
if (address == null) {
result.error("INVALID_ARGUMENT", "Address cannot be null", null)
return
}
bleManager.connect(address)
result.success(true)
}
"disconnect" -> {
bleManager.disconnect()
result.success(true)
}
"isConnected" -> {
val connected = bleManager.isConnected()
result.success(connected)
}
"getConnectionState" -> {
val state = bleManager.getConnectionState()
result.success(state)
}
"getConnectedDeviceAddress" -> {
val address = bleManager.getConnectedDeviceAddress()
result.success(address)
}
"getBatteryLevel" -> {
val level = bleManager.getBatteryLevel()
result.success(level)
}
"getFirmwareVersion" -> {
val version = bleManager.getFirmwareVersion()
result.success(version)
}
"getDeviceVersion" -> {
val success = bleManager.getDeviceVersion()
result.success(success)
}
"getBatteryInfo" -> {
val success = bleManager.getBatteryInfo()
result.success(success)
}
"openEncoder" -> {
val success = bleManager.openEncoder()
result.success(success)
}
"openDecoder" -> {
val success = bleManager.openDecoder()
result.success(success)
}
"closeCodec" -> {
val success = bleManager.closeCodec()
result.success(success)
}
"playA2DP" -> {
val success = bleManager.playA2DP()
result.success(success)
}
"volumeUp" -> {
val success = bleManager.volumeUp()
result.success(success)
}
"volumeDown" -> {
val success = bleManager.volumeDown()
result.success(success)
}
"sendAudioData" -> {
val data = call.argument<ByteArray>("data")
if (data == null) {
result.error("INVALID_ARGUMENT", "Audio data cannot be null", null)
return
}
val success = bleManager.sendAudioData(data)
result.success(success)
}
"cleanup" -> {
bleManager.cleanup()
result.success(true)
}
"getScannedDevices" -> {
val devices = bleManager.getScannedDevices()
result.success(devices)
}
else -> {
result.notImplemented()
}
}
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
binding.addRequestPermissionsResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
// 无需实现
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
binding.addRequestPermissionsResultListener(this)
}
override fun onDetachedFromActivity() {
// 无需实现
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
): Boolean {
// 简化权限处理
return false
}
// BleService.Callback 接口实现
override fun onScanResult(devices: List<Map<String, Any>>) {
sendEvent(scanEventSink, mapOf("devices" to devices), "发送扫描结果异常")
}
override fun onConnectionStateChanged(state: Int) {
sendEvent(connectionEventSink, state, "发送连接状态异常")
sendStatusUpdate()
}
override fun onDataReceived(data: ByteArray) {
sendEvent(dataEventSink, mapOf("type" to "rawData", "data" to data), "发送原始数据异常")
}
override fun onAudioDataReceived(data: ByteArray) {
sendEvent(dataEventSink, mapOf("type" to "audioData", "data" to data), "发送音频数据异常")
}
override fun onWakeupSignalReceived() {
sendEvent(dataEventSink, mapOf("type" to "wakeup"), "发送唤醒信号异常")
}
override fun onCommandResult(commandType: Int, success: Boolean) {
sendEvent(connectionEventSink, mapOf(
"type" to "commandResult",
"commandType" to commandType,
"success" to success
), "发送命令结果异常")
}
override fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean) {
sendEvent(connectionEventSink, mapOf(
"type" to "protocolCommandResult",
"commandType" to commandType,
"data" to (data?.let { String(it) } ?: ""),
"success" to success
), "发送协议命令结果异常")
}
override fun onDeviceInfoReceived(infoType: Int, infoData: String) {
sendEvent(statusEventSink, mapOf(
"type" to "deviceInfo",
"infoType" to infoType,
"infoData" to infoData
), "发送设备信息异常")
sendStatusUpdate()
}
override fun onStatusUpdate(status: Map<String, Any>) {
sendEvent(statusEventSink, mapOf(
"type" to "statusUpdate",
"status" to status
), "发送状态更新异常")
}
}

34
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt

@ -0,0 +1,34 @@
package com.yunqiinnovation.ble_service
import android.content.*
import android.bluetooth.le.*
import android.os.Build
import com.yunqiinnovation.deepvoice_utils.FileLogger
/**
* BLE唤醒接收器
* 当耳机发送含特定UUID广播时,系统会触发此接收器
* 接收器会解析MAC地址并启动前台服务进行连接
*/
class BleWakeReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BleWakeReceiver"
}
override fun onReceive(ctx: Context, intent: Intent) {
FileLogger.init(ctx)
if (intent.action != BleConst.ACTION_SCAN_RESULT) return
val list = intent.getParcelableArrayListExtra<ScanResult>(
BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT
) ?: return
val mac = list.first().device.address
FileLogger.d(TAG, "被系统唤醒,发现设备 $mac")
// 拉起前台服务并把MAC传过去
val svc = Intent(ctx, BleForegroundService::class.java).putExtra("mac", mac)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
ctx.startForegroundService(svc) else ctx.startService(svc)
}
}

19
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt

@ -0,0 +1,19 @@
package com.yunqiinnovation.ble_service
import android.content.*
import android.os.Build
/**
* 系统启动接收器
* 当系统重启完成后,自动启动BLE前台服务
* 注意:需要用户在设置中授予应用自启动权限才能生效
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, i: Intent) {
if (i.action == Intent.ACTION_BOOT_COMPLETED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
ctx.startForegroundService(Intent(ctx, BleForegroundService::class.java))
else ctx.startService(Intent(ctx, BleForegroundService::class.java))
}
}
}

356
local_plugins/ble_service/lib/ble_service.dart

@ -0,0 +1,356 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/services.dart';
/// BLE连接状态常量
class BleConnectionState {
static const int disconnected = 0;
static const int connecting = 1;
static const int connected = 2;
static const int disconnecting = 3;
static const int error = 4;
}
/// BLE设备数据模型
class BleDevice {
final String id;
final String name;
final int rssi;
final List<String> uuids;
BleDevice({
required this.id,
required this.name,
required this.rssi,
required this.uuids,
});
factory BleDevice.fromMap(Map<dynamic, dynamic> map) {
final uuidsList = (map['uuids'] as List?)?.cast<String>() ?? [];
return BleDevice(
id: map['id'] as String,
name: map['name'] as String? ?? 'Unknown',
rssi: map['rssi'] as int,
uuids: uuidsList,
);
}
@override
String toString() => 'BleDevice(id: $id, name: $name, rssi: $rssi, uuids: $uuids)';
}
/// BLE服务插件
class BleService {
static final BleService _instance = BleService._internal();
/// 获取单例
factory BleService() => _instance;
// 方法通道
final MethodChannel _methodChannel = const MethodChannel('com.yunqiinnovation.ble_service');
// 事件通道
late final EventChannel _scanEventChannel;
late final EventChannel _connectionEventChannel;
late final EventChannel _dataEventChannel;
late final EventChannel _statusEventChannel;
// 事件流
late final Stream<List<BleDevice>> _scanStream;
late final Stream<dynamic> _connectionStream;
late final Stream<dynamic> _dataStream;
late final Stream<dynamic> _statusStream;
// 获取事件流
Stream<List<BleDevice>> get onScanResults => _scanStream;
Stream<dynamic> get onConnectionChanged => _connectionStream;
Stream<dynamic> get onDataReceived => _dataStream;
Stream<dynamic> get onStatusUpdated => _statusStream;
// 内部构造函数
BleService._internal() {
_scanEventChannel = const EventChannel('com.yunqiinnovation.ble_service/scan_events');
_connectionEventChannel = const EventChannel('com.yunqiinnovation.ble_service/connection_events');
_dataEventChannel = const EventChannel('com.yunqiinnovation.ble_service/data_events');
_statusEventChannel = const EventChannel('com.yunqiinnovation.ble_service/status_events');
// 初始化事件流
_scanStream = _scanEventChannel.receiveBroadcastStream().map<List<BleDevice>>((event) {
final List<dynamic> devices = (event['devices'] as List?) ?? [];
return devices.map((device) => BleDevice.fromMap(device)).toList();
});
_connectionStream = _connectionEventChannel.receiveBroadcastStream();
_dataStream = _dataEventChannel.receiveBroadcastStream();
_statusStream = _statusEventChannel.receiveBroadcastStream();
}
/// 初始化BLE服务
Future<bool> initialize() async {
try {
final result = await _methodChannel.invokeMethod<bool>('initialize');
return result ?? false;
} catch (e) {
print('初始化BLE服务失败: $e');
return false;
}
}
/// 注册BLE回调
Future<bool> registerCallback() async {
try {
final result = await _methodChannel.invokeMethod<bool>('registerCallback');
return result ?? false;
} catch (e) {
print('注册BLE回调失败: $e');
return false;
}
}
/// 设置BLE方法回调处理器
void setMethodCallHandler(Future<dynamic> Function(MethodCall call) handler) {
_methodChannel.setMethodCallHandler(handler);
}
/// 开始扫描设备
Future<bool> startScan({int timeout = 10000}) async {
try {
final result = await _methodChannel.invokeMethod<bool>(
'startScan',
{'timeout': timeout},
);
return result ?? false;
} catch (e) {
print('开始扫描设备失败: $e');
return false;
}
}
/// 停止扫描设备
Future<bool> stopScan() async {
try {
final result = await _methodChannel.invokeMethod<bool>('stopScan');
return result ?? false;
} catch (e) {
print('停止扫描设备失败: $e');
return false;
}
}
/// 连接到设备
Future<bool> connect(String address) async {
try {
final result = await _methodChannel.invokeMethod<bool>(
'connect',
{'address': address},
);
return result ?? false;
} catch (e) {
print('连接设备失败: $e');
return false;
}
}
/// 断开设备连接
Future<bool> disconnect() async {
try {
final result = await _methodChannel.invokeMethod<bool>('disconnect');
return result ?? false;
} catch (e) {
print('断开设备连接失败: $e');
return false;
}
}
/// 检查是否已连接
Future<bool> isConnected() async {
try {
final result = await _methodChannel.invokeMethod<bool>('isConnected');
return result ?? false;
} catch (e) {
print('检查连接状态失败: $e');
return false;
}
}
/// 获取连接状态
Future<int> getConnectionState() async {
try {
final result = await _methodChannel.invokeMethod<int>('getConnectionState');
return result ?? BleConnectionState.disconnected;
} catch (e) {
print('获取连接状态失败: $e');
return BleConnectionState.disconnected;
}
}
/// 获取已连接设备地址
Future<String?> getConnectedDeviceAddress() async {
try {
return await _methodChannel.invokeMethod<String>('getConnectedDeviceAddress');
} catch (e) {
print('获取已连接设备地址失败: $e');
return null;
}
}
/// 获取电池电量
Future<int> getBatteryLevel() async {
try {
final result = await _methodChannel.invokeMethod<int>('getBatteryLevel');
return result ?? 0;
} catch (e) {
print('获取电池电量失败: $e');
return 0;
}
}
/// 获取固件版本
Future<String?> getFirmwareVersion() async {
try {
return await _methodChannel.invokeMethod<String>('getFirmwareVersion');
} catch (e) {
print('获取固件版本失败: $e');
return null;
}
}
/// 获取设备版本信息
Future<bool> getDeviceVersion() async {
try {
final result = await _methodChannel.invokeMethod<bool>('getDeviceVersion');
return result ?? false;
} catch (e) {
print('获取设备版本失败: $e');
return false;
}
}
/// 获取电池信息
Future<bool> getBatteryInfo() async {
try {
final result = await _methodChannel.invokeMethod<bool>('getBatteryInfo');
return result ?? false;
} catch (e) {
print('获取电池信息失败: $e');
return false;
}
}
/// 打开编码器
Future<bool> openEncoder() async {
try {
final result = await _methodChannel.invokeMethod<bool>('openEncoder');
return result ?? false;
} catch (e) {
print('打开编码器失败: $e');
return false;
}
}
/// 打开解码器
Future<bool> openDecoder() async {
try {
final result = await _methodChannel.invokeMethod<bool>('openDecoder');
return result ?? false;
} catch (e) {
print('打开解码器失败: $e');
return false;
}
}
/// 关闭编解码器
Future<bool> closeCodec() async {
try {
final result = await _methodChannel.invokeMethod<bool>('closeCodec');
return result ?? false;
} catch (e) {
print('关闭编解码器失败: $e');
return false;
}
}
/// 播放A2DP
Future<bool> playA2DP() async {
try {
final result = await _methodChannel.invokeMethod<bool>('playA2DP');
return result ?? false;
} catch (e) {
print('播放A2DP失败: $e');
return false;
}
}
/// 增加音量
Future<bool> volumeUp() async {
try {
final result = await _methodChannel.invokeMethod<bool>('volumeUp');
return result ?? false;
} catch (e) {
print('增加音量失败: $e');
return false;
}
}
/// 减小音量
Future<bool> volumeDown() async {
try {
final result = await _methodChannel.invokeMethod<bool>('volumeDown');
return result ?? false;
} catch (e) {
print('减小音量失败: $e');
return false;
}
}
/// 发送音频数据
Future<bool> sendAudioData(Uint8List data) async {
try {
final result = await _methodChannel.invokeMethod<bool>(
'sendAudioData',
{'data': data},
);
return result ?? false;
} catch (e) {
print('发送音频数据失败: $e');
return false;
}
}
/// 清理资源
Future<bool> cleanup() async {
try {
final result = await _methodChannel.invokeMethod<bool>('cleanup');
return result ?? false;
} catch (e) {
print('清理资源失败: $e');
return false;
}
}
/// 获取已扫描到的设备列表
Future<List<BleDevice>> getScannedDevices() async {
try {
final result = await _methodChannel.invokeMethod<List<dynamic>>('getScannedDevices');
if (result != null) {
return result.map((item) => BleDevice.fromMap(Map<String, dynamic>.from(item as Map))).toList();
}
return [];
} catch (e) {
print('获取扫描设备列表失败: $e');
return [];
}
}
/// 获取平台版本
Future<String?> getPlatformVersion() async {
try {
return await _methodChannel.invokeMethod<String>('getPlatformVersion');
} catch (e) {
print('获取平台版本失败: $e');
return null;
}
}
}

24
local_plugins/ble_service/pubspec.yaml

@ -0,0 +1,24 @@
name: ble_service
description: BLE服务插件,提供蓝牙扫描、连接和通信功能
version: 0.0.1
homepage:
environment:
sdk: ">=2.16.0 <3.0.0"
flutter: ">=2.5.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^1.0.0
flutter:
plugin:
platforms:
android:
package: com.yunqiinnovation.ble_service
pluginClass: BleServicePlugin

175
local_plugins/bytedance_speech/README.md

@ -0,0 +1,175 @@
# Bytedance Speech Plugin
火山引擎语音识别和语音合成插件,为Flutter应用提供高质量的语音识别(ASR)和语音合成(TTS)能力。
## 功能特性
- **语音识别(ASR)**
- 支持实时语音识别
- 支持流式输入和流式识别两种模式
- 支持中文普通话等多种语言
- 支持标点符号自动添加
- **语音合成(TTS)**
- 支持高质量语音合成
- 支持多种音色选择
- 支持多种音频格式输出
- 支持使用大模型合成自然的语音
## 安装
将以下内容添加到您的`pubspec.yaml`文件中:
```yaml
dependencies:
bytedance_speech:
path: ../local_plugins/bytedance_speech
```
## 使用方法
### 初始化插件
```dart
import 'package:bytedance_speech/bytedance_speech.dart';
// 获取插件实例
final bytedanceSpeech = BytedanceSpeech();
// 初始化ASR
await bytedanceSpeech.initAsr(
AsrConfig(
appId: 'YOUR_APP_ID',
accessToken: 'YOUR_ACCESS_TOKEN',
streamMode: true, // true: 双向流式模式, false: 流式输入模式
enablePunctuation: true,
sampleRate: 16000,
bits: 16,
channels: 1,
),
);
// 初始化TTS
await bytedanceSpeech.initTts(
TtsConfig(
appId: 'YOUR_APP_ID',
accessToken: 'YOUR_ACCESS_TOKEN',
speaker: 'zh_female_shuangkuaisisi_moon_bigtts', // 选择音色
format: 'mp3',
sampleRate: 24000,
enableTimestamp: true,
),
);
```
### 语音识别(ASR)
```dart
// 监听ASR事件
bytedanceSpeech.onAsrEvent.listen((event) {
switch (event.type) {
case AsrEventType.recognizing:
// 处理中间识别结果
print('识别中:${event.text}');
break;
case AsrEventType.result:
// 处理最终识别结果
print('识别结果:${event.text}');
break;
case AsrEventType.error:
// 处理错误
print('识别错误:${event.errorCode} - ${event.errorMessage}');
break;
case AsrEventType.sessionStarted:
print('开始识别会话');
break;
case AsrEventType.sessionStopped:
print('识别会话结束');
break;
default:
break;
}
});
// 开始识别
await bytedanceSpeech.startAsr();
// 发送音频数据
await bytedanceSpeech.feedAudioData(audioBytes);
// 停止识别
await bytedanceSpeech.stopAsr();
// 释放资源
await bytedanceSpeech.disposeAsr();
```
### 语音合成(TTS)
```dart
// 监听TTS事件
bytedanceSpeech.onTtsEvent.listen((event) {
switch (event.type) {
case TtsEventType.audio:
// 处理音频数据
if (event.audioData != null) {
// 播放或保存音频数据
playAudio(event.audioData!);
}
break;
case TtsEventType.completed:
// 合成完成
print('语音合成完成');
break;
case TtsEventType.error:
// 处理错误
print('合成错误:${event.errorCode} - ${event.errorMessage}');
break;
case TtsEventType.started:
print('开始合成');
break;
default:
break;
}
});
// 合成文本
await bytedanceSpeech.synthesize('您好,欢迎使用火山引擎语音合成服务。');
// 停止合成
await bytedanceSpeech.stopTts();
// 释放资源
await bytedanceSpeech.disposeTts();
```
## 音色列表
以下是部分可用的音色列表:
| 音色ID | 描述 |
|--------|------|
| zh_female_shuangkuaisisi_moon_bigtts | 中文女声-爽快思思 |
| zh_male_zhiqiangqikai_moon_bigtts | 中文男声-志强起凯 |
| zh_female_xiaoya_moon_bigtts | 中文女声-小雅 |
| zh_male_xiaoming_moon_bigtts | 中文男声-小明 |
完整的音色列表,请参考火山引擎官方文档:[火山引擎音色列表](https://www.volcengine.com/docs/6561/123172)
## 许可证
本插件基于MIT许可证开源。
## 相关链接
- [火山引擎官网](https://www.volcengine.com/)
- [火山引擎语音服务文档](https://www.volcengine.com/docs/6561/65067)

36
local_plugins/bytedance_speech/android/build.gradle.kts

@ -0,0 +1,36 @@
group = "com.yunqiinnovation.bytedance_speech"
version = "1.0.0"
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.yunqiinnovation.ble_service"
compileSdk = 34
defaultConfig {
minSdk = 26
targetSdk = 34
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(kotlin("stdlib-jdk7"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
implementation("com.google.code.gson:gson:2.10.1")
}

1
local_plugins/bytedance_speech/android/settings.gradle.kts

@ -0,0 +1 @@
rootProject.name = "bytedance_speech"

8
local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml

@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.deep_voice.bytedance_speech">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
</manifest>

750
local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt

@ -0,0 +1,750 @@
package com.deep_voice.bytedance_speech
import android.content.Context
import com.google.gson.Gson
import com.google.gson.JsonObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.logging.HttpLoggingInterceptor.Level
import okio.ByteString
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
/**
* 火山引擎大模型语音识别实现
*
* 基于WebSocket协议实现实时语音识别
*/
class BytedanceASR(
private val context: Context,
private val resultCallback: (String, Boolean) -> Unit,
private val errorCallback: (String, String) -> Unit,
private val statusCallback: (String) -> Unit
) {
companion object {
// 协议常量
private const val PROTOCOL_VERSION = 0x01
private const val DEFAULT_HEADER_SIZE = 0x01
// 消息类型
private const val FULL_CLIENT_REQUEST = 0x01
private const val AUDIO_ONLY_REQUEST = 0x02
private const val FULL_SERVER_RESPONSE = 0x09
private const val SERVER_ACK = 0x0B
private const val SERVER_ERROR_RESPONSE = 0x0F
// 消息类型特定标志
private const val NO_SEQUENCE = 0x00
private const val POS_SEQUENCE = 0x01
private const val NEG_SEQUENCE = 0x02
private const val NEG_WITH_SEQUENCE = 0x03
// 序列化方法
private const val NO_SERIALIZATION = 0x00
private const val JSON = 0x01
// 压缩方法
private const val NO_COMPRESSION = 0x00
private const val GZIP = 0x01
// 状态常量
private const val STATUS_INITIALIZING = "initializing"
private const val STATUS_READY = "ready"
private const val STATUS_RECOGNIZING = "recognizing"
private const val STATUS_STOPPED = "stopped"
private const val STATUS_ERROR = "error"
}
// WebSocket连接
private var webSocket: WebSocket? = null
private val client: OkHttpClient
private var seq = 1
// 配置参数
private var appId: String = ""
private var accessToken: String = ""
private var streamMode: Boolean = true
private var enablePunctuation: Boolean = true
private var sampleRate: Int = 16000
private var bits: Int = 16
private var channels: Int = 1
private var supportedLanguages: List<String> = listOf("zh-CN")
// 状态控制
private var isRunning = false
private var isLastPacket = false
private var isContinuousMode = false
private var isInitialized = false
private var currentStatus = STATUS_STOPPED
// 单次识别结果收集
private var onceResultBuilder = StringBuilder()
private var singleRecognitionCallback: ((String) -> Unit)? = null
init {
// 配置OkHttp客户端
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = Level.HEADERS
}
client = OkHttpClient.Builder()
.pingInterval(50, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor)
.readTimeout(100, TimeUnit.SECONDS)
.writeTimeout(100, TimeUnit.SECONDS)
.build()
}
/**
* 初始化语音识别服务
*/
suspend fun initialize(
subscriptionKey: String,
region: String,
supportedLanguages: List<String>? = null
): Boolean = withContext(Dispatchers.IO) {
try {
updateStatus(STATUS_INITIALIZING)
// 设置必要参数
this@BytedanceASR.appId = region // 火山引擎中region对应应用ID
this@BytedanceASR.accessToken = subscriptionKey // 订阅密钥作为访问令牌
if (!supportedLanguages.isNullOrEmpty()) {
this@BytedanceASR.supportedLanguages = supportedLanguages
}
// 释放旧资源
release()
isInitialized = true
updateStatus(STATUS_READY)
true
} catch (e: Exception) {
errorCallback("INIT_ERROR", e.message ?: "初始化失败")
updateStatus(STATUS_ERROR)
false
}
}
/**
* 单次识别(非流式),完成后自动断开连接
*/
suspend fun recognizeOnce(audioData: ByteArray, callback: (String) -> Unit): Boolean = withContext(Dispatchers.IO) {
try {
if (!isInitialized) {
errorCallback("NOT_INITIALIZED", "请先调用initialize方法")
return@withContext false
}
if (isRunning) {
errorCallback("BUSY", "语音识别正在进行中")
return@withContext false
}
// 重置状态
isContinuousMode = false
isLastPacket = false
onceResultBuilder.clear()
singleRecognitionCallback = callback
streamMode = false // 使用非流式模式
// 开始连接
start()
// 等待连接建立
var attempts = 0
while (!isRunning && attempts < 10) {
delay(300)
attempts++
}
if (!isRunning) {
errorCallback("CONNECTION_ERROR", "无法建立连接")
return@withContext false
}
// 发送音频数据
feedAudioData(audioData)
// 发送结束标志
stop()
true
} catch (e: Exception) {
errorCallback("RECOGNIZE_ERROR", e.message ?: "识别失败")
updateStatus(STATUS_ERROR)
false
}
}
/**
* 启动连续识别
*/
suspend fun startContinuousRecognition(): Boolean = withContext(Dispatchers.IO) {
try {
if (!isInitialized) {
errorCallback("NOT_INITIALIZED", "请先调用initialize方法")
return@withContext false
}
if (isRunning) {
errorCallback("BUSY", "语音识别正在进行中")
return@withContext false
}
// 设置为连续模式
isContinuousMode = true
isLastPacket = false
streamMode = true // 使用流式模式
// 开始连接
start()
true
} catch (e: Exception) {
errorCallback("START_ERROR", e.message ?: "启动连续识别失败")
updateStatus(STATUS_ERROR)
false
}
}
/**
* 停止连续识别
*/
suspend fun stopContinuousRecognition(): Boolean = withContext(Dispatchers.IO) {
try {
if (!isRunning || !isContinuousMode) {
return@withContext true // 已经停止或非连续模式
}
// 发送结束标志
stop()
true
} catch (e: Exception) {
errorCallback("STOP_ERROR", e.message ?: "停止连续识别失败")
false
}
}
/**
* 推送音频数据
*/
suspend fun pushAudioData(audioData: ByteArray): Boolean = withContext(Dispatchers.IO) {
try {
if (!isRunning) {
errorCallback("NOT_RUNNING", "语音识别未启动")
return@withContext false
}
feedAudioData(audioData)
true
} catch (e: Exception) {
errorCallback("AUDIO_PUSH_ERROR", e.message ?: "推送音频数据失败")
false
}
}
/**
* 用于暂停执行的辅助函数
*/
private suspend fun delay(ms: Long) = withContext(Dispatchers.IO) {
try {
Thread.sleep(ms)
} catch (e: InterruptedException) {
// 忽略中断异常
}
}
/**
* 更新状态并通知Dart层
*/
private fun updateStatus(status: String) {
currentStatus = status
statusCallback(status)
}
/**
* 开始语音识别
*/
private suspend fun start() = withContext(Dispatchers.IO) {
if (isRunning) return@withContext
isRunning = true
isLastPacket = false
seq = 1
val url = if (streamMode) {
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
} else {
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_nostream"
}
val request = Request.Builder()
.url(url)
.header("X-Api-App-Key", appId)
.header("X-Api-Access-Key", accessToken)
.header("X-Api-Resource-Id", "volc.bigasr.sauc.duration")
.header("X-Api-Connect-Id", UUID.randomUUID().toString())
.build()
updateStatus(STATUS_RECOGNIZING)
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val logId = response.header("X-Tt-Logid")
println("ASR WebSocket连接成功, X-Tt-Logid: $logId")
// 发送初始配置请求
sendInitialRequest(webSocket)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
try {
val data = bytes.toByteArray()
val response = parseResponse(data)
// 根据消息类型处理响应
when (response.messageType) {
FULL_SERVER_RESPONSE -> {
// 解析识别结果
val isFinal = response.messageTypeSpecificFlags == NEG_WITH_SEQUENCE
val resultText = if (response.payload.isNotEmpty()) {
val decompressedData = if (response.compression == GZIP) {
gzipDecompress(response.payload)
} else {
response.payload
}
val jsonStr = String(decompressedData)
extractTextFromJson(jsonStr)
} else {
""
}
if (resultText.isNotEmpty()) {
// 处理单次识别模式
if (!isContinuousMode) {
onceResultBuilder.append(resultText)
}
// 回调识别结果
resultCallback(resultText, isFinal)
}
// 如果是最后一个包,关闭连接
if (isFinal) {
// 单次识别完成回调
if (!isContinuousMode && singleRecognitionCallback != null) {
val finalResult = onceResultBuilder.toString()
singleRecognitionCallback?.invoke(finalResult)
onceResultBuilder.clear()
singleRecognitionCallback = null
}
if (!isContinuousMode) {
isRunning = false
updateStatus(STATUS_STOPPED)
webSocket.close(1000, "Finished")
}
}
}
SERVER_ERROR_RESPONSE -> {
val errorMessage = String(response.payload)
errorCallback(response.sequence.toString(), errorMessage)
isRunning = false
updateStatus(STATUS_ERROR)
webSocket.close(1000, "Error")
}
}
} catch (e: Exception) {
errorCallback("PARSE_ERROR", e.message ?: "解析响应失败")
updateStatus(STATUS_ERROR)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isRunning = false
errorCallback("CONNECTION_ERROR", t.message ?: "连接失败")
updateStatus(STATUS_ERROR)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isRunning = false
updateStatus(STATUS_STOPPED)
println("ASR WebSocket连接关闭: $code, $reason")
}
})
}
/**
* 发送音频数据
*/
private suspend fun feedAudioData(audioData: ByteArray) = withContext(Dispatchers.IO) {
if (!isRunning || isLastPacket) return@withContext
webSocket?.let { ws ->
// 发送音频请求
sendAudioRequest(ws, audioData, false)
}
}
/**
* 停止识别
*/
private suspend fun stop() = withContext(Dispatchers.IO) {
if (!isRunning || isLastPacket) return@withContext
isLastPacket = true
webSocket?.let { ws ->
// 发送最后一个音频包
sendAudioRequest(ws, ByteArray(0), true)
}
}
/**
* 释放资源
*/
fun release() {
webSocket?.cancel()
webSocket = null
isRunning = false
isLastPacket = false
updateStatus(STATUS_STOPPED)
}
/**
* 发送初始配置请求
*/
private fun sendInitialRequest(webSocket: WebSocket) {
// 构建请求参数
val user = JsonObject().apply {
addProperty("uid", "test")
}
val audio = JsonObject().apply {
addProperty("format", "pcm")
addProperty("sample_rate", sampleRate)
addProperty("bits", bits)
addProperty("channel", channels)
addProperty("codec", "raw")
}
val request = JsonObject().apply {
addProperty("model_name", "bigmodel")
addProperty("enable_punc", enablePunctuation)
}
val payload = JsonObject().apply {
add("user", user)
add("audio", audio)
add("request", request)
}
val payloadStr = payload.toString()
println("ASR初始请求: $payloadStr")
// 压缩负载
val compressedPayload = gzipCompress(payloadStr.toByteArray())
// 构建消息头
val header = getHeader(
FULL_CLIENT_REQUEST,
POS_SEQUENCE,
JSON,
GZIP,
0
)
// 构建消息序列号
val seqBytes = intToBytes(seq)
// 构建负载大小
val payloadSizeBytes = intToBytes(compressedPayload.size)
// 组装完整请求数据
val requestBytes = ByteArray(header.size + seqBytes.size + payloadSizeBytes.size + compressedPayload.size)
var offset = 0
System.arraycopy(header, 0, requestBytes, offset, header.size)
offset += header.size
System.arraycopy(seqBytes, 0, requestBytes, offset, seqBytes.size)
offset += seqBytes.size
System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size)
offset += payloadSizeBytes.size
System.arraycopy(compressedPayload, 0, requestBytes, offset, compressedPayload.size)
// 发送请求
webSocket.send(ByteString.of(requestBytes))
}
/**
* 发送音频数据请求
*/
private fun sendAudioRequest(webSocket: WebSocket, audioData: ByteArray, isLast: Boolean) {
seq++
// 如果是最后一个包,使用负序列号
val actualSeq = if (isLast) -seq else seq
// 设置消息标志
val flag = if (isLast) NEG_WITH_SEQUENCE else POS_SEQUENCE
// 构建消息头
val header = getHeader(
AUDIO_ONLY_REQUEST,
flag,
NO_SERIALIZATION,
GZIP,
0
)
// 构建消息序列号
val seqBytes = intToBytes(actualSeq)
// 压缩音频数据
val compressedPayload = if (audioData.isNotEmpty()) {
gzipCompress(audioData)
} else {
ByteArray(0)
}
// 构建负载大小
val payloadSizeBytes = intToBytes(compressedPayload.size)
// 组装完整请求数据
val requestBytes = ByteArray(header.size + seqBytes.size + payloadSizeBytes.size + compressedPayload.size)
var offset = 0
System.arraycopy(header, 0, requestBytes, offset, header.size)
offset += header.size
System.arraycopy(seqBytes, 0, requestBytes, offset, seqBytes.size)
offset += seqBytes.size
System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size)
offset += payloadSizeBytes.size
if (compressedPayload.isNotEmpty()) {
System.arraycopy(compressedPayload, 0, requestBytes, offset, compressedPayload.size)
}
// 发送请求
webSocket.send(ByteString.of(requestBytes))
}
/**
* 解析响应数据
*/
private fun parseResponse(data: ByteArray): AsrResponse {
val response = AsrResponse()
// 解析头部 (4字节)
response.protocolVersion = (data[0].toInt() shr 4) and 0x0F
response.headerSize = data[0].toInt() and 0x0F
response.messageType = (data[1].toInt() shr 4) and 0x0F
response.messageTypeSpecificFlags = data[1].toInt() and 0x0F
response.serialization = (data[2].toInt() shr 4) and 0x0F
response.compression = data[2].toInt() and 0x0F
response.reserved = data[3].toInt()
// 解析序列号 (4字节)
val seqBytes = ByteArray(4)
System.arraycopy(data, 4, seqBytes, 0, 4)
response.sequence = bytesToInt(seqBytes)
// 解析负载大小 (4字节)
val sizeBytes = ByteArray(4)
System.arraycopy(data, 8, sizeBytes, 0, 4)
val payloadSize = bytesToInt(sizeBytes)
// 解析负载数据
if (payloadSize > 0 && data.size > 12) {
val payload = ByteArray(payloadSize)
System.arraycopy(data, 12, payload, 0, payloadSize)
response.payload = payload
} else {
response.payload = ByteArray(0)
}
return response
}
/**
* 从JSON响应中提取文本
*/
private fun extractTextFromJson(jsonStr: String): String {
try {
val gson = Gson()
val jsonObject = gson.fromJson(jsonStr, JsonObject::class.java)
// 提取识别结果文本
if (jsonObject.has("result") && !jsonObject.get("result").isJsonNull) {
val result = jsonObject.getAsJsonObject("result")
if (result.has("text") && !result.get("text").isJsonNull) {
return result.get("text").asString
}
// 尝试从segments中提取文本
if (result.has("segments") && result.get("segments").isJsonArray) {
val segments = result.getAsJsonArray("segments")
val builder = StringBuilder()
for (i in 0 until segments.size()) {
val segment = segments.get(i).asJsonObject
if (segment.has("text") && !segment.get("text").isJsonNull) {
builder.append(segment.get("text").asString)
}
}
return builder.toString()
}
}
return ""
} catch (e: Exception) {
println("解析JSON失败: ${e.message}")
return ""
}
}
/**
* 获取消息头
*/
private fun getHeader(
messageType: Int,
messageTypeSpecificFlags: Int,
serialMethod: Int,
compressionType: Int,
reservedData: Int
): ByteArray {
val header = ByteArray(4)
header[0] = ((PROTOCOL_VERSION shl 4) or DEFAULT_HEADER_SIZE).toByte()
header[1] = ((messageType shl 4) or messageTypeSpecificFlags).toByte()
header[2] = ((serialMethod shl 4) or compressionType).toByte()
header[3] = reservedData.toByte()
return header
}
/**
* 整数转字节数组 (大端序)
*/
private fun intToBytes(value: Int): ByteArray {
return byteArrayOf(
((value shr 24) and 0xFF).toByte(),
((value shr 16) and 0xFF).toByte(),
((value shr 8) and 0xFF).toByte(),
(value and 0xFF).toByte()
)
}
/**
* 字节数组转整数 (大端序)
*/
private fun bytesToInt(bytes: ByteArray): Int {
if (bytes.size != 4) {
throw IllegalArgumentException("字节数组长度必须为4")
}
return ((bytes[0].toInt() and 0xFF) shl 24) or
((bytes[1].toInt() and 0xFF) shl 16) or
((bytes[2].toInt() and 0xFF) shl 8) or
(bytes[3].toInt() and 0xFF)
}
/**
* GZIP压缩
*/
private fun gzipCompress(data: ByteArray): ByteArray {
if (data.isEmpty()) return ByteArray(0)
val output = ByteArrayOutputStream()
GZIPOutputStream(output).use { gzip ->
gzip.write(data)
}
return output.toByteArray()
}
/**
* GZIP解压缩
*/
private fun gzipDecompress(data: ByteArray): ByteArray {
if (data.isEmpty()) return ByteArray(0)
val output = ByteArrayOutputStream()
GZIPInputStream(data.inputStream()).use { gzip ->
val buffer = ByteArray(1024)
var len: Int
while (gzip.read(buffer).also { len = it } > 0) {
output.write(buffer, 0, len)
}
}
return output.toByteArray()
}
/**
* ASR响应数据结构
*/
private data class AsrResponse(
var protocolVersion: Int = 0,
var headerSize: Int = 0,
var messageType: Int = 0,
var messageTypeSpecificFlags: Int = 0,
var serialization: Int = 0,
var compression: Int = 0,
var reserved: Int = 0,
var sequence: Int = 0,
var payload: ByteArray = ByteArray(0)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AsrResponse
if (protocolVersion != other.protocolVersion) return false
if (headerSize != other.headerSize) return false
if (messageType != other.messageType) return false
if (messageTypeSpecificFlags != other.messageTypeSpecificFlags) return false
if (serialization != other.serialization) return false
if (compression != other.compression) return false
if (reserved != other.reserved) return false
if (sequence != other.sequence) return false
if (!payload.contentEquals(other.payload)) return false
return true
}
override fun hashCode(): Int {
var result = protocolVersion
result = 31 * result + headerSize
result = 31 * result + messageType
result = 31 * result + messageTypeSpecificFlags
result = 31 * result + serialization
result = 31 * result + compression
result = 31 * result + reserved
result = 31 * result + sequence
result = 31 * result + payload.contentHashCode()
return result
}
}
}

331
local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt

@ -0,0 +1,331 @@
package com.deep_voice.bytedance_speech
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.EventChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/** 火山引擎语音识别和语音合成插件 */
class BytedanceSpeechPlugin : FlutterPlugin {
private val TAG = "BytedanceSpeechPlugin"
private lateinit var context: Context
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mainHandler = Handler(Looper.getMainLooper())
// ASR相关
private lateinit var asrChannel: MethodChannel
private lateinit var asrEventChannel: EventChannel
private var asrEventSink: EventChannel.EventSink? = null
private var asrInstance: BytedanceASR? = null
// TTS相关
private lateinit var ttsChannel: MethodChannel
private lateinit var ttsEventChannel: EventChannel
private var ttsEventSink: EventChannel.EventSink? = null
private var ttsInstance: BytedanceTTS? = null
// ASR事件发送方法
private fun sendAsrEvent(event: Map<String, Any>) {
println("发送ASR事件: $event")
if (asrEventSink == null) {
println("无法发送ASR事件:事件通道未准备好")
return
}
mainHandler.post {
try {
asrEventSink?.success(event)
println("ASR事件发送成功")
} catch (e: Exception) {
println("发送ASR事件失败: ${e.message}")
}
}
}
// TTS事件发送方法
private fun sendTtsEvent(event: Map<String, Any>) {
println("发送TTS事件: $event")
if (ttsEventSink == null) {
println("无法发送TTS事件:事件通道未准备好")
return
}
mainHandler.post {
try {
ttsEventSink?.success(event)
println("TTS事件发送成功")
} catch (e: Exception) {
println("发送TTS事件失败: ${e.message}")
}
}
}
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
// 初始化ASR通道
asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/asr")
asrChannel.setMethodCallHandler(AsrMethodHandler())
// 初始化TTS通道
ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/tts")
ttsChannel.setMethodCallHandler(TtsMethodHandler())
// 初始化ASR事件通道
asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/asr_events")
asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
asrEventSink = events
}
override fun onCancel(arguments: Any?) {
asrEventSink = null
}
})
// 初始化TTS事件通道
ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/tts_events")
ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
ttsEventSink = events
}
override fun onCancel(arguments: Any?) {
ttsEventSink = null
}
})
}
// ASR方法处理器
inner class AsrMethodHandler : MethodCallHandler {
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"initialize" -> {
val appId = call.argument<String>("appId") ?: ""
val accessToken = call.argument<String>("accessToken") ?: ""
val streamMode = call.argument<Boolean>("streamMode") ?: true
val enablePunctuation = call.argument<Boolean>("enablePunctuation") ?: true
val sampleRate = call.argument<Int>("sampleRate") ?: 16000
val bits = call.argument<Int>("bits") ?: 16
val channels = call.argument<Int>("channels") ?: 1
try {
asrInstance = BytedanceASR(
context = context,
appId = appId,
accessToken = accessToken,
streamMode = streamMode,
enablePunctuation = enablePunctuation,
sampleRate = sampleRate,
bits = bits,
channels = channels,
resultCallback = { text, isFinal ->
val eventType = if (isFinal) "result" else "recognizing"
sendAsrEvent(mapOf(
"type" to eventType,
"text" to text,
"isFinal" to isFinal
))
},
errorCallback = { errorCode, errorMessage ->
sendAsrEvent(mapOf(
"type" to "error",
"errorCode" to errorCode,
"errorMessage" to errorMessage
))
}
)
result.success(true)
} catch (e: Exception) {
result.error("INITIALIZATION_ERROR", e.message, null)
}
}
"startRecognition" -> {
if (asrEventSink == null) {
result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始识别", null)
return
}
scope.launch {
try {
asrInstance?.start()
sendAsrEvent(mapOf("type" to "sessionStarted"))
scope.launch(Dispatchers.Main) {
result.success(true)
}
} catch (e: Exception) {
scope.launch(Dispatchers.Main) {
result.error("START_ERROR", e.message, null)
}
}
}
}
"feedAudioData" -> {
val audioData = call.argument<ByteArray>("audioData")
if (audioData != null) {
scope.launch {
try {
asrInstance?.feedAudioData(audioData)
scope.launch(Dispatchers.Main) {
result.success(true)
}
} catch (e: Exception) {
scope.launch(Dispatchers.Main) {
result.error("FEED_ERROR", e.message, null)
}
}
}
} else {
result.error("INVALID_ARGUMENT", "音频数据不能为空", null)
}
}
"stopRecognition" -> {
scope.launch {
try {
asrInstance?.stop()
sendAsrEvent(mapOf("type" to "sessionStopped"))
scope.launch(Dispatchers.Main) {
result.success(true)
}
} catch (e: Exception) {
scope.launch(Dispatchers.Main) {
result.error("STOP_ERROR", e.message, null)
}
}
}
}
"dispose" -> {
try {
asrInstance?.release()
asrInstance = null
result.success(true)
} catch (e: Exception) {
result.error("DISPOSE_ERROR", e.message, null)
}
}
else -> {
result.notImplemented()
}
}
}
}
// TTS方法处理器
inner class TtsMethodHandler : MethodCallHandler {
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"initialize" -> {
val appId = call.argument<String>("appId") ?: ""
val accessToken = call.argument<String>("accessToken") ?: ""
val speaker = call.argument<String>("speaker") ?: ""
val format = call.argument<String>("format") ?: "mp3"
val sampleRate = call.argument<Int>("sampleRate") ?: 24000
val enableTimestamp = call.argument<Boolean>("enableTimestamp") ?: true
try {
ttsInstance = BytedanceTTS(
context = context,
appId = appId,
accessToken = accessToken,
speaker = speaker,
format = format,
sampleRate = sampleRate,
enableTimestamp = enableTimestamp,
audioCallback = { audioData ->
sendTtsEvent(mapOf(
"type" to "audio",
"audioData" to audioData
))
},
completionCallback = {
sendTtsEvent(mapOf("type" to "completed"))
},
errorCallback = { errorCode, errorMessage ->
sendTtsEvent(mapOf(
"type" to "error",
"errorCode" to errorCode,
"errorMessage" to errorMessage
))
}
)
result.success(true)
} catch (e: Exception) {
result.error("INITIALIZATION_ERROR", e.message, null)
}
}
"synthesize" -> {
val text = call.argument<String>("text") ?: ""
if (ttsEventSink == null) {
result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始合成", null)
return
}
scope.launch {
try {
sendTtsEvent(mapOf("type" to "started"))
ttsInstance?.synthesize(text)
scope.launch(Dispatchers.Main) {
result.success(true)
}
} catch (e: Exception) {
scope.launch(Dispatchers.Main) {
result.error("SYNTHESIS_ERROR", e.message, null)
}
}
}
}
"stopSynthesis" -> {
scope.launch {
try {
ttsInstance?.stop()
scope.launch(Dispatchers.Main) {
result.success(true)
}
} catch (e: Exception) {
scope.launch(Dispatchers.Main) {
result.error("STOP_ERROR", e.message, null)
}
}
}
}
"dispose" -> {
try {
ttsInstance?.release()
ttsInstance = null
result.success(true)
} catch (e: Exception) {
result.error("DISPOSE_ERROR", e.message, null)
}
}
else -> {
result.notImplemented()
}
}
}
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
asrChannel.setMethodCallHandler(null)
ttsChannel.setMethodCallHandler(null)
asrEventChannel.setStreamHandler(null)
ttsEventChannel.setStreamHandler(null)
scope.cancel()
asrInstance?.release()
ttsInstance?.release()
asrInstance = null
ttsInstance = null
}
}

938
local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt

@ -0,0 +1,938 @@
package com.deep_voice.bytedance_speech
import android.content.Context
import com.google.gson.Gson
import com.google.gson.JsonObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.logging.HttpLoggingInterceptor.Level
import okio.ByteString
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
/**
* 火山引擎大模型语音合成实现
*
* 基于WebSocket协议实现双向流式TTS
*/
class BytedanceTTS(
private val context: Context,
private val audioCallback: (ByteArray) -> Unit,
private val completionCallback: () -> Unit,
private val errorCallback: (String, String) -> Unit,
private val statusCallback: (String) -> Unit
) {
companion object {
// 协议常量
private const val PROTOCOL_VERSION = 0x01
private const val DEFAULT_HEADER_SIZE = 0x01
// 消息类型
private const val FULL_CLIENT_REQUEST = 0x01
private const val AUDIO_ONLY_RESPONSE = 0x0B
private const val FULL_SERVER_RESPONSE = 0x09
private const val ERROR_INFORMATION = 0x0F
// 消息类型特定标志
private const val MSG_TYPE_FLAG_NO_SEQ = 0x00
private const val MSG_TYPE_FLAG_POSITIVE_SEQ = 0x01
private const val MSG_TYPE_FLAG_LAST_NO_SEQ = 0x02
private const val MSG_TYPE_FLAG_NEGATIVE_SEQ = 0x03
private const val MSG_TYPE_FLAG_WITH_EVENT = 0x04
// 序列化方法
private const val NO_SERIALIZATION = 0x00
private const val JSON = 0x01
// 压缩方法
private const val COMPRESSION_NO = 0x00
private const val COMPRESSION_GZIP = 0x01
// 事件类型
private const val EVENT_NONE = 0
private const val EVENT_START_CONNECTION = 1
private const val EVENT_FINISH_CONNECTION = 2
private const val EVENT_CONNECTION_STARTED = 50
private const val EVENT_CONNECTION_FAILED = 51
private const val EVENT_CONNECTION_FINISHED = 52
private const val EVENT_START_SESSION = 100
private const val EVENT_FINISH_SESSION = 102
private const val EVENT_SESSION_STARTED = 150
private const val EVENT_SESSION_FINISHED = 152
private const val EVENT_SESSION_FAILED = 153
private const val EVENT_TASK_REQUEST = 200
private const val EVENT_TTS_SENTENCE_START = 350
private const val EVENT_TTS_SENTENCE_END = 351
private const val EVENT_TTS_RESPONSE = 352
// 状态常量
private const val STATUS_INITIALIZING = "initializing"
private const val STATUS_READY = "ready"
private const val STATUS_SPEAKING = "speaking"
private const val STATUS_STOPPED = "stopped"
private const val STATUS_ERROR = "error"
}
// WebSocket连接
private var webSocket: WebSocket? = null
private val client: OkHttpClient
// 会话信息
private var sessionId = ""
private var isConnected = false
private var isSessionStarted = false
// TTS配置参数
private var appId: String = ""
private var accessToken: String = ""
private var speaker: String = "zh_female_qingxin" // 默认语音
private var format: String = "pcm"
private var sampleRate: Int = 16000
private var enableTimestamp: Boolean = false
private var language: String = "zh-CN"
// 流式处理状态
private var isStreamMode = false
private var currentStatus = STATUS_STOPPED
init {
// 配置OkHttp客户端
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = Level.HEADERS
}
client = OkHttpClient.Builder()
.pingInterval(50, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor)
.readTimeout(100, TimeUnit.SECONDS)
.writeTimeout(100, TimeUnit.SECONDS)
.build()
// 生成会话ID
sessionId = UUID.randomUUID().toString().replace("-", "")
}
/**
* 初始化火山语音服务
*/
suspend fun initialize(subscriptionKey: String, region: String, language: String = "zh-CN"): Boolean = withContext(Dispatchers.IO) {
try {
updateStatus(STATUS_INITIALIZING)
// 设置必要参数
this@BytedanceTTS.appId = region // 火山引擎中region对应应用ID
this@BytedanceTTS.accessToken = subscriptionKey // 订阅密钥作为访问令牌
this@BytedanceTTS.language = language
// 根据语言设置默认speaker
if (language.startsWith("zh")) {
this@BytedanceTTS.speaker = "zh_female_qingxin"
} else if (language.startsWith("en")) {
this@BytedanceTTS.speaker = "en_female_crystal"
}
// 释放旧连接
release()
// 建立新连接
connect()
true
} catch (e: Exception) {
errorCallback("INIT_ERROR", e.message ?: "初始化失败")
updateStatus(STATUS_ERROR)
false
}
}
/**
* 设置语音角色
*/
fun setVoice(voiceName: String): Boolean {
try {
speaker = voiceName
return true
} catch (e: Exception) {
errorCallback("VOICE_ERROR", e.message ?: "设置语音角色失败")
return false
}
}
/**
* 单次播放(非流式)
*/
suspend fun speakOnce(text: String): Boolean = withContext(Dispatchers.IO) {
try {
if (text.isEmpty()) {
errorCallback("PARAM_ERROR", "合成文本不能为空")
return@withContext false
}
isStreamMode = false
// 如果没有连接或会话,重新初始化
if (webSocket == null || !isConnected) {
connect()
// 等待连接和会话建立
var attempts = 0
while (!isSessionStarted && attempts < 10) {
delay(300)
attempts++
}
if (!isSessionStarted) {
errorCallback("SESSION_ERROR", "无法建立会话")
return@withContext false
}
}
// 发送文本进行合成
sendTextRequest(text)
return@withContext true
} catch (e: Exception) {
errorCallback("SYNTH_ERROR", e.message ?: "语音合成失败")
updateStatus(STATUS_ERROR)
false
}
}
/**
* 流式播放开始
*/
suspend fun speakStream(text: String): Boolean = withContext(Dispatchers.IO) {
try {
if (text.isEmpty()) {
errorCallback("PARAM_ERROR", "合成文本不能为空")
return@withContext false
}
isStreamMode = true
// 确保会话已就绪
ensureSessionReady()
// 发送文本进行合成,但不自动结束会话
sendStreamTextRequest(text)
updateStatus(STATUS_SPEAKING)
return@withContext true
} catch (e: Exception) {
errorCallback("STREAM_ERROR", e.message ?: "流式合成失败")
updateStatus(STATUS_ERROR)
false
}
}
/**
* 结束流式播放
*/
suspend fun flushStream(): Boolean = withContext(Dispatchers.IO) {
try {
if (!isStreamMode) {
return@withContext true
}
if (isSessionStarted) {
finishSession()
}
isStreamMode = false
updateStatus(STATUS_STOPPED)
return@withContext true
} catch (e: Exception) {
errorCallback("FLUSH_ERROR", e.message ?: "结束流式合成失败")
false
}
}
/**
* 停止语音合成
*/
suspend fun stop() = withContext(Dispatchers.IO) {
if (isSessionStarted) {
finishSession()
}
updateStatus(STATUS_STOPPED)
}
/**
* 释放资源
*/
fun release() {
webSocket?.cancel()
webSocket = null
isConnected = false
isSessionStarted = false
updateStatus(STATUS_STOPPED)
}
/**
* 确保会话已就绪
*/
private suspend fun ensureSessionReady() {
// 如果没有连接或会话,重新初始化
if (webSocket == null || !isConnected) {
connect()
// 等待连接和会话建立
var attempts = 0
while (!isSessionStarted && attempts < 10) {
delay(300)
attempts++
}
if (!isSessionStarted) {
throw Exception("无法建立会话")
}
}
}
/**
* 用于暂停执行的辅助函数
*/
private suspend fun delay(ms: Long) = withContext(Dispatchers.IO) {
try {
Thread.sleep(ms)
} catch (e: InterruptedException) {
// 忽略中断异常
}
}
/**
* 更新状态并通知Dart层
*/
private fun updateStatus(status: String) {
currentStatus = status
statusCallback(status)
}
/**
* 建立WebSocket连接
*/
private fun connect() {
val url = "wss://openspeech.bytedance.com/api/v3/tts/bidirection"
val request = Request.Builder()
.url(url)
.header("X-Api-App-Key", appId)
.header("X-Api-Access-Key", accessToken)
.header("X-Api-Resource-Id", "volc.service_type.10029")
.header("X-Api-Connect-Id", UUID.randomUUID().toString())
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val logId = response.header("X-Tt-Logid")
println("TTS WebSocket连接成功, X-Tt-Logid: $logId")
// 发送开始连接事件
startConnection(webSocket)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
try {
val data = bytes.toByteArray()
val response = parseResponse(data)
// 处理事件
when (response.event) {
EVENT_CONNECTION_FAILED, EVENT_SESSION_FAILED -> {
val errorMsg = if (response.responseMetaJson.isNotEmpty()) {
response.responseMetaJson
} else {
"连接或会话失败"
}
errorCallback("TTS_ERROR", errorMsg)
webSocket.close(1000, "Error")
updateStatus(STATUS_ERROR)
}
EVENT_CONNECTION_STARTED -> {
isConnected = true
// 开始TTS会话
startTtsSession(webSocket)
}
EVENT_SESSION_STARTED -> {
isSessionStarted = true
updateStatus(STATUS_READY)
}
EVENT_TTS_RESPONSE -> {
// 音频数据
if (response.messageType == AUDIO_ONLY_RESPONSE && response.payload.isNotEmpty()) {
audioCallback(response.payload)
}
}
EVENT_CONNECTION_FINISHED -> {
isConnected = false
isSessionStarted = false
updateStatus(STATUS_STOPPED)
completionCallback()
webSocket.close(1000, "Connection finished")
}
EVENT_SESSION_FINISHED -> {
isSessionStarted = false
if (!isStreamMode) {
finishConnection(webSocket)
} else {
// 流模式下保持连接
updateStatus(STATUS_READY)
}
}
}
} catch (e: Exception) {
errorCallback("PARSE_ERROR", e.message ?: "解析响应失败")
updateStatus(STATUS_ERROR)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isConnected = false
isSessionStarted = false
errorCallback("CONNECTION_ERROR", t.message ?: "连接失败")
updateStatus(STATUS_ERROR)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
isSessionStarted = false
updateStatus(STATUS_STOPPED)
println("TTS WebSocket连接关闭: $code, $reason")
}
})
}
/**
* 开始连接
*/
private fun startConnection(webSocket: WebSocket) {
val header = Header(
protocolVersion = PROTOCOL_VERSION,
headerSize = DEFAULT_HEADER_SIZE,
messageType = FULL_CLIENT_REQUEST,
messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT,
serializationMethod = JSON,
messageCompression = COMPRESSION_NO,
reserved = 0
)
val optional = Optional(
event = EVENT_START_CONNECTION
)
val payload = "{}".toByteArray()
sendEvent(webSocket, header, optional, payload)
}
/**
* 结束连接
*/
private fun finishConnection(webSocket: WebSocket) {
val header = Header(
protocolVersion = PROTOCOL_VERSION,
headerSize = DEFAULT_HEADER_SIZE,
messageType = FULL_CLIENT_REQUEST,
messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT,
serializationMethod = JSON,
messageCompression = COMPRESSION_NO,
reserved = 0
)
val optional = Optional(
event = EVENT_FINISH_CONNECTION
)
val payload = "{}".toByteArray()
sendEvent(webSocket, header, optional, payload)
}
/**
* 开始TTS会话
*/
private fun startTtsSession(webSocket: WebSocket) {
val header = Header(
protocolVersion = PROTOCOL_VERSION,
headerSize = DEFAULT_HEADER_SIZE,
messageType = FULL_CLIENT_REQUEST,
messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT,
serializationMethod = JSON,
messageCompression = COMPRESSION_NO,
reserved = 0
)
val optional = Optional(
event = EVENT_START_SESSION,
sessionId = sessionId
)
val user = JsonObject().apply {
addProperty("uid", "123456")
}
val reqParams = JsonObject().apply {
addProperty("speaker", speaker)
val audioParams = JsonObject().apply {
addProperty("format", format)
addProperty("sample_rate", sampleRate)
addProperty("enable_timestamp", enableTimestamp)
}
add("audio_params", audioParams)
}
val payloadObj = JsonObject().apply {
add("user", user)
addProperty("event", EVENT_START_SESSION)
addProperty("namespace", "BidirectionalTTS")
add("req_params", reqParams)
}
val payload = payloadObj.toString().toByteArray()
sendEvent(webSocket, header, optional, payload)
}
/**
* 发送文本进行合成 (非流式)
*/
private fun sendTextRequest(text: String) {
webSocket?.let { ws ->
val header = Header(
protocolVersion = PROTOCOL_VERSION,
headerSize = DEFAULT_HEADER_SIZE,
messageType = FULL_CLIENT_REQUEST,
messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT,
serializationMethod = JSON,
messageCompression = COMPRESSION_NO,
reserved = 0
)
val optional = Optional(
event = EVENT_TASK_REQUEST,
sessionId = sessionId
)
val user = JsonObject().apply {
addProperty("uid", "123456")
}
val reqParams = JsonObject().apply {
addProperty("text", text)
addProperty("speaker", speaker)
val audioParams = JsonObject().apply {
addProperty("format", format)
addProperty("sample_rate", sampleRate)
}
add("audio_params", audioParams)
}
val payloadObj = JsonObject().apply {
add("user", user)
addProperty("event", EVENT_TASK_REQUEST)
addProperty("namespace", "BidirectionalTTS")
add("req_params", reqParams)
}
val payload = payloadObj.toString().toByteArray()
sendEvent(ws, header, optional, payload)
// 发送完文本后结束会话
updateStatus(STATUS_SPEAKING)
finishSession()
}
}
/**
* 发送文本进行合成 (流式)
*/
private fun sendStreamTextRequest(text: String) {
webSocket?.let { ws ->
val header = Header(
protocolVersion = PROTOCOL_VERSION,
headerSize = DEFAULT_HEADER_SIZE,
messageType = FULL_CLIENT_REQUEST,
messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT,
serializationMethod = JSON,
messageCompression = COMPRESSION_NO,
reserved = 0
)
val optional = Optional(
event = EVENT_TASK_REQUEST,
sessionId = sessionId
)
val user = JsonObject().apply {
addProperty("uid", "123456")
}
val reqParams = JsonObject().apply {
addProperty("text", text)
addProperty("speaker", speaker)
val audioParams = JsonObject().apply {
addProperty("format", format)
addProperty("sample_rate", sampleRate)
}
add("audio_params", audioParams)
}
val payloadObj = JsonObject().apply {
add("user", user)
addProperty("event", EVENT_TASK_REQUEST)
addProperty("namespace", "BidirectionalTTS")
add("req_params", reqParams)
}
val payload = payloadObj.toString().toByteArray()
sendEvent(ws, header, optional, payload)
// 流式模式下不自动结束会话
}
}
/**
* 结束会话
*/
private fun finishSession() {
webSocket?.let { ws ->
val header = Header(
protocolVersion = PROTOCOL_VERSION,
headerSize = DEFAULT_HEADER_SIZE,
messageType = FULL_CLIENT_REQUEST,
messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT,
serializationMethod = JSON,
messageCompression = COMPRESSION_NO,
reserved = 0
)
val optional = Optional(
event = EVENT_FINISH_SESSION,
sessionId = sessionId
)
val payload = "{}".toByteArray()
sendEvent(ws, header, optional, payload)
}
}
/**
* 发送事件
*/
private fun sendEvent(webSocket: WebSocket, header: Header, optional: Optional, payload: ByteArray) {
val headerBytes = header.getBytes()
val optionalBytes = optional.getBytes()
val payloadSizeBytes = intToBytes(payload.size)
val requestBytes = ByteArray(
headerBytes.size + optionalBytes.size + payloadSizeBytes.size + payload.size
)
var offset = 0
System.arraycopy(headerBytes, 0, requestBytes, offset, headerBytes.size)
offset += headerBytes.size
System.arraycopy(optionalBytes, 0, requestBytes, offset, optionalBytes.size)
offset += optionalBytes.size
System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size)
offset += payloadSizeBytes.size
System.arraycopy(payload, 0, requestBytes, offset, payload.size)
webSocket.send(ByteString.of(requestBytes))
}
/**
* 解析响应
*/
private fun parseResponse(data: ByteArray): TtsResponse {
val response = TtsResponse()
// 解析头部
val header = Header()
header.protocolVersion = (data[0].toInt() shr 4) and 0x0F
header.headerSize = data[0].toInt() and 0x0F
header.messageType = (data[1].toInt() shr 4) and 0x0F
header.messageTypeSpecificFlags = data[1].toInt() and 0x0F
header.serializationMethod = (data[2].toInt() shr 4) and 0x0F
header.messageCompression = data[2].toInt() and 0x0F
header.reserved = data[3].toInt()
response.header = header
response.messageType = header.messageType
var offset = 4
// 读取事件
if (header.messageTypeSpecificFlags == MSG_TYPE_FLAG_WITH_EVENT) {
val eventBytes = ByteArray(4)
System.arraycopy(data, offset, eventBytes, 0, 4)
response.event = bytesToInt(eventBytes)
offset += 4
}
// 根据事件类型处理不同数据
when (response.event) {
EVENT_CONNECTION_STARTED -> {
// 读取连接ID
offset = readConnectId(data, response, offset)
}
EVENT_CONNECTION_FAILED -> {
offset = readConnectId(data, response, offset)
readMetaJson(data, response, offset)
}
EVENT_SESSION_STARTED,
EVENT_TTS_RESPONSE,
EVENT_TTS_SENTENCE_START,
EVENT_TTS_SENTENCE_END -> {
offset = readSessionId(data, response, offset)
if (response.messageType == AUDIO_ONLY_RESPONSE) {
offset = readPayload(data, response, offset)
}
}
EVENT_SESSION_FAILED,
EVENT_SESSION_FINISHED -> {
offset = readSessionId(data, response, offset)
readMetaJson(data, response, offset)
}
EVENT_CONNECTION_FINISHED -> {
// 会话结束
}
}
return response
}
/**
* 读取连接ID
*/
private fun readConnectId(data: ByteArray, response: TtsResponse, offset: Int): Int {
val sizeBytes = ByteArray(4)
System.arraycopy(data, offset, sizeBytes, 0, 4)
val size = bytesToInt(sizeBytes)
if (size > 0 && offset + 4 + size <= data.size) {
val textBytes = ByteArray(size)
System.arraycopy(data, offset + 4, textBytes, 0, size)
response.connectionId = String(textBytes)
}
return offset + 4 + size
}
/**
* 读取元数据JSON
*/
private fun readMetaJson(data: ByteArray, response: TtsResponse, offset: Int): Int {
val sizeBytes = ByteArray(4)
System.arraycopy(data, offset, sizeBytes, 0, 4)
val size = bytesToInt(sizeBytes)
if (size > 0 && offset + 4 + size <= data.size) {
val textBytes = ByteArray(size)
System.arraycopy(data, offset + 4, textBytes, 0, size)
response.responseMetaJson = String(textBytes)
}
return offset + 4 + size
}
/**
* 读取会话ID
*/
private fun readSessionId(data: ByteArray, response: TtsResponse, offset: Int): Int {
val sizeBytes = ByteArray(4)
System.arraycopy(data, offset, sizeBytes, 0, 4)
val size = bytesToInt(sizeBytes)
if (size > 0 && offset + 4 + size <= data.size) {
val textBytes = ByteArray(size)
System.arraycopy(data, offset + 4, textBytes, 0, size)
response.sessionId = String(textBytes)
}
return offset + 4 + size
}
/**
* 读取负载数据
*/
private fun readPayload(data: ByteArray, response: TtsResponse, offset: Int): Int {
val sizeBytes = ByteArray(4)
System.arraycopy(data, offset, sizeBytes, 0, 4)
val size = bytesToInt(sizeBytes)
if (size > 0 && offset + 4 + size <= data.size) {
val payload = ByteArray(size)
System.arraycopy(data, offset + 4, payload, 0, size)
response.payload = payload
}
return offset + 4 + size
}
/**
* 整数转字节数组 (大端序)
*/
private fun intToBytes(value: Int): ByteArray {
return byteArrayOf(
((value shr 24) and 0xFF).toByte(),
((value shr 16) and 0xFF).toByte(),
((value shr 8) and 0xFF).toByte(),
(value and 0xFF).toByte()
)
}
/**
* 字节数组转整数 (大端序)
*/
private fun bytesToInt(bytes: ByteArray): Int {
if (bytes.size != 4) {
throw IllegalArgumentException("字节数组长度必须为4")
}
return ((bytes[0].toInt() and 0xFF) shl 24) or
((bytes[1].toInt() and 0xFF) shl 16) or
((bytes[2].toInt() and 0xFF) shl 8) or
(bytes[3].toInt() and 0xFF)
}
/**
* 消息头数据类
*/
private data class Header(
var protocolVersion: Int = PROTOCOL_VERSION,
var headerSize: Int = DEFAULT_HEADER_SIZE,
var messageType: Int = 0,
var messageTypeSpecificFlags: Int = 0,
var serializationMethod: Int = 0,
var messageCompression: Int = 0,
var reserved: Int = 0
) {
/**
* 转成字节数组
*/
fun getBytes(): ByteArray {
return byteArrayOf(
((protocolVersion shl 4) or headerSize).toByte(),
((messageType shl 4) or messageTypeSpecificFlags).toByte(),
((serializationMethod shl 4) or messageCompression).toByte(),
reserved.toByte()
)
}
}
/**
* 可选字段数据类
*/
private data class Optional(
var event: Int = EVENT_NONE,
var sessionId: String = ""
) {
/**
* 转成字节数组
*/
fun getBytes(): ByteArray {
var bytes = ByteArray(0)
// 添加事件字段
if (event != EVENT_NONE) {
bytes = intToBytes(event)
}
// 添加会话ID字段
if (sessionId.isNotEmpty()) {
val sessionIdBytes = sessionId.toByteArray()
val sessionIdSizeBytes = intToBytes(sessionIdBytes.size)
val temp = bytes
val newBytes = ByteArray(temp.size + sessionIdSizeBytes.size + sessionIdBytes.size)
var offset = 0
System.arraycopy(temp, 0, newBytes, offset, temp.size)
offset += temp.size
System.arraycopy(sessionIdSizeBytes, 0, newBytes, offset, sessionIdSizeBytes.size)
offset += sessionIdSizeBytes.size
System.arraycopy(sessionIdBytes, 0, newBytes, offset, sessionIdBytes.size)
bytes = newBytes
}
return bytes
}
/**
* 整数转字节数组 (大端序)
*/
private fun intToBytes(value: Int): ByteArray {
return byteArrayOf(
((value shr 24) and 0xFF).toByte(),
((value shr 16) and 0xFF).toByte(),
((value shr 8) and 0xFF).toByte(),
(value and 0xFF).toByte()
)
}
}
/**
* TTS响应数据类
*/
private data class TtsResponse(
var header: Header = Header(),
var event: Int = EVENT_NONE,
var messageType: Int = 0,
var sessionId: String = "",
var connectionId: String = "",
var responseMetaJson: String = "",
var payload: ByteArray = ByteArray(0)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TtsResponse
if (header != other.header) return false
if (event != other.event) return false
if (messageType != other.messageType) return false
if (sessionId != other.sessionId) return false
if (connectionId != other.connectionId) return false
if (responseMetaJson != other.responseMetaJson) return false
if (!payload.contentEquals(other.payload)) return false
return true
}
override fun hashCode(): Int {
var result = header.hashCode()
result = 31 * result + event
result = 31 * result + messageType
result = 31 * result + sessionId.hashCode()
result = 31 * result + connectionId.hashCode()
result = 31 * result + responseMetaJson.hashCode()
result = 31 * result + payload.contentHashCode()
return result
}
}
}

26
local_plugins/bytedance_speech/pubspec.yaml

@ -0,0 +1,26 @@
name: bytedance_speech
description: 火山引擎语音识别和语音合成Flutter插件
version: 0.0.1
homepage:
environment:
sdk: ">=2.19.0 <4.0.0"
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
plugin:
platforms:
android:
package: com.deep_voice.bytedance_speech
pluginClass: BytedanceSpeechPlugin
ios:
pluginClass: BytedanceSpeechPlugin

29
local_plugins/chat_storage/android/build.gradle.kts

@ -30,31 +30,12 @@ android {
jvmTarget = "11" jvmTarget = "11"
} }
// 添加packaging配置,排除冲突文件
packaging {
resources {
excludes.add("META-INF/DEPENDENCIES")
excludes.add("META-INF/LICENSE")
excludes.add("META-INF/LICENSE.txt")
excludes.add("META-INF/license.txt")
excludes.add("META-INF/NOTICE")
excludes.add("META-INF/NOTICE.txt")
excludes.add("META-INF/notice.txt")
excludes.add("META-INF/*.kotlin_module")
}
}
} }
dependencies { dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") add("implementation", "androidx.core:core-ktx:1.8.0")
implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") add("implementation", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("com.squareup.okhttp3:okhttp:4.9.3") // SQLite相关依赖
implementation("com.anthropic:anthropic-java:0.8.0")
implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0")
}
dependencies {
// SQLite相关依赖
implementation("androidx.sqlite:sqlite:2.3.1") implementation("androidx.sqlite:sqlite:2.3.1")
} }

2
local_plugins/chat_storage/android/settings.gradle.kts

@ -1 +1 @@
rootProject.name = "chat_storage" rootProject.name = "chat_storage"

367
local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt

@ -0,0 +1,367 @@
package com.yunqiinnovation.chat_storage
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
/**
* 数据库帮助类
* 负责SQLite数据库操作和业务逻辑
*/
class ChatStorageHelper(context: Context) {
private val TAG = "ChatStorageHelper"
private val dbHelper: ChatDatabaseHelper = ChatDatabaseHelper(context)
/**
* 保存消息
*
* @param sessionId 会话ID
* @param message 消息内容
* @param sender 发送者("user" 或 "assistant")
* @param metadata 元数据(可选)
* @return 成功返回消息ID,失败返回-1
*/
fun saveMessage(
sessionId: String,
message: String,
sender: String,
metadata: String? = null
): Long {
try {
val db = dbHelper.writableDatabase
// 获取当前时间作为时间戳
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
// 执行插入操作
val values = android.content.ContentValues().apply {
put(ChatDatabaseHelper.COLUMN_SESSION_ID, sessionId)
put(ChatDatabaseHelper.COLUMN_MESSAGE, message)
put(ChatDatabaseHelper.COLUMN_SENDER, sender)
put(ChatDatabaseHelper.COLUMN_TIMESTAMP, timestamp)
put(ChatDatabaseHelper.COLUMN_METADATA, metadata)
}
return db.insert(ChatDatabaseHelper.TABLE_MESSAGES, null, values)
} catch (e: Exception) {
Log.e(TAG, "保存消息失败: ${e.message}", e)
return -1
}
}
/**
* 获取指定会话的消息
*
* @param sessionId 会话ID
* @param page 页码,从1开始
* @param pageSize 每页大小
* @return 包含消息列表和分页信息的JSON对象
*/
fun getMessages(sessionId: String, page: Int = 1, pageSize: Int = 20): JSONObject {
try {
val db = dbHelper.readableDatabase
val offset = (page - 1) * pageSize
// 构建查询语句
val query = """
SELECT * FROM ${ChatDatabaseHelper.TABLE_MESSAGES}
WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?
ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC
LIMIT ? OFFSET ?
""".trimIndent()
val selectionArgs = arrayOf(sessionId, pageSize.toString(), offset.toString())
val cursor = db.rawQuery(query, selectionArgs)
val messages = cursorToJsonArray(cursor)
// 获取总记录数
val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?"
val countCursor = db.rawQuery(countQuery, arrayOf(sessionId))
var totalCount = 0
if (countCursor.moveToFirst()) {
totalCount = countCursor.getInt(0)
}
countCursor.close()
// 计算总页数
val totalPages = if (totalCount % pageSize == 0) {
totalCount / pageSize
} else {
(totalCount / pageSize) + 1
}
return JSONObject().apply {
put("messages", messages)
put("page", page)
put("pageSize", pageSize)
put("totalCount", totalCount)
put("totalPages", totalPages)
}
} catch (e: Exception) {
Log.e(TAG, "获取消息失败: ${e.message}", e)
return JSONObject().apply {
put("messages", JSONArray())
put("error", e.message)
}
}
}
/**
* 获取会话列表
*
* @return 包含会话列表的JSON数组
*/
fun getSessions(): JSONArray {
try {
val db = dbHelper.readableDatabase
// 查询不同的会话ID及其最新消息
val query = """
SELECT s1.${ChatDatabaseHelper.COLUMN_SESSION_ID},
s1.${ChatDatabaseHelper.COLUMN_MESSAGE},
s1.${ChatDatabaseHelper.COLUMN_SENDER},
s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP}
FROM ${ChatDatabaseHelper.TABLE_MESSAGES} s1
INNER JOIN (
SELECT ${ChatDatabaseHelper.COLUMN_SESSION_ID},
MAX(${ChatDatabaseHelper.COLUMN_TIMESTAMP}) as max_timestamp
FROM ${ChatDatabaseHelper.TABLE_MESSAGES}
GROUP BY ${ChatDatabaseHelper.COLUMN_SESSION_ID}
) s2 ON s1.${ChatDatabaseHelper.COLUMN_SESSION_ID} = s2.${ChatDatabaseHelper.COLUMN_SESSION_ID}
AND s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} = s2.max_timestamp
ORDER BY s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC
""".trimIndent()
val cursor = db.rawQuery(query, null)
val sessions = JSONArray()
val sessionIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_ID)
val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE)
val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER)
val timestampIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_TIMESTAMP)
while (cursor.moveToNext()) {
val session = JSONObject().apply {
put("sessionId", cursor.getString(sessionIdIndex))
put("lastMessage", cursor.getString(messageIndex))
put("lastSender", cursor.getString(senderIndex))
put("timestamp", cursor.getString(timestampIndex))
// 获取会话中的消息数量
val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?"
val countCursor = db.rawQuery(countQuery, arrayOf(cursor.getString(sessionIdIndex)))
if (countCursor.moveToFirst()) {
put("messageCount", countCursor.getInt(0))
}
countCursor.close()
}
sessions.put(session)
}
cursor.close()
return sessions
} catch (e: Exception) {
Log.e(TAG, "获取会话列表失败: ${e.message}", e)
return JSONArray()
}
}
/**
* 删除消息
*
* @param sessionId 会话ID(可选,如果提供则删除整个会话)
* @param messageIds 消息ID列表(可选,如果提供则删除指定消息)
* @return 成功返回true,失败返回false
*/
fun deleteMessages(sessionId: String? = null, messageIds: List<Int>? = null): Boolean {
try {
val db = dbHelper.writableDatabase
if (messageIds != null && messageIds.isNotEmpty()) {
// 删除指定ID的消息
val idsStr = messageIds.joinToString(",")
val deleteQuery = "DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_ID} IN ($idsStr)"
db.execSQL(deleteQuery)
return true
} else if (!sessionId.isNullOrEmpty()) {
// 删除会话中的所有消息
val deleteQuery = "DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?"
db.execSQL(deleteQuery, arrayOf(sessionId))
return true
}
return false
} catch (e: Exception) {
Log.e(TAG, "删除消息失败: ${e.message}", e)
return false
}
}
/**
* 清空数据库
*
* @return 成功返回true,失败返回false
*/
fun clearDatabase(): Boolean {
try {
val db = dbHelper.writableDatabase
db.execSQL("DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES}")
return true
} catch (e: Exception) {
Log.e(TAG, "清空数据库失败: ${e.message}", e)
return false
}
}
/**
* 查询指定会话的最近N条消息
*
* @param sessionId 会话ID
* @param limit 限制数量
* @return 消息列表,按时间正序排列
*/
fun getRecentMessages(sessionId: String, limit: Int): List<Map<String, Any>> {
try {
val db = dbHelper.readableDatabase
// 构建查询语句 - 按时间倒序获取最近的N条,然后在结果中再按时间正序
val query = """
SELECT * FROM (
SELECT * FROM ${ChatDatabaseHelper.TABLE_MESSAGES}
WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?
ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC
LIMIT ?
) tmp ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} ASC
""".trimIndent()
val selectionArgs = arrayOf(sessionId, limit.toString())
val cursor = db.rawQuery(query, selectionArgs)
val messages = ArrayList<Map<String, Any>>()
val idIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_ID)
val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE)
val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER)
val timestampIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_TIMESTAMP)
val metadataIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_METADATA)
while (cursor.moveToNext()) {
val message = mutableMapOf<String, Any>(
"id" to cursor.getInt(idIndex),
"message" to cursor.getString(messageIndex),
"sender" to cursor.getString(senderIndex),
"timestamp" to cursor.getString(timestampIndex)
)
if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) {
message["metadata"] = cursor.getString(metadataIndex)
}
messages.add(message)
}
cursor.close()
return messages
} catch (e: Exception) {
Log.e(TAG, "获取最近消息失败: ${e.message}", e)
return emptyList()
}
}
/**
* 将数据库游标转换为JSON数组
*/
private fun cursorToJsonArray(cursor: Cursor): JSONArray {
val jsonArray = JSONArray()
val idIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_ID)
val sessionIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_ID)
val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE)
val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER)
val timestampIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_TIMESTAMP)
val metadataIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_METADATA)
while (cursor.moveToNext()) {
val jsonObject = JSONObject().apply {
put("id", cursor.getInt(idIndex))
put("sessionId", cursor.getString(sessionIdIndex))
put("message", cursor.getString(messageIndex))
put("sender", cursor.getString(senderIndex))
put("timestamp", cursor.getString(timestampIndex))
if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) {
put("metadata", cursor.getString(metadataIndex))
}
}
jsonArray.put(jsonObject)
}
cursor.close()
return jsonArray
}
/**
* 获取数据库帮助类实例
*/
fun getDBHelper(): ChatDatabaseHelper {
return dbHelper
}
}
/**
* SQLite数据库帮助类
*/
class ChatDatabaseHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
const val DATABASE_NAME = "chat_messages.db"
const val DATABASE_VERSION = 1
// 定义消息表
const val TABLE_MESSAGES = "messages"
const val COLUMN_ID = "id"
const val COLUMN_SESSION_ID = "session_id"
const val COLUMN_MESSAGE = "message"
const val COLUMN_SENDER = "sender"
const val COLUMN_TIMESTAMP = "timestamp"
const val COLUMN_METADATA = "metadata"
}
override fun onCreate(db: SQLiteDatabase) {
// 创建消息表
val createMessagesTable = """
CREATE TABLE $TABLE_MESSAGES (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_SESSION_ID TEXT NOT NULL,
$COLUMN_MESSAGE TEXT NOT NULL,
$COLUMN_SENDER TEXT NOT NULL,
$COLUMN_TIMESTAMP TEXT NOT NULL,
$COLUMN_METADATA TEXT
)
""".trimIndent()
db.execSQL(createMessagesTable)
// 创建索引以加快查询速度
db.execSQL("CREATE INDEX idx_session_id ON $TABLE_MESSAGES ($COLUMN_SESSION_ID)")
db.execSQL("CREATE INDEX idx_timestamp ON $TABLE_MESSAGES ($COLUMN_TIMESTAMP)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// 处理数据库升级逻辑
if (oldVersion < newVersion) {
// 简单的升级策略 - 删除旧表并创建新表
// 在实际应用中,应该实现数据迁移而非删除
db.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES")
onCreate(db)
}
}
}

216
local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt

@ -1,80 +1,30 @@
package com.yunqiinnovation.chat_storage package com.yunqiinnovation.chat_storage
import android.content.Context import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.util.Log import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*
// 定义数据库帮助类 /**
private class ChatDatabaseHelper(context: Context) : * ChatStoragePlugin - Flutter插件接口
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { * 负责处理Flutter端的方法调用,并委托给ChatStorageHelper
*/
companion object {
private const val DATABASE_NAME = "chat_messages.db"
private const val DATABASE_VERSION = 1
// 定义消息表
private const val TABLE_MESSAGES = "messages"
private const val COLUMN_ID = "id"
private const val COLUMN_SESSION_ID = "session_id"
private const val COLUMN_MESSAGE = "message"
private const val COLUMN_SENDER = "sender"
private const val COLUMN_TIMESTAMP = "timestamp"
private const val COLUMN_METADATA = "metadata"
}
override fun onCreate(db: SQLiteDatabase) {
// 创建消息表
val createMessagesTable = """
CREATE TABLE $TABLE_MESSAGES (
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_SESSION_ID TEXT NOT NULL,
$COLUMN_MESSAGE TEXT NOT NULL,
$COLUMN_SENDER TEXT NOT NULL,
$COLUMN_TIMESTAMP TEXT NOT NULL,
$COLUMN_METADATA TEXT
)
""".trimIndent()
db.execSQL(createMessagesTable)
// 创建索引以加快查询速度
db.execSQL("CREATE INDEX idx_session_id ON $TABLE_MESSAGES ($COLUMN_SESSION_ID)")
db.execSQL("CREATE INDEX idx_timestamp ON $TABLE_MESSAGES ($COLUMN_TIMESTAMP)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// 处理数据库升级逻辑
if (oldVersion < newVersion) {
// 简单的升级策略 - 删除旧表并创建新表
// 在实际应用中,应该实现数据迁移而非删除
db.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES")
onCreate(db)
}
}
}
/** ChatStorageSqlitePlugin */
class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { class ChatStoragePlugin: FlutterPlugin, MethodCallHandler {
private val TAG = "ChatStoragePlugin"
private lateinit var channel : MethodChannel private lateinit var channel : MethodChannel
private lateinit var context: Context private lateinit var context: Context
private lateinit var dbHelper: ChatDatabaseHelper private lateinit var chatStorageHelper: ChatStorageHelper
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext context = flutterPluginBinding.applicationContext
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage") channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage")
channel.setMethodCallHandler(this) channel.setMethodCallHandler(this)
dbHelper = ChatDatabaseHelper(context) chatStorageHelper = ChatStorageHelper(context)
Log.d(TAG, "聊天存储插件已加载")
} }
override fun onMethodCall(call: MethodCall, result: Result) { override fun onMethodCall(call: MethodCall, result: Result) {
@ -86,27 +36,20 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler {
val sender = call.argument<String>("sender") ?: "" val sender = call.argument<String>("sender") ?: ""
val metadata = call.argument<String>("metadata") val metadata = call.argument<String>("metadata")
val db = dbHelper.writableDatabase val id = chatStorageHelper.saveMessage(
sessionId = sessionId,
message = message,
sender = sender,
metadata = metadata
)
// 获取当前时间作为时间戳
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
// 执行插入操作
val values = android.content.ContentValues().apply {
put("session_id", sessionId)
put("message", message)
put("sender", sender)
put("timestamp", timestamp)
put("metadata", metadata)
}
val id = db.insert("messages", null, values)
if (id != -1L) { if (id != -1L) {
result.success(mapOf("success" to true, "id" to id)) result.success(mapOf("success" to true, "id" to id))
} else { } else {
result.error("DB_ERROR", "插入消息失败", null) result.error("DB_ERROR", "插入消息失败", null)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "保存消息时发生错误", e)
result.error("EXCEPTION", "保存消息时发生错误: ${e.message}", null) result.error("EXCEPTION", "保存消息时发生错误: ${e.message}", null)
} }
} }
@ -116,121 +59,80 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler {
val page = call.argument<Int>("page") ?: 1 val page = call.argument<Int>("page") ?: 1
val pageSize = call.argument<Int>("pageSize") ?: 20 val pageSize = call.argument<Int>("pageSize") ?: 20
val db = dbHelper.readableDatabase val messagesJson = chatStorageHelper.getMessages(
val offset = (page - 1) * pageSize sessionId = sessionId,
page = page,
// 构建查询语句 pageSize = pageSize
val query = """ )
SELECT * FROM messages
WHERE session_id = ?
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
""".trimIndent()
val selectionArgs = arrayOf(sessionId, pageSize.toString(), offset.toString())
val cursor = db.rawQuery(query, selectionArgs)
val messages = cursorToJsonArray(cursor)
// 获取总记录数
val countQuery = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
val countCursor = db.rawQuery(countQuery, arrayOf(sessionId))
var totalCount = 0
if (countCursor.moveToFirst()) {
totalCount = countCursor.getInt(0)
}
countCursor.close()
// 计算总页数
val totalPages = if (totalCount % pageSize == 0) {
totalCount / pageSize
} else {
(totalCount / pageSize) + 1
}
val response = JSONObject().apply { result.success(messagesJson.toString())
put("messages", messages)
put("page", page)
put("pageSize", pageSize)
put("totalCount", totalCount)
put("totalPages", totalPages)
}
result.success(response.toString())
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "获取消息时发生错误", e)
result.error("EXCEPTION", "获取消息时发生错误: ${e.message}", null) result.error("EXCEPTION", "获取消息时发生错误: ${e.message}", null)
} }
} }
"getSessions" -> {
try {
val sessionsJson = chatStorageHelper.getSessions()
result.success(sessionsJson.toString())
} catch (e: Exception) {
Log.e(TAG, "获取会话列表时发生错误", e)
result.error("EXCEPTION", "获取会话列表时发生错误: ${e.message}", null)
}
}
"deleteMessages" -> { "deleteMessages" -> {
try { try {
val sessionId = call.argument<String>("sessionId") ?: "" val sessionId = call.argument<String>("sessionId")
val messageIds = call.argument<List<Int>>("messageIds") val messageIds = call.argument<List<Int>>("messageIds")
val db = dbHelper.writableDatabase val success = chatStorageHelper.deleteMessages(
sessionId = sessionId,
messageIds = messageIds
)
if (messageIds != null && messageIds.isNotEmpty()) { if (success) {
// 删除指定ID的消息
val idsStr = messageIds.joinToString(",")
val deleteQuery = "DELETE FROM messages WHERE id IN ($idsStr)"
db.execSQL(deleteQuery)
result.success(mapOf("success" to true, "count" to messageIds.size))
} else if (sessionId.isNotEmpty()) {
// 删除会话中的所有消息
val deleteQuery = "DELETE FROM messages WHERE session_id = ?"
db.execSQL(deleteQuery, arrayOf(sessionId))
result.success(mapOf("success" to true)) result.success(mapOf("success" to true))
} else { } else {
result.error("INVALID_ARGS", "必须提供sessionId或messageIds", null) result.error("INVALID_ARGS", "必须提供sessionId或messageIds", null)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "删除消息时发生错误", e)
result.error("EXCEPTION", "删除消息时发生错误: ${e.message}", null) result.error("EXCEPTION", "删除消息时发生错误: ${e.message}", null)
} }
} }
"clearDatabase" -> { "clearDatabase" -> {
try { try {
val db = dbHelper.writableDatabase val success = chatStorageHelper.clearDatabase()
db.execSQL("DELETE FROM messages") result.success(mapOf("success" to success))
result.success(mapOf("success" to true))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "清空数据库时发生错误", e)
result.error("EXCEPTION", "清空数据库时发生错误: ${e.message}", null) result.error("EXCEPTION", "清空数据库时发生错误: ${e.message}", null)
} }
} }
"getRecentMessages" -> {
try {
val sessionId = call.argument<String>("sessionId") ?: ""
val limit = call.argument<Int>("limit") ?: 10
val messages = chatStorageHelper.getRecentMessages(
sessionId = sessionId,
limit = limit
)
result.success(messages)
} catch (e: Exception) {
Log.e(TAG, "获取最近消息时发生错误", e)
result.error("EXCEPTION", "获取最近消息时发生错误: ${e.message}", null)
}
}
else -> { else -> {
result.notImplemented() result.notImplemented()
} }
} }
} }
private fun cursorToJsonArray(cursor: Cursor): JSONArray {
val jsonArray = JSONArray()
val idIndex = cursor.getColumnIndex("id")
val sessionIdIndex = cursor.getColumnIndex("session_id")
val messageIndex = cursor.getColumnIndex("message")
val senderIndex = cursor.getColumnIndex("sender")
val timestampIndex = cursor.getColumnIndex("timestamp")
val metadataIndex = cursor.getColumnIndex("metadata")
while (cursor.moveToNext()) {
val jsonObject = JSONObject().apply {
put("id", cursor.getInt(idIndex))
put("sessionId", cursor.getString(sessionIdIndex))
put("message", cursor.getString(messageIndex))
put("sender", cursor.getString(senderIndex))
put("timestamp", cursor.getString(timestampIndex))
if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) {
put("metadata", cursor.getString(metadataIndex))
}
}
jsonArray.put(jsonObject)
}
cursor.close()
return jsonArray
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null) channel.setMethodCallHandler(null)
Log.d(TAG, "聊天存储插件已卸载")
} }
} }

8
local_plugins/chat_storage/pubspec.yaml

@ -1,7 +1,7 @@
name: chat_storage name: chat_storage
description: A Flutter plugin for storing chat messages using SQLite. description: 聊天历史存储插件,提供存储和检索聊天历史的功能
version: 0.0.1 version: 0.0.1
# homepage: homepage:
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@ -10,15 +10,13 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
sqflite: ^2.3.0
path: ^1.8.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0
# The following section is specific to Flutter packages. # 插件平台配置
flutter: flutter:
# This section identifies this Flutter project as a plugin project. # This section identifies this Flutter project as a plugin project.
plugin: plugin:

40
local_plugins/classic_bluetooth/android/build.gradle.kts

@ -0,0 +1,40 @@
plugins {
// Android Library 插件
id("com.android.library")
// Kotlin Android 插件
id("org.jetbrains.kotlin.android")
id("com.github.johnrengelman.shadow") version "8.1.1"
}
android {
// 命名空间,对应你插件的包名(需与代码内包名保持一致)
namespace = "com.yunqiinnovation.classic_bluetooth"
// 目标 SDK 版本
compileSdk = 33
defaultConfig {
// 最低 SDK 版本
minSdk = 21
targetSdk = 33
}
// Java 语言级别兼容配置
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
// Kotlin 语言级别
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0")
implementation("androidx.core:core-ktx:1.7.0")
}

1
local_plugins/classic_bluetooth/android/settings.gradle.kts

@ -0,0 +1 @@
rootProject.name = "classic_bluetooth"

17
local_plugins/classic_bluetooth/android/src/main/AndroidManifest.xml

@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yunqiinnovation.classic_bluetooth">
<!-- 蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Android 12+蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 位置权限(部分Android版本需要) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
</manifest>

266
local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt

@ -0,0 +1,266 @@
package com.yunqiinnovation.classic_bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.Manifest
import androidx.core.content.ContextCompat
import android.util.Log
/**
* 经典蓝牙助手类,集成蓝牙设备管理和状态监听功能
*/
class ClassicBluetoothHelper(private val context: Context) {
private val TAG = "ClassicBluetoothHelper"
// 回调接口用于通知蓝牙事件
private var bluetoothEventListener: BluetoothEventListener? = null
// 蓝牙状态变化广播接收器
private val bluetoothStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
if (device != null) {
val deviceInfo = mapOf(
"name" to (device.name ?: "未知设备"),
"address" to device.address
)
log("蓝牙设备已连接: ${deviceInfo["name"]} (${deviceInfo["address"]})")
notifyEvent("deviceConnected", deviceInfo)
}
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
if (device != null) {
val deviceInfo = mapOf(
"name" to (device.name ?: "未知设备"),
"address" to device.address
)
log("蓝牙设备已断开: ${deviceInfo["name"]} (${deviceInfo["address"]})")
notifyEvent("deviceDisconnected", deviceInfo)
}
}
BluetoothAdapter.ACTION_STATE_CHANGED -> {
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
when (state) {
BluetoothAdapter.STATE_ON -> {
log("蓝牙已开启")
notifyEvent("bluetoothStateChanged", mapOf("state" to "on"))
}
BluetoothAdapter.STATE_OFF -> {
log("蓝牙已关闭")
notifyEvent("bluetoothStateChanged", mapOf("state" to "off"))
}
}
}
}
}
}
init {
// 在构造函数中注册广播接收器
registerBroadcastReceivers()
}
/**
* 注册蓝牙广播接收器
*/
private fun registerBroadcastReceivers() {
val bluetoothFilter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
}
if (Build.VERSION.SDK_INT >= 34) { // Android 14 (UPSIDE_DOWN_CAKE)
context.registerReceiver(bluetoothStateReceiver, bluetoothFilter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(bluetoothStateReceiver, bluetoothFilter)
}
log("已注册蓝牙状态广播接收器")
}
/**
* 通知蓝牙事件
*/
private fun notifyEvent(type: String, data: Map<String, Any>) {
val event = mutableMapOf<String, Any>(
"type" to type,
"timestamp" to System.currentTimeMillis()
)
// 添加其他数据
if (type == "deviceConnected" || type == "deviceDisconnected") {
event["device"] = data
} else {
event.putAll(data)
}
// 通知监听器
bluetoothEventListener?.onBluetoothEvent(event)
}
/**
* 设置蓝牙事件监听器
*/
fun setBluetoothEventListener(listener: BluetoothEventListener?) {
this.bluetoothEventListener = listener
}
/**
* 检查蓝牙是否启用
* @return 蓝牙是否启用
*/
fun isBluetoothEnabled(): Boolean {
try {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
log("设备不支持蓝牙", true)
return false
}
return bluetoothAdapter.isEnabled
} catch (e: Exception) {
log("检查蓝牙状态失败: ${e.message}", true)
return false
}
}
/**
* 获取所有连接的经典蓝牙设备
* @return 设备信息列表,如果出错则返回空列表
*/
fun getConnectedBluetoothDevices(): List<Map<String, String>> {
if (!hasBluetoothPermission()) {
log("缺少蓝牙权限", true)
return emptyList()
}
val devices = mutableSetOf<BluetoothDevice>()
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: return emptyList()
if (!bluetoothAdapter.isEnabled) {
return emptyList()
}
try {
// 获取已配对设备
val bondedDevices = bluetoothAdapter.bondedDevices ?: setOf()
// 只保留已连接的设备
devices.addAll(bondedDevices.filter { device ->
try {
val deviceClass = device.bluetoothClass
val majorDeviceClass = deviceClass.majorDeviceClass
// 检查设备是否为音频设备、穿戴设备等常见外设类型
device.bondState == BluetoothDevice.BOND_BONDED &&
(majorDeviceClass == BluetoothProfile.HEADSET ||
majorDeviceClass == BluetoothProfile.A2DP ||
majorDeviceClass == BluetoothProfile.GATT)
} catch (e: Exception) {
false
}
})
return devices.map { device ->
mapOf(
"name" to (device.name ?: "未知设备"),
"address" to device.address,
"type" to getDeviceTypeName(device)
)
}
} catch (e: Exception) {
log("获取已连接蓝牙设备失败: ${e.message}", true)
return emptyList()
}
}
/**
* 获取设备类型名称
*/
private fun getDeviceTypeName(device: BluetoothDevice): String {
return try {
val deviceClass = device.bluetoothClass
when (deviceClass.majorDeviceClass) {
BluetoothProfile.HEADSET -> "耳机"
BluetoothProfile.A2DP -> "音频设备"
BluetoothProfile.GATT -> "低功耗设备"
else -> "其他设备"
}
} catch (e: Exception) {
"未知类型"
}
}
/**
* 检查是否有蓝牙权限
*/
fun hasBluetoothPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH
) == PackageManager.PERMISSION_GRANTED
}
}
/**
* 简单的日志函数
*/
private fun log(message: String, isError: Boolean = false) {
if (isError) {
Log.e(TAG, message)
} else {
Log.d(TAG, message)
}
}
/**
* 蓝牙事件回调接口
*/
interface BluetoothEventListener {
fun onBluetoothEvent(event: Map<String, Any>)
}
/**
* 释放资源
*/
fun dispose() {
try {
context.unregisterReceiver(bluetoothStateReceiver)
log("已注销蓝牙状态广播接收器")
} catch (e: Exception) {
log("注销蓝牙状态广播接收器失败: ${e.message}", true)
}
// 清除监听器
bluetoothEventListener = null
}
}

186
local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt

@ -0,0 +1,186 @@
package com.yunqiinnovation.classic_bluetooth
import androidx.annotation.NonNull
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import io.flutter.plugin.common.PluginRegistry.RequestPermissionsResultListener
/** ClassicBluetoothPlugin */
class ClassicBluetoothPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, RequestPermissionsResultListener, ClassicBluetoothHelper.BluetoothEventListener {
private lateinit var methodChannel : MethodChannel
private lateinit var eventChannel : EventChannel
private var eventSink: EventSink? = null
private lateinit var context: Context
private var activity: Activity? = null
private lateinit var bluetoothHelper: ClassicBluetoothHelper
private val REQUEST_BLUETOOTH_PERMISSIONS = 1003
private var pendingResult: Result? = null
private var pendingMethodCall: MethodCall? = null
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
// 设置方法通道
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.classic_bluetooth/methods")
methodChannel.setMethodCallHandler(this)
// 设置事件通道
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.classic_bluetooth/events")
eventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
})
// 初始化蓝牙助手
bluetoothHelper = ClassicBluetoothHelper(context)
bluetoothHelper.setBluetoothEventListener(this)
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"getConnectedDevices" -> {
if (!bluetoothHelper.hasBluetoothPermission()) {
if (activity != null) {
// 保存当前方法调用和结果,以便权限授予后继续执行
pendingMethodCall = call
pendingResult = result
requestBluetoothPermissions()
} else {
result.error("PERMISSION_DENIED", "缺少必要的蓝牙权限,且无法请求权限", null)
}
return
}
// 获取已连接的所有蓝牙设备
val devices = bluetoothHelper.getConnectedBluetoothDevices()
result.success(devices)
}
"isBluetoothEnabled" -> {
val isEnabled = bluetoothHelper.isBluetoothEnabled()
result.success(isEnabled)
}
else -> {
result.notImplemented()
}
}
}
/**
* 请求蓝牙权限
*/
private fun requestBluetoothPermissions() {
if (activity == null) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ActivityCompat.requestPermissions(
activity!!,
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
),
REQUEST_BLUETOOTH_PERMISSIONS
)
} else {
ActivityCompat.requestPermissions(
activity!!,
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
),
REQUEST_BLUETOOTH_PERMISSIONS
)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean {
if (requestCode == REQUEST_BLUETOOTH_PERMISSIONS) {
// 检查是否有足够的权限
val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (granted) {
// 权限已授予,继续执行挂起的方法调用
pendingMethodCall?.let { call ->
pendingResult?.let { result ->
onMethodCall(call, result)
pendingMethodCall = null
pendingResult = null
}
}
} else {
// 权限被拒绝
pendingResult?.error("PERMISSION_DENIED", "蓝牙权限被拒绝", null)
pendingMethodCall = null
pendingResult = null
}
return true
}
return false
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addRequestPermissionsResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addRequestPermissionsResultListener(this)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
// 释放资源
bluetoothHelper.setBluetoothEventListener(null)
bluetoothHelper.dispose()
}
override fun onBluetoothEvent(event: Map<String, Any>) {
if (eventSink == null) return
try {
activity?.runOnUiThread {
try {
eventSink?.success(event)
} catch (e: Exception) {
// 忽略异常
}
}
} catch (e: Exception) {
// 忽略异常
}
}
}

119
local_plugins/classic_bluetooth/lib/classic_bluetooth.dart

@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// 经典蓝牙设备信息
class BluetoothDevice {
/// 设备名称
final String name;
/// 设备地址
final String address;
/// 设备类型
final String type;
/// 构造函数
BluetoothDevice({
required this.name,
required this.address,
required this.type,
});
/// 从Map创建设备信息
factory BluetoothDevice.fromMap(Map<String, dynamic> map) {
return BluetoothDevice(
name: map['name'] as String? ?? '未知设备',
address: map['address'] as String? ?? '',
type: map['type'] as String? ?? '未知类型',
);
}
/// 转换为Map
Map<String, dynamic> toMap() {
return {
'name': name,
'address': address,
'type': type,
};
}
@override
String toString() => '$name ($address) [$type]';
}
/// 蓝牙事件
class BluetoothEvent {
/// 事件类型
final String type;
/// 时间戳
final int timestamp;
/// 事件数据
final Map<String, dynamic> data;
/// 构造函数
BluetoothEvent({
required this.type,
required this.timestamp,
required this.data,
});
/// 从Map创建事件
factory BluetoothEvent.fromMap(Map<dynamic, dynamic> map) {
return BluetoothEvent(
type: map['type'] as String,
timestamp: map['timestamp'] as int,
data: Map<String, dynamic>.from(map)
..remove('type')
..remove('timestamp'),
);
}
/// 获取关联的设备(如果有)
BluetoothDevice? get device {
if (data.containsKey('device')) {
return BluetoothDevice.fromMap(Map<String, dynamic>.from(data['device'] as Map));
}
return null;
}
/// 获取蓝牙状态(如果是状态变化事件)
String? get bluetoothState {
if (type == 'bluetoothStateChanged') {
return data['state'] as String?;
}
return null;
}
}
/// 经典蓝牙插件类
class ClassicBluetooth {
static const MethodChannel _methodChannel =
MethodChannel('com.yunqiinnovation.classic_bluetooth/methods');
static const EventChannel _eventChannel =
EventChannel('com.yunqiinnovation.classic_bluetooth/events');
static Stream<BluetoothEvent>? _eventStream;
/// 获取蓝牙事件流
static Stream<BluetoothEvent> get events {
_eventStream ??= _eventChannel.receiveBroadcastStream()
.map<BluetoothEvent>((dynamic event) => BluetoothEvent.fromMap(Map<dynamic, dynamic>.from(event as Map)));
return _eventStream!;
}
/// 获取当前连接的蓝牙设备
static Future<List<BluetoothDevice>> getConnectedDevices() async {
final List<dynamic> devices = await _methodChannel.invokeMethod('getConnectedDevices');
return devices
.map((device) => BluetoothDevice.fromMap(Map<String, dynamic>.from(device as Map)))
.toList();
}
/// 检查蓝牙是否启用
static Future<bool> isBluetoothEnabled() async {
return await _methodChannel.invokeMethod('isBluetoothEnabled');
}
}

23
local_plugins/classic_bluetooth/pubspec.yaml

@ -0,0 +1,23 @@
name: classic_bluetooth
description: 经典蓝牙设备管理插件
version: 1.0.0
homepage: https://yunqiinnovation.com
environment:
sdk: ">=2.12.0 <3.0.0"
flutter: ">=1.20.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
plugin:
platforms:
android:
package: com.yunqiinnovation.classic_bluetooth
pluginClass: ClassicBluetoothPlugin

46
local_plugins/deepvoice_utils/android/build.gradle.kts

@ -0,0 +1,46 @@
plugins {
// Android Library 插件
id("com.android.library")
// Kotlin Android 插件
id("org.jetbrains.kotlin.android")
id("com.github.johnrengelman.shadow") version "8.1.1"
}
android {
// 命名空间,对应你插件的包名(需与代码内包名保持一致)
namespace = "com.yunqiinnovation.deepvoice_utils"
// 目标 SDK 版本
compileSdk = 33
defaultConfig {
// 最低 SDK 版本
minSdk = 21
targetSdk = 33
}
// Java 语言级别兼容配置
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
// Kotlin 语言级别
kotlinOptions {
jvmTarget = "11"
}
// 添加packaging配置,排除冲突文件
packaging {
resources {
excludes.add("META-INF/DEPENDENCIES")
excludes.add("META-INF/LICENSE")
excludes.add("META-INF/LICENSE.txt")
excludes.add("META-INF/license.txt")
excludes.add("META-INF/NOTICE")
excludes.add("META-INF/NOTICE.txt")
excludes.add("META-INF/notice.txt")
excludes.add("META-INF/*.kotlin_module")
}
}
}

1
local_plugins/deepvoice_utils/android/settings.gradle.kts

@ -0,0 +1 @@
rootProject.name = "deepvoice_utils"

7
local_plugins/deepvoice_utils/android/src/main/AndroidManifest.xml

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yunqiinnovation.deepvoice_utils">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
</manifest>

33
local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/DeepvoiceUtilsPlugin.kt

@ -0,0 +1,33 @@
package com.yunqiinnovation.deepvoice_utils
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
/** DeepvoiceUtilsPlugin */
class DeepvoiceUtilsPlugin: FlutterPlugin, MethodCallHandler {
private lateinit var channel : MethodChannel
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "deepvoice_utils")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"getPlatformVersion" -> {
result.success("Android ${android.os.Build.VERSION.RELEASE}")
}
else -> {
result.notImplemented()
}
}
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}

2
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/core/utils/FileLogger.kt → local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/FileLogger.kt

@ -1,4 +1,4 @@
package com.yunqiinnovation.deepsound.core.utils package com.yunqiinnovation.deepvoice_utils
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log

3
local_plugins/deepvoice_utils/lib/deepvoice_utils.dart

@ -0,0 +1,3 @@
library deepvoice_utils;
export 'src/file_logger.dart';

31
local_plugins/deepvoice_utils/lib/src/file_logger.dart

@ -0,0 +1,31 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// 文件日志记录器
///
/// 提供文件日志记录功能,支持简单的日志记录
class FileLogger {
/// 记录调试日志
static void d(String tag, String message) {
debugPrint('[$tag] DEBUG: $message');
}
/// 记录信息日志
static void i(String tag, String message) {
debugPrint('[$tag] INFO: $message');
}
/// 记录警告日志
static void w(String tag, String message) {
debugPrint('[$tag] WARNING: $message');
}
/// 记录错误日志
static void e(String tag, String message, [dynamic error, StackTrace? stackTrace]) {
final errorMsg = error != null ? '$message: $error' : message;
debugPrint('[$tag] ERROR: $errorMsg');
if (stackTrace != null) {
debugPrint(stackTrace.toString());
}
}
}

27
local_plugins/deepvoice_utils/pubspec.yaml

@ -0,0 +1,27 @@
name: deepvoice_utils
description: 通用工具库,包含日志、文件处理等功能
version: 0.0.1
environment:
sdk: ">=2.17.0 <4.0.0"
flutter: ">=2.5.0"
dependencies:
flutter:
sdk: flutter
logger: ^2.0.0
path_provider: ^2.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
plugin:
platforms:
android:
package: com.yunqiinnovation.deepvoice_utils
pluginClass: DeepvoiceUtilsPlugin
ios:
pluginClass: DeepvoiceUtilsPlugin

10
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt

@ -222,7 +222,7 @@ class MCPSubClient(
// 使用toString()获取完整JSON字符串 // 使用toString()获取完整JSON字符串
val schemaStr = inputSchema.toString() val schemaStr = inputSchema.toString()
Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr") // Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr")
// 解析properties和required // 解析properties和required
val propertiesMap = HashMap<String, Any>() val propertiesMap = HashMap<String, Any>()
@ -278,7 +278,7 @@ class MCPSubClient(
propertiesMap[fieldName] = fieldMap propertiesMap[fieldName] = fieldMap
} }
Log.d(TAG, "成功解析属性: $propertiesMap") // Log.d(TAG, "成功解析属性: $propertiesMap")
} }
// 解析required // 解析required
@ -299,7 +299,7 @@ class MCPSubClient(
} }
} }
requiredList.addAll(fieldNames) requiredList.addAll(fieldNames)
Log.d(TAG, "解析到required字段: $requiredList") // Log.d(TAG, "解析到required字段: $requiredList")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -903,8 +903,8 @@ private fun createSslTrustAllClient(): HttpClient {
.hostnameVerifier { _, _ -> true } .hostnameVerifier { _, _ -> true }
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 // .addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器
.addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 // .addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器
.build() .build()
// 创建使用OkHttp引擎的HttpClient // 创建使用OkHttp引擎的HttpClient

4
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt

@ -395,8 +395,8 @@ class OpenAIService(private val context: Context? = null) {
.build() .build()
// 输出用于测试的curl命令 // 输出用于测试的curl命令
val curlCommand = buildCurlCommand(request, requestBody.toString()) // val curlCommand = buildCurlCommand(request, requestBody.toString())
Log.d(TAG, "curl command: $curlCommand") // Log.d(TAG, "curl command: $curlCommand")
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {

BIN
local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.bin

Binary file not shown.

BIN
local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock

Binary file not shown.

BIN
local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

4
local_plugins/opus/.gradle/buildOutputCleanup/cache.properties

@ -1,2 +1,2 @@
#Thu Apr 24 10:52:53 CST 2025 #Wed Apr 30 15:54:50 IST 2025
gradle.version=8.9 gradle.version=8.10

863
local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt

@ -1,863 +0,0 @@
package com.csdcorp.speech_to_text
import android.Manifest
import android.R.attr.data
import android.annotation.TargetApi
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.*
import android.speech.SpeechRecognizer.createOnDeviceSpeechRecognizer
import android.speech.SpeechRecognizer.createSpeechRecognizer
import android.util.Log
import androidx.annotation.NonNull
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import io.flutter.plugin.common.PluginRegistry.Registrar
import org.json.JSONArray
import org.json.JSONObject
import java.util.*
import java.util.concurrent.Executors
enum class SpeechToTextErrors {
multipleRequests,
unimplemented,
noLanguageIntent,
recognizerNotAvailable,
missingOrInvalidArg,
missingContext,
unknown
}
enum class SpeechToTextCallbackMethods {
textRecognition,
notifyStatus,
notifyError,
soundLevelChange,
}
enum class SpeechToTextStatus {
listening,
notListening,
unavailable,
available,
done,
doneNoResult,
}
enum class ListenMode {
deviceDefault,
dictation,
search,
confirmation,
}
const val pluginChannelName = "plugin.csdcorp.com/speech_to_text"
@TargetApi(8)
/** SpeechToTextPlugin */
public class SpeechToTextPlugin :
MethodCallHandler, RecognitionListener,
PluginRegistry.RequestPermissionsResultListener, FlutterPlugin,
ActivityAware {
private var pluginContext: Context? = null
private var channel: MethodChannel? = null
private val minSdkForSpeechSupport = 21
private val brokenStopSdk = 29
private val minSdkForOnDeviceSpeechSupport = 31
private val speechToTextPermissionCode = 28521
private val missingConfidence: Double = -1.0
private var speechThresholdRms = 9
private val logTag = "SpeechToTextPlugin"
private var recognizerStops = true
private var currentActivity: Activity? = null
private var activeResult: Result? = null
private var initializedSuccessfully: Boolean = false
private var permissionToRecordAudio: Boolean = false
private var listening = false
private var debugLogging: Boolean = false
private var alwaysUseStop: Boolean = false
private var intentLookup: Boolean = false
private var noBluetoothOpt: Boolean = false // user-defined option
private var bluetoothDisabled = true // final bluetooth state (combines user-defined option and permissions)
private var resultSent: Boolean = false
private var lastOnDevice: Boolean = false
private var speechRecognizer: SpeechRecognizer? = null
private var recognizerIntent: Intent? = null
private var bluetoothAdapter: android.bluetooth.BluetoothAdapter? = null
private var pairedDevices: Set<android.bluetooth.BluetoothDevice>? = null
private var activeBluetooth: android.bluetooth.BluetoothDevice? = null
private var bluetoothHeadset: BluetoothHeadset? = null
private var previousRecognizerLang: String? = null
private var previousPartialResults: Boolean = true
private var previousListenMode: ListenMode = ListenMode.deviceDefault
private var lastFinalTime: Long = 0
private var speechStartTime: Long = 0
private var minRms: Float = 1000.0F
private var maxRms: Float = -100.0F
private val handler: Handler = Handler(Looper.getMainLooper())
private val defaultLanguageTag: String = Locale.getDefault().toLanguageTag()
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(flutterPluginBinding.getApplicationContext(), flutterPluginBinding.getBinaryMessenger());
}
// This static function is optional and equivalent to onAttachedToEngine. It supports the old
// pre-Flutter-1.12 Android projects. You are encouraged to continue supporting
// plugin registration via this function while apps migrate to use the new Android APIs
// post-flutter-1.12 via https://flutter.dev/go/android-project-migration.
//
// It is encouraged to share logic between onAttachedToEngine and registerWith to keep
// them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called
// depending on the user's project. onAttachedToEngine or registerWith must both be defined
// in the same class.
companion object {
@JvmStatic
fun registerWith(registrar: Registrar) {
val speechPlugin = SpeechToTextPlugin()
speechPlugin.currentActivity = registrar.activity()
registrar.addRequestPermissionsResultListener(speechPlugin)
speechPlugin.onAttachedToEngine(registrar.context(), registrar.messenger())
}
}
private fun onAttachedToEngine(applicationContext: Context, messenger: BinaryMessenger) {
this.pluginContext = applicationContext;
channel = MethodChannel(messenger, pluginChannelName)
channel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
this.pluginContext = null;
channel?.setMethodCallHandler(null)
channel = null
}
override fun onDetachedFromActivity() {
currentActivity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
currentActivity = binding.activity
binding.addRequestPermissionsResultListener(this)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
currentActivity = binding.activity
binding.addRequestPermissionsResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
currentActivity = null
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull rawrResult: Result) {
val result = ChannelResultWrapper(rawrResult)
try {
when (call.method) {
"has_permission" -> hasPermission(result)
"initialize" -> {
var dlog = call.argument<Boolean>("debugLogging")
if (null != dlog) {
debugLogging = dlog
}
var ausOpt = call.argument<Boolean>("alwaysUseStop")
if (null != ausOpt) {
alwaysUseStop = ausOpt == true
}
var iOpt = call.argument<Boolean>("intentLookup")
if (null != iOpt) {
intentLookup = iOpt == true
}
var noBtOpt = call.argument<Boolean>("noBluetooth")
if (null != noBtOpt) {
noBluetoothOpt = noBtOpt == true
}
initialize(result)
}
"listen" -> {
var localeId = call.argument<String>("localeId")
if (null == localeId) {
localeId = defaultLanguageTag
}
var partialResults = call.argument<Boolean>("partialResults")
if (null == partialResults) {
partialResults = true
}
var onDevice = call.argument<Boolean>("onDevice")
if ( null == onDevice ) {
onDevice = false
}
val listenModeIndex = call.argument<Int>("listenMode")
if ( null == listenModeIndex ) {
result.error(SpeechToTextErrors.missingOrInvalidArg.name,
"listenMode is required", null)
return
}
startListening(result, localeId, partialResults, listenModeIndex, onDevice )
}
"stop" -> stopListening(result)
"cancel" -> cancelListening(result)
"locales" -> locales(result)
else -> result.notImplemented()
}
} catch (exc: Exception) {
Log.e(logTag, "Unexpected exception", exc)
result.error(SpeechToTextErrors.unknown.name,
"Unexpected exception", exc.localizedMessage)
}
}
private fun hasPermission(result: Result) {
if (sdkVersionTooLow()) {
result.success(false)
return
}
debugLog("Start has_permission")
val localContext = pluginContext
if (localContext != null) {
val hasPerm = ContextCompat.checkSelfPermission(localContext,
Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
result.success(hasPerm)
}
}
private fun initialize(result: Result) {
if (sdkVersionTooLow()) {
result.success(false)
return
}
recognizerStops = Build.VERSION.SDK_INT != brokenStopSdk || alwaysUseStop
debugLog("Start initialize")
if (null != activeResult) {
result.error(SpeechToTextErrors.multipleRequests.name,
"Only one initialize at a time", null)
return
}
activeResult = result
initializeIfPermitted(pluginContext)
}
private fun sdkVersionTooLow(): Boolean {
if (Build.VERSION.SDK_INT < minSdkForSpeechSupport) {
return true;
}
return false;
}
private fun isNotInitialized(): Boolean {
return !initializedSuccessfully
}
private fun isListening(): Boolean {
return listening
}
private fun isNotListening(): Boolean {
return !listening
}
private fun startListening(result: Result, languageTag: String, partialResults: Boolean,
listenModeIndex: Int, onDevice: Boolean) {
if (sdkVersionTooLow() || isNotInitialized() || isListening()) {
result.success(false)
return
}
resultSent = false
createRecognizer(onDevice)
minRms = 1000.0F
maxRms = -100.0F
debugLog("Start listening")
var listenMode = ListenMode.deviceDefault
if ( listenModeIndex == ListenMode.dictation.ordinal) {
listenMode = ListenMode.dictation
}
optionallyStartBluetooth()
setupRecognizerIntent(languageTag, partialResults, listenMode, onDevice )
handler.post {
run {
speechRecognizer?.startListening(recognizerIntent)
}
}
speechStartTime = System.currentTimeMillis()
notifyListening(isRecording = true)
result.success(true)
debugLog("Start listening done")
}
private fun optionallyStartBluetooth() {
if ( bluetoothDisabled ) return
val context = pluginContext
val lbt = bluetoothAdapter
val lpaired = pairedDevices
val lhead = bluetoothHeadset
if (null != lbt && null!= lhead && null != lpaired && lbt.isEnabled) {
for (tryDevice in lpaired) {
//This loop tries to start VoiceRecognition mode on every paired device until it finds one that works(which will be the currently in use bluetooth headset)
if (lhead.startVoiceRecognition(tryDevice)) {
debugLog("Starting bluetooth voice recognition")
activeBluetooth = tryDevice;
break
}
}
}
}
private fun stopListening(result: Result) {
if (sdkVersionTooLow() || isNotInitialized() || isNotListening()) {
result.success(false)
return
}
debugLog("Stop listening")
handler.post {
run {
speechRecognizer?.stopListening()
}
}
if ( !recognizerStops ) {
destroyRecognizer()
}
notifyListening(isRecording = false)
result.success(true)
debugLog("Stop listening done")
}
private fun cancelListening(result: Result) {
if (sdkVersionTooLow() || isNotInitialized() || isNotListening()) {
result.success(false)
return
}
debugLog("Cancel listening")
handler.post {
run {
speechRecognizer?.cancel()
}
}
if ( !recognizerStops ) {
destroyRecognizer()
}
notifyListening(isRecording = false)
result.success(true)
debugLog("Cancel listening done")
}
private fun locales(result: Result) {
if (sdkVersionTooLow()) {
result.success(false)
return
}
var hasPermission = ContextCompat.checkSelfPermission(pluginContext!!,
Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (Build.VERSION.SDK_INT >= 33 && hasPermission) {
if ( SpeechRecognizer.isOnDeviceRecognitionAvailable(pluginContext!!)) {
// after much experimentation this was the only working iteration of the
// checkRecognitionSupport that works.
var recognizer = createOnDeviceSpeechRecognizer(pluginContext!!)
var recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
// var recognizer = createSpeechRecognizer(pluginContext!!)
// var recognizerIntent = Intent(RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS)
recognizer?.checkRecognitionSupport(recognizerIntent, Executors.newSingleThreadExecutor(),
object : RecognitionSupportCallback {
override fun onSupportResult(recognitionSupport: RecognitionSupport) {
var details = LanguageDetailsChecker( result, debugLogging )
details.createResponse(recognitionSupport.supportedOnDeviceLanguages )
recognizer?.destroy()
}
override fun onError(error: Int) {
debugLog("error from checkRecognitionSupport: " + error)
recognizer?.destroy()
}
})
}
} else {
var detailsIntent = RecognizerIntent.getVoiceDetailsIntent(pluginContext)
if (null == detailsIntent) {
detailsIntent = Intent(RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS)
detailsIntent.setPackage("com.google.android.googlequicksearchbox")
}
pluginContext?.sendOrderedBroadcast(
detailsIntent, null, LanguageDetailsChecker(result, debugLogging),
null, Activity.RESULT_OK, null, null)
}
}
private fun notifyListening(isRecording: Boolean ) {
if ( listening == isRecording ) return;
listening = isRecording
val status = when (isRecording) {
true -> SpeechToTextStatus.listening.name
false -> SpeechToTextStatus.notListening.name
}
debugLog("Notify status:" + status)
channel?.invokeMethod(SpeechToTextCallbackMethods.notifyStatus.name, status)
if ( !isRecording ) {
val doneStatus = when( resultSent) {
false -> SpeechToTextStatus.doneNoResult.name
else -> SpeechToTextStatus.done.name
}
debugLog("Notify status:" + doneStatus )
optionallyStopBluetooth();
channel?.invokeMethod(SpeechToTextCallbackMethods.notifyStatus.name,
doneStatus )
}
}
private fun optionallyStopBluetooth() {
if ( bluetoothDisabled ) return
val lactive = activeBluetooth
val lbt = bluetoothHeadset
if (null != lactive && null != lbt ) {
debugLog("Stopping bluetooth voice recognition")
lbt.stopVoiceRecognition(lactive)
activeBluetooth = null
}
}
private fun updateResults(speechBundle: Bundle?, isFinal: Boolean) {
if (isDuplicateFinal( isFinal )) {
debugLog("Discarding duplicate final")
return
}
val userSaid = speechBundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
if (null != userSaid && userSaid.isNotEmpty()) {
val speechResult = JSONObject()
speechResult.put("finalResult", isFinal)
val confidence = speechBundle.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES)
val alternates = JSONArray()
for (resultIndex in 0..userSaid.size - 1) {
val speechWords = JSONObject()
speechWords.put("recognizedWords", userSaid[resultIndex])
if (null != confidence && confidence.size >= userSaid.size) {
speechWords.put("confidence", confidence[resultIndex])
} else {
speechWords.put("confidence", missingConfidence)
}
alternates.put(speechWords)
}
speechResult.put("alternates", alternates)
val jsonResult = speechResult.toString()
debugLog("Calling results callback")
resultSent = true
channel?.invokeMethod(SpeechToTextCallbackMethods.textRecognition.name,
jsonResult)
} else {
debugLog("Results null or empty")
}
}
private fun isDuplicateFinal( isFinal: Boolean ) : Boolean {
if ( !isFinal ) {
return false
}
val delta = System.currentTimeMillis() - lastFinalTime
lastFinalTime = System.currentTimeMillis()
return delta >= 0 && delta < 100
}
private fun initializeIfPermitted(context: Context?) {
val localContext = context
if (null == localContext) {
completeInitialize()
return
}
permissionToRecordAudio = ContextCompat.checkSelfPermission(localContext,
Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
val permissionToEnableBluetooth = ContextCompat.checkSelfPermission(localContext,
Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
bluetoothDisabled = !permissionToEnableBluetooth || noBluetoothOpt
debugLog("Checked permission")
if (!permissionToRecordAudio) {
val localActivity = currentActivity
if (null != localActivity) {
debugLog("Requesting permission")
var requiredPermissions = arrayOf(Manifest.permission.RECORD_AUDIO)
if ( !noBluetoothOpt ) {
requiredPermissions = requiredPermissions.plus(Manifest.permission.BLUETOOTH_CONNECT)
}
ActivityCompat.requestPermissions(localActivity, requiredPermissions, speechToTextPermissionCode)
} else {
debugLog("no permission, no activity, completing")
completeInitialize()
}
} else {
debugLog("has permission, completing")
completeInitialize()
}
debugLog("leaving initializeIfPermitted")
}
private fun completeInitialize() {
debugLog("completeInitialize")
if (permissionToRecordAudio) {
debugLog("Testing recognition availability")
val localContext = pluginContext
if (localContext != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!SpeechRecognizer.isRecognitionAvailable(localContext) && !SpeechRecognizer.isOnDeviceRecognitionAvailable(
localContext
)
) {
Log.e(logTag, "Speech recognition not available on this device")
activeResult?.error(
SpeechToTextErrors.recognizerNotAvailable.name,
"Speech recognition not available on this device", ""
)
activeResult = null
return
}
} else {
if (!SpeechRecognizer.isRecognitionAvailable(localContext)) {
Log.e(logTag, "Speech recognition not available on this device")
activeResult?.error(
SpeechToTextErrors.recognizerNotAvailable.name,
"Speech recognition not available on this device", ""
)
activeResult = null
return
}
}
setupBluetooth()
} else {
debugLog("null context during initialization")
activeResult?.success(false)
activeResult?.error(
SpeechToTextErrors.missingContext.name,
"context unexpectedly null, initialization failed", "")
activeResult = null
return
}
}
initializedSuccessfully = permissionToRecordAudio
debugLog("sending result")
activeResult?.success(permissionToRecordAudio)
debugLog("leaving complete")
activeResult = null
}
private fun setupBluetooth() {
if ( bluetoothDisabled ) return
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
pairedDevices = bluetoothAdapter?.getBondedDevices()
val mProfileListener: BluetoothProfile.ServiceListener = object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
bluetoothHeadset = proxy as BluetoothHeadset
debugLog("Found a headset: " + bluetoothHeadset.toString())
}
}
override fun onServiceDisconnected(profile: Int) {
if (profile == BluetoothProfile.HEADSET) {
debugLog("Clearing headset: ")
bluetoothHeadset = null
}
}
}
bluetoothAdapter?.getProfileProxy(pluginContext, mProfileListener, BluetoothProfile.HEADSET)
}
private fun Context.findComponentName(): ComponentName? {
val list: List<ResolveInfo> = packageManager.queryIntentServices(Intent(RecognitionService.SERVICE_INTERFACE), 0)
debugLog("RecognitionService, found: ${list.size}")
list.forEach() { it.serviceInfo?.let { it1 -> debugLog("RecognitionService: packageName: ${it1.packageName}, name: ${it1.name}") } }
return list.firstOrNull()?.serviceInfo?.let { ComponentName(it.packageName, it.name) }
}
private fun createRecognizer(onDevice: Boolean) {
if ( null != speechRecognizer && onDevice == lastOnDevice ) {
return
}
lastOnDevice = onDevice
speechRecognizer?.destroy()
speechRecognizer = null
handler.post {
run {
debugLog("Creating recognizer")
if (intentLookup) {
speechRecognizer = createSpeechRecognizer(
pluginContext,
pluginContext?.findComponentName()
).apply {
debugLog("Setting listener after intent lookup")
setRecognitionListener(this@SpeechToTextPlugin)
}
} else {
var supportsLocal = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && onDevice) {
supportsLocal = SpeechRecognizer.isOnDeviceRecognitionAvailable(pluginContext!!)
if (supportsLocal ) {
speechRecognizer = createOnDeviceSpeechRecognizer(pluginContext!!).apply {
debugLog("Setting on device listener")
setRecognitionListener(this@SpeechToTextPlugin)
}
}
}
if ( null == speechRecognizer) {
speechRecognizer = createSpeechRecognizer(pluginContext).apply {
debugLog("Setting default listener")
setRecognitionListener(this@SpeechToTextPlugin)
}
}
}
if (null == speechRecognizer) {
Log.e(logTag, "Speech recognizer null")
activeResult?.error(
SpeechToTextErrors.recognizerNotAvailable.name,
"Speech recognizer null", ""
)
activeResult = null
}
}
}
debugLog("before setup intent")
setupRecognizerIntent(defaultLanguageTag, true, ListenMode.deviceDefault, false )
debugLog("after setup intent")
}
private fun setupRecognizerIntent(languageTag: String, partialResults: Boolean, listenMode: ListenMode, onDevice: Boolean ) {
debugLog("setupRecognizerIntent")
if (previousRecognizerLang == null ||
previousRecognizerLang != languageTag ||
partialResults != previousPartialResults || previousListenMode != listenMode ) {
previousRecognizerLang = languageTag;
previousPartialResults = partialResults
previousListenMode = listenMode
handler.post {
run {
recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
debugLog("In RecognizerIntent apply")
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
debugLog("put model")
val localContext = pluginContext
if (null != localContext) {
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE,
localContext.applicationInfo.packageName)
}
debugLog("put package")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, partialResults)
debugLog("put partial")
if (languageTag != Locale.getDefault().toLanguageTag()) {
putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageTag);
debugLog("put languageTag")
}
if ( onDevice ) {
putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, onDevice );
}
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS,10)
}
}
}
}
}
private fun destroyRecognizer() {
handler.postDelayed( {
run {
debugLog("Recognizer destroy")
speechRecognizer?.destroy();
speechRecognizer = null;
}
}, 50 )
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean {
when (requestCode) {
speechToTextPermissionCode -> {
permissionToRecordAudio = grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
bluetoothDisabled = (grantResults.isEmpty() || grantResults.size == 1 ||
grantResults[1] != PackageManager.PERMISSION_GRANTED) ||
noBluetoothOpt
completeInitialize()
return true
}
}
return false
}
override fun onPartialResults(results: Bundle?) = updateResults(results, false)
override fun onResults(results: Bundle?) = updateResults(results, true)
override fun onEndOfSpeech() = notifyListening(isRecording = false)
override fun onError(errorCode: Int) {
val delta = System.currentTimeMillis() - speechStartTime
var errorReturn = errorCode
if ( SpeechRecognizer.ERROR_NO_MATCH == errorCode && maxRms < speechThresholdRms ) {
errorReturn = SpeechRecognizer.ERROR_SPEECH_TIMEOUT
}
debugLog( "Error $errorCode after start at $delta $minRms / $maxRms")
val errorMsg = when (errorReturn) {
SpeechRecognizer.ERROR_AUDIO -> "error_audio_error"
SpeechRecognizer.ERROR_CLIENT -> "error_client"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "error_permission"
SpeechRecognizer.ERROR_NETWORK -> "error_network"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "error_network_timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "error_no_match"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "error_busy"
SpeechRecognizer.ERROR_SERVER -> "error_server"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "error_speech_timeout"
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "error_language_not_supported"
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "error_language_unavailable"
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "error_server_disconnected"
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "error_too_many_requests"
else -> "error_unknown ($errorCode)"
}
sendError(errorMsg)
if ( isListening()) {
notifyListening(false)
}
}
private fun debugLog( msg: String ) {
if ( debugLogging ) {
Log.d( logTag, msg )
}
}
private fun sendError(errorMsg: String) {
val speechError = JSONObject()
speechError.put("errorMsg", errorMsg)
speechError.put("permanent", true)
handler.post {
run {
channel?.invokeMethod(SpeechToTextCallbackMethods.notifyError.name, speechError.toString())
}
}
}
override fun onRmsChanged(rmsdB: Float) {
if ( rmsdB < minRms ) {
minRms = rmsdB
}
if ( rmsdB > maxRms ) {
maxRms = rmsdB
}
debugLog("rmsDB $minRms / $maxRms")
handler.post {
run {
channel?.invokeMethod(SpeechToTextCallbackMethods.soundLevelChange.name, rmsdB)
}
}
}
override fun onReadyForSpeech(p0: Bundle?) {}
override fun onBufferReceived(p0: ByteArray?) {}
override fun onEvent(p0: Int, p1: Bundle?) {}
override fun onBeginningOfSpeech() {}
}
// See https://stackoverflow.com/questions/10538791/how-to-set-the-language-in-speech-recognition-on-android/10548680#10548680
class LanguageDetailsChecker(flutterResult: Result, logging: Boolean ) : BroadcastReceiver() {
private val logTag = "SpeechToTextPlugin"
private val result: Result = flutterResult
private val debugLogging: Boolean = logging
private var supportedLanguages: List<String>? = null
private var languagePreference: String? = null
override fun onReceive(context: Context, intent: Intent) {
debugLog( "Received extra language broadcast" )
val results = getResultExtras(true)
if (results.containsKey(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE)) {
languagePreference = results.getString(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE)
}
if (results.containsKey(RecognizerIntent.EXTRA_SUPPORTED_LANGUAGES)) {
debugLog( "Extra supported languages" )
supportedLanguages = results.getStringArrayList(
RecognizerIntent.EXTRA_SUPPORTED_LANGUAGES)
createResponse(supportedLanguages)
}
else {
debugLog( "No extra supported languages" )
createResponse( ArrayList<String>())
}
}
public fun createResponse(supportedLanguages: List<String>?) {
val currentLocale = Locale.getDefault()
val localeNames = ArrayList<String>()
localeNames.add(buildIdNameForLocale(currentLocale))
if (null != supportedLanguages) {
for (lang in supportedLanguages) {
if (currentLocale.toLanguageTag() == lang) {
continue
}
val locale = Locale.forLanguageTag(lang)
localeNames.add(buildIdNameForLocale(locale))
}
}
result.success(localeNames)
}
private fun buildIdNameForLocale(locale: Locale): String {
val name = locale.displayName.replace(':', ' ')
return "${locale.language}_${locale.country}:$name"
}
private fun debugLog( msg: String ) {
if ( debugLogging ) {
Log.d( logTag, msg )
}
}
}
private class ChannelResultWrapper(result: Result) : Result {
// Caller handler
val handler: Handler = Handler(Looper.getMainLooper())
val result: Result = result
// make sure to respond in the caller thread
override fun success(results: Any?) {
handler.post {
run {
result.success(results);
}
}
}
override fun error(errorCode: String, errorMessage: String?, data: Any?) {
handler.post {
run {
result.error(errorCode, errorMessage, data);
}
}
}
override fun notImplemented() {
handler.post {
run {
result.notImplemented();
}
}
}
}

3
local_plugins/volcano_speech/android/build.gradle.kts

@ -60,5 +60,6 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
dependencies { dependencies {
// 添加火山引擎语音合成SDK // 添加火山引擎语音合成SDK
implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") // implementation("com.bytedance.speechengine:speechengine_tob:0.0.5")
implementation("com.bytedance.speechengine:speechengine_asr_tob:1.1.7")
} }

559
local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt

@ -7,9 +7,11 @@ import com.bytedance.speech.speechengine.SpeechEngine
import com.bytedance.speech.speechengine.SpeechEngineDefines import com.bytedance.speech.speechengine.SpeechEngineDefines
import com.bytedance.speech.speechengine.SpeechEngineGenerator import com.bytedance.speech.speechengine.SpeechEngineGenerator
import com.yunqiinnovation.volcano_speech.utils.FileLogger import com.yunqiinnovation.volcano_speech.utils.FileLogger
import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONArray
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** /**
* 火山语音识别帮助类 (大模型版本) * 火山语音识别帮助类 (大模型版本)
@ -24,34 +26,56 @@ class VolcanoAsrHelper(private val context: Context) {
private var isInitialized = false private var isInitialized = false
// 当前回调 // 当前回调
private var currentAsrCallback: ASRCallback? = null private var recognizeCallback: RecognizeCallback? = null
private var currentContinuousCallback: ASRContinuousCallback? = null private var continuousCallback: ContinuousRecognizeCallback? = null
// 识别状态 // 识别状态
private var isContinuousRecognitionActive = false private var isRecognizing = false
private var isEngineStarted = false
private var isContinuousMode = false
// 配置参数 // 配置参数
private var language = "zh-CN" private var language = "zh-CN"
private var enableVolume = false private var recorderType = SpeechEngineDefines.RECORDER_TYPE_RECORDER
private var showUtterances = false
// 音频参数
private var sampleRate = 16000
private var channels = 1
private var needResample = false
/**
* ASR识别状态
*/
enum class AsrState {
IDLE, // 空闲状态
INITIALIZING, // 初始化中
READY, // 就绪(已初始化)
RECOGNIZING, // 识别中
ERROR // 错误状态
}
/**
* 当前ASR状态
*/
var state: AsrState = AsrState.IDLE
private set
/** /**
* ASR一次性识别回调 * 一次性识别回调接口
*/ */
interface ASRCallback { interface RecognizeCallback {
fun onSuccess(text: String) fun onResult(text: String, detectedLanguage: String)
fun onError(error: String) fun onError(error: String)
} }
/** /**
* ASR连续识别回调 * 连续识别回调接口
*/ */
interface ASRContinuousCallback { interface ContinuousRecognizeCallback {
fun onResult(text: String) fun onResult(text: String, detectedLanguage: String)
fun onRecognizing(text: String) fun onRecognizing(recognizing: String, detectedLanguage: String)
fun onSessionStarted() fun onSessionStarted()
fun onSessionStopped() fun onSessionStopped()
fun onVolumeChanged(volume: Int)
fun onError(error: String) fun onError(error: String)
} }
@ -64,13 +88,13 @@ class VolcanoAsrHelper(private val context: Context) {
return true return true
} }
state = AsrState.INITIALIZING
try { try {
// 准备环境 // 准备环境
try { try {
SpeechEngineGenerator.PrepareEnvironment(context, null) SpeechEngineGenerator.PrepareEnvironment(context, null)
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化")
// 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类
} }
// 创建引擎 // 创建引擎
@ -79,6 +103,7 @@ class VolcanoAsrHelper(private val context: Context) {
if (engineHandler == -1L) { if (engineHandler == -1L) {
FileLogger.e(TAG, "创建引擎失败") FileLogger.e(TAG, "创建引擎失败")
state = AsrState.ERROR
return false return false
} }
@ -91,7 +116,7 @@ class VolcanoAsrHelper(private val context: Context) {
// 设置日志级别 // 设置日志级别
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN)
// 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) // 设置用户ID和设备ID
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id")
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id")
@ -113,21 +138,46 @@ class VolcanoAsrHelper(private val context: Context) {
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_CONN_TIMEOUT_INT, 12000) engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_CONN_TIMEOUT_INT, 12000)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RECV_TIMEOUT_INT, 8000) engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RECV_TIMEOUT_INT, 8000)
// 设置音频来源为录音机 // 默认设置音频来源为录音机
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, SpeechEngineDefines.RECORDER_TYPE_RECORDER) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, recorderType)
// 设置最大录音时长 (默认60秒) // 设置最大录音时长 (默认60秒)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_VAD_MAX_SPEECH_DURATION_INT, 60000) engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_VAD_MAX_SPEECH_DURATION_INT, 60000)
// 设置回声消除 (用于ASR识别时不会收到TTS的声音) // 设置VAD静音判停时间,用户说话后停顿多久视为说话结束
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_VAD_END_SILENCE_TIME_INT, 600)
// 启用utterances返回
// engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_UTTERANCES_BOOL, true)
// 设置VAD头部静音时间,用于过滤开始的杂音
// engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_VAD_START_SILENCE_TIME_INT, 500)
// 设置回声消除
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_PRESET_INT, SpeechEngineDefines.RECORDER_PRESET_VOICE_COMMUNICATION) engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_PRESET_INT, SpeechEngineDefines.RECORDER_PRESET_VOICE_COMMUNICATION)
// 设置音频参数
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_SAMPLE_RATE_INT, sampleRate)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CHANNEL_NUM_INT, channels)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_UP_CHANNEL_NUM_INT, channels)
// 启用语言检测
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_LANG_BOOL, true)
// 启用标点符号返回(默认启用)
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_NLU_PUNC_BOOL, true)
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_ENABLE_DDC_BOOL, true);
// 使用增量返回模式,避免内存增长
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RESULT_TYPE_STRING,
SpeechEngineDefines.ASR_RESULT_TYPE_SINGLE)
// 初始化引擎 // 初始化引擎
val result = engine?.initEngine(engineHandler) val result = engine?.initEngine(engineHandler)
isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR
if (isInitialized) { if (isInitialized) {
FileLogger.i(TAG, "引擎初始化成功") FileLogger.i(TAG, "引擎初始化成功")
state = AsrState.READY
// 设置回调监听 // 设置回调监听
engine?.setListener(object : SpeechEngine.SpeechListener { engine?.setListener(object : SpeechEngine.SpeechListener {
@ -139,269 +189,396 @@ class VolcanoAsrHelper(private val context: Context) {
return true return true
} else { } else {
// 错误码说明: FileLogger.e(TAG, "引擎初始化失败: $result")
// -1:通用错误 state = AsrState.ERROR
// -2:无效参数
// -3:无效状态
// -4:无效引擎
// -5:操作失败
// -6:缺少配置
// -7:参数无效
// -8:超时
// -9:资源不足
FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}")
return false return false
} }
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "初始化异常: ${e.message}", e) FileLogger.e(TAG, "初始化异常: ${e.message}", e)
state = AsrState.ERROR
return false return false
} }
} }
/** /**
* 设置语言 * 设置音频输入类型
*/ */
fun setLanguage(language: String): Boolean { fun setAudioInputType(type: String): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
if (isEngineStarted) {
FileLogger.e(TAG, "引擎已启动,无法更改配置")
return false
}
try { try {
this.language = language when (type) {
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language) "mic" -> recorderType = SpeechEngineDefines.RECORDER_TYPE_RECORDER
"stream" -> recorderType = SpeechEngineDefines.RECORDER_TYPE_STREAM
else -> {
FileLogger.e(TAG, "不支持的音频输入类型: $type")
return false
}
}
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, recorderType)
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置语言失败: ${e.message}", e) FileLogger.e(TAG, "设置音频输入类型失败: ${e.message}", e)
return false return false
} }
} }
/** /**
* 设置热词 * 设置音频参数
*/ */
fun setHotWords(hotWordsId: String): Boolean { fun setAudioParams(sampleRate: Int, channels: Int): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
if (isEngineStarted) {
FileLogger.e(TAG, "引擎已启动,无法更改配置")
return false
}
try { try {
if (hotWordsId.isNotEmpty()) { this.sampleRate = sampleRate
// 大模型ASR需要通过请求参数设置热词 this.channels = channels
val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}"
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) // 设置采样率和通道数
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_SAMPLE_RATE_INT, sampleRate)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CHANNEL_NUM_INT, channels)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_UP_CHANNEL_NUM_INT, channels)
// 配置重采样参数
needResample = sampleRate != 16000 || channels != 1
if (needResample) {
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_RESAMPLER_BOOL, true)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CUSTOM_SAMPLE_RATE_INT, sampleRate)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_CUSTOM_CHANNEL_INT, channels)
} else {
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_RESAMPLER_BOOL, false)
} }
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置热词失败: ${e.message}", e) FileLogger.e(TAG, "设置音频参数失败: ${e.message}", e)
return false return false
} }
} }
/** /**
* 设置是否返回音量 * 设置语言
*/ */
fun setEnableVolume(enable: Boolean): Boolean { fun setLanguage(language: String): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
try { try {
this.enableVolume = enable this.language = language
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_GET_VOLUME_BOOL, enable) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language)
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置音量返回失败: ${e.message}", e) FileLogger.e(TAG, "设置语言失败: ${e.message}", e)
return false return false
} }
} }
/** /**
* 设置是否显示语音停顿、分句、分词信息 * 设置热词
*/ */
fun setShowUtterances(show: Boolean): Boolean { fun setHotWords(hotWordsId: String): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
try { try {
this.showUtterances = show if (hotWordsId.isNotEmpty()) {
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_UTTER_BOOL, show) // 大模型ASR需要通过请求参数设置热词
val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}"
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams)
}
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置语音信息显示失败: ${e.message}", e) FileLogger.e(TAG, "设置热词失败: ${e.message}", e)
return false return false
} }
} }
/** /**
* 设置VAD切句参数 * 一次性识别(异步)
*/ */
fun setVadParams(forceToSpeechTime: Int, endWindowSize: Int): Boolean { fun recognizeOnce(callback: RecognizeCallback): Boolean {
FileLogger.i(TAG, "开始一次性识别")
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
if (isRecognizing) {
FileLogger.e(TAG, "当前已在识别中")
return false
}
try { try {
val reqParams = "{\"force_to_speech_time\":$forceToSpeechTime, \"end_window_size\":$endWindowSize}" this.recognizeCallback = callback
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) this.continuousCallback = null
this.isContinuousMode = false
// 停止当前引擎
engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "")
// 开启自动判停,达到静音时间后自动停止识别
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_AUTO_STOP_BOOL, true)
// 启动引擎开始识别
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "启动识别失败: $ret")
return false
}
isRecognizing = true
isEngineStarted = true
state = AsrState.RECOGNIZING
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置VAD参数失败: ${e.message}", e) FileLogger.e(TAG, "识别异常: ${e.message}", e)
return false return false
} }
} }
/** /**
* 设置纠错词表 * 停止一次性识别
*/ */
fun setCorrectWords(correctWordsJson: String): Boolean { fun stopRecognize(): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
if (!isEngineStarted) {
FileLogger.e(TAG, "引擎未启动")
return false
}
try { try {
val reqParams = "{\"context\": \"{\\\"correct_words\\\": $correctWordsJson}\"}" if (recorderType == SpeechEngineDefines.RECORDER_TYPE_RECORDER) {
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) // 麦克风输入-告知引擎音频输入完成
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_FINISH_TALKING, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "停止识别失败: $ret")
return false
}
} else {
// 流式输入-需要完全停止引擎
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "停止识别失败: $ret")
return false
}
isRecognizing = false
isEngineStarted = false
state = AsrState.READY
}
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置纠错词表失败: ${e.message}", e) FileLogger.e(TAG, "停止识别异常: ${e.message}", e)
return false return false
} }
} }
/** /**
* 一次性识别 * 开始连续识别
*/ */
fun recognizeOnce(callback: ASRCallback): Boolean { fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean {
FileLogger.i(TAG, "开始连续识别")
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
if (isContinuousRecognitionActive) { if (isRecognizing) {
FileLogger.e(TAG, "当前正在连续识别中") FileLogger.e(TAG, "当前已在识别中")
return false return false
} }
try { try {
this.currentAsrCallback = callback this.continuousCallback = callback
this.recognizeCallback = null
this.isContinuousMode = true
// 停止当前引擎 // 停止当前引擎
engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "")
// 关闭自动判停,持续接收语音直到主动关闭
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_AUTO_STOP_BOOL, false)
// 启动引擎开始识别 // 启动引擎开始识别
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "启动识别失败: $ret") FileLogger.e(TAG, "启动连续识别失败: $ret")
return false return false
} }
isRecognizing = true
isEngineStarted = true
state = AsrState.RECOGNIZING
FileLogger.i(TAG, "开始连续识别")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "识别异常: ${e.message}", e) FileLogger.e(TAG, "连续识别异常: ${e.message}", e)
return false return false
} }
} }
/** /**
* 停止一次性识别 * 停止连续识别
*/ */
fun stopRecognize(): Boolean { fun stopContinuousRecognition(): Boolean {
if (!isInitialized) { if (!isInitialized || !isRecognizing) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化或未在识别中")
return false return false
} }
try { try {
// 告知引擎音频输入完成 // 停止引擎
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_FINISH_TALKING, "") val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "停止识别失败: $ret") FileLogger.e(TAG, "停止连续识别失败: $ret")
return false return false
} }
isRecognizing = false
isEngineStarted = false
state = AsrState.READY
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "停止识别异常: ${e.message}", e) FileLogger.e(TAG, "停止连续识别异常: ${e.message}", e)
return false return false
} }
} }
/** /**
* 开始连续识别 * 发送PCM音频数据到引擎
* 注意:必须在setAudioInputType("stream")后,才能调用此方法
*/ */
fun startContinuousRecognition(callback: ASRContinuousCallback): Boolean { fun feedAudio(data: ByteArray): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
if (isContinuousRecognitionActive) { if (!isEngineStarted) {
FileLogger.e(TAG, "当前已经在连续识别中") FileLogger.e(TAG, "引擎未启动")
return false
}
if (recorderType != SpeechEngineDefines.RECORDER_TYPE_STREAM) {
FileLogger.e(TAG, "当前录音类型不是stream模式,无法使用此方法")
return false return false
} }
try { try {
this.currentContinuousCallback = callback // 调用引擎feedAudio方法
val ret = engine?.feedAudio(engineHandler, data, data.size)
// 停止当前引擎
engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "")
// 启动引擎开始识别
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "启动连续识别失败: $ret") FileLogger.e(TAG, "发送音频数据失败: $ret")
return false return false
} }
isContinuousRecognitionActive = true
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "连续识别异常: ${e.message}", e) FileLogger.e(TAG, "发送音频数据异常: ${e.message}", e)
return false return false
} }
} }
/** /**
* 停止连续识别 * 发送任意格式音频的最后一块数据
* 告知引擎音频输入已结束
*/ */
fun stopContinuousRecognition(): Boolean { fun feedAudioEnd(): Boolean {
if (!isInitialized || !isContinuousRecognitionActive) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化或未在连续识别中") FileLogger.e(TAG, "引擎未初始化")
return false
}
if (!isEngineStarted) {
FileLogger.e(TAG, "引擎未启动")
return false
}
if (recorderType != SpeechEngineDefines.RECORDER_TYPE_STREAM) {
FileLogger.e(TAG, "当前录音类型不是stream模式,无法使用此方法")
return false return false
} }
try { try {
// 停止引擎 // 空Buffer告知引擎输入结束
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") val ret = engine?.feedAudio(engineHandler, ByteArray(0), 0)
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "停止连续识别失败: $ret") FileLogger.e(TAG, "发送音频结束标记失败: $ret")
return false return false
} }
isContinuousRecognitionActive = false
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "停止连续识别异常: ${e.message}", e) FileLogger.e(TAG, "发送音频结束标记异常: ${e.message}", e)
return false return false
} }
} }
/** /**
* 判断是否在连续识别中 * 发送Int16数组音频数据
* 输入格式: PCM 16bit 小端 单声道或双声道
*/
fun feedAudioInt16(audioData: ShortArray): Boolean {
// 将Int16数组转换为字节数组
val byteBuffer = ByteBuffer.allocate(audioData.size * 2)
byteBuffer.order(ByteOrder.LITTLE_ENDIAN)
for (sample in audioData) {
byteBuffer.putShort(sample)
}
return feedAudio(byteBuffer.array())
}
/**
* 判断引擎是否启动
*/
fun isEngineStarted(): Boolean {
return isEngineStarted
}
/**
* 判断是否在识别中
*/ */
fun isContinuousRecognitionActive(): Boolean { fun isRecognizing(): Boolean {
return isContinuousRecognitionActive return isRecognizing
} }
/** /**
@ -414,10 +591,10 @@ class VolcanoAsrHelper(private val context: Context) {
try { try {
// 停止引擎 // 停止引擎
if (isContinuousRecognitionActive) { if (isEngineStarted) {
stopContinuousRecognition()
} else {
engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "")
isRecognizing = false
isEngineStarted = false
} }
// 销毁引擎 // 销毁引擎
@ -425,6 +602,7 @@ class VolcanoAsrHelper(private val context: Context) {
engineHandler = -1 engineHandler = -1
engine = null engine = null
isInitialized = false isInitialized = false
state = AsrState.IDLE
FileLogger.i(TAG, "引擎已释放") FileLogger.i(TAG, "引擎已释放")
} catch (e: Exception) { } catch (e: Exception) {
@ -439,101 +617,115 @@ class VolcanoAsrHelper(private val context: Context) {
when (type) { when (type) {
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> {
// 引擎启动成功 // 引擎启动成功
FileLogger.i(TAG, "引擎启动成功")
mainHandler.post { mainHandler.post {
currentContinuousCallback?.onSessionStarted() continuousCallback?.onSessionStarted()
} }
} }
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> {
// 引擎停止 // 引擎停止
isContinuousRecognitionActive = false FileLogger.i(TAG, "引擎停止")
isRecognizing = false
isEngineStarted = false
state = AsrState.READY
mainHandler.post { mainHandler.post {
currentContinuousCallback?.onSessionStopped() continuousCallback?.onSessionStopped()
} }
} }
SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> { SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> {
// 中间识别结果 // 中间识别结果
FileLogger.i(TAG, "中间识别结果: $data")
try { try {
val json = JSONObject(data) val json = JSONObject(data)
if (!json.has("result")) { if (!json.has("result")) {
FileLogger.w(TAG, "中间结果没有 result 字段: $data")
return return
} }
// if (json.has("definite")) {
// FileLogger.i(TAG, "utterances: ${json.getString("utterances")}")
// }
// 从多种可能的格式中获取文本
val text = extractTextFromResult(json)
val resultArray = json.getJSONArray("result") // 获取语言信息,如果没有则使用默认语言
if (resultArray.length() > 0) { val detectedLanguage = json.optString("lang", language)
val result = resultArray.getJSONObject(0)
val text = result.optString("text", "") if (text.isNotEmpty()) {
mainHandler.post {
if (text.isNotEmpty()) { FileLogger.i(TAG, "提取到中间文本: $text")
mainHandler.post { // 中间结果
currentContinuousCallback?.onRecognizing(text) continuousCallback?.onRecognizing(text, detectedLanguage)
}
} }
} }
} catch (e: JSONException) { } catch (e: Exception) {
FileLogger.e(TAG, "解析中间识别结果异常: ${e.message}", e) FileLogger.e(TAG, "解析中间识别结果异常: ${e}")
} }
} }
SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> { SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> {
FileLogger.i(TAG, "最终识别结果: $data")
// 最终识别结果 // 最终识别结果
try { try {
val json = JSONObject(data) val json = JSONObject(data)
if (!json.has("result")) { if (!json.has("result")) {
FileLogger.w(TAG, "最终结果没有 result 字段: $data")
return return
} }
val resultArray = json.getJSONArray("result") // 从多种可能的格式中获取文本
if (resultArray.length() > 0) { val text = extractTextFromResult(json)
val result = resultArray.getJSONObject(0)
val text = result.optString("text", "")
// 获取语言信息,如果没有则使用默认语言
if (text.isNotEmpty()) { val detectedLanguage = json.optString("lang", language)
mainHandler.post {
if (isContinuousRecognitionActive) { if (text.isNotEmpty()) {
currentContinuousCallback?.onResult(text) mainHandler.post {
} else { if (isContinuousMode) {
currentAsrCallback?.onSuccess(text) FileLogger.i(TAG, "发送最终识别结果: $text (连续模式)")
} continuousCallback?.onResult(text, detectedLanguage)
// 在连续模式下,不需要手动触发下一轮识别,引擎会自动继续监听
// 但如果需要明确标记新一轮开始,可以在此处添加代码
} else {
FileLogger.i(TAG, "发送最终识别结果: $text (单次模式)")
recognizeCallback?.onResult(text, detectedLanguage)
} }
} }
} }
} catch (e: JSONException) { } catch (e: Exception) {
FileLogger.e(TAG, "解析最终识别结果异常: ${e.message}", e) val errMsg = "解析最终识别结果异常: ${e.message}"
FileLogger.e(TAG, errMsg)
FileLogger.e(TAG, "原始数据: $data")
FileLogger.e(TAG, "详细异常信息", e)
} }
} }
SpeechEngineDefines.MESSAGE_TYPE_VOLUME_LEVEL -> { SpeechEngineDefines.MESSAGE_TYPE_VOLUME_LEVEL -> {
// 音量回调 // 忽略音量回调
if (enableVolume && isContinuousRecognitionActive) {
try {
val volume = (data.toFloat() * 100).toInt()
mainHandler.post {
currentContinuousCallback?.onVolumeChanged(volume)
}
} catch (e: Exception) {
FileLogger.e(TAG, "解析音量数据异常: ${e.message}", e)
}
}
} }
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> {
// 错误信息 // 错误信息
try { try {
val json = JSONObject(data) val json = JSONObject(data)
val errCode = json.optInt("err_code", -1)
val errMsg = json.optString("err_msg", "未知错误") val errMsg = json.optString("err_msg", "未知错误")
val errCode = json.optInt("err_code", -1)
FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") FileLogger.e(TAG, "引擎错误: code=$errCode, msg=$errMsg")
state = AsrState.ERROR
mainHandler.post { mainHandler.post {
if (isContinuousRecognitionActive) { if (isContinuousMode) {
currentContinuousCallback?.onError(errMsg) continuousCallback?.onError(errMsg)
isContinuousRecognitionActive = false
} else { } else {
currentAsrCallback?.onError(errMsg) recognizeCallback?.onError(errMsg)
} }
isRecognizing = false
isEngineStarted = false
} }
} catch (e: JSONException) { } catch (e: JSONException) {
FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e)
@ -541,22 +733,51 @@ class VolcanoAsrHelper(private val context: Context) {
} }
} }
} }
/** /**
* 获取错误描述 * 从多种可能的JSON格式中提取文本
*/ */
private fun getErrorDescription(errorCode: Int?): String { private fun extractTextFromResult(json: JSONObject): String {
return when (errorCode) { try {
-1 -> "通用错误" if (!json.has("result")) {
-2 -> "无效参数,检查appId、token、resourceId等参数是否正确" return ""
-3 -> "无效状态,引擎状态异常" }
-4 -> "无效引擎句柄"
-5 -> "操作失败,可能网络连接问题" // 获取result字段(可能是JSONObject或JSONArray)
-6 -> "缺少配置参数" val resultObj = json.get("result")
-7 -> "参数设置无效,请检查appId、token等参数是否有效"
-8 -> "操作超时" // 根据result的实际类型处理
-9 -> "资源不足" val text = when (resultObj) {
else -> "未知错误: $errorCode" // 情况1: result是JSONObject,直接获取text字段
is JSONObject -> {
resultObj.optString("text", "")
}
// 情况2: result是JSONArray,获取第一个元素的text字段
is JSONArray -> {
if (resultObj.length() == 0) {
""
} else {
resultObj.getJSONObject(0).optString("text", "")
}
}
// 其他情况
else -> {
resultObj.toString()
}
}
// 过滤空结果
if (text.isEmpty()) {
return ""
}
return text
} catch (e: Exception) {
FileLogger.e(TAG, "提取文本异常: ${e.message}", e)
FileLogger.e(TAG, "详细异常信息", e)
return ""
} }
} }
} }

310
local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt

@ -29,6 +29,10 @@ class VolcanoSpeechPlugin: FlutterPlugin {
private lateinit var ttsEventChannel: EventChannel private lateinit var ttsEventChannel: EventChannel
private var ttsEventSink: EventChannel.EventSink? = null private var ttsEventSink: EventChannel.EventSink? = null
// TTS音频数据相关
private lateinit var ttsAudioDataChannel: EventChannel
private var ttsAudioDataSink: EventChannel.EventSink? = null
// TTS帮助类 // TTS帮助类
private lateinit var volcanoTtsHelper: VolcanoTtsHelper private lateinit var volcanoTtsHelper: VolcanoTtsHelper
@ -70,8 +74,50 @@ class VolcanoSpeechPlugin: FlutterPlugin {
} }
}) })
// 初始化TTS音频数据通道
ttsAudioDataChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts_audio_data")
ttsAudioDataChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
ttsAudioDataSink = events
}
override fun onCancel(arguments: Any?) {
ttsAudioDataSink = null
}
})
// 初始化TTS帮助类 // 初始化TTS帮助类
volcanoTtsHelper = VolcanoTtsHelper(context) volcanoTtsHelper = VolcanoTtsHelper(context)
// 添加TTS事件监听
volcanoTtsHelper.addListener(object : VolcanoTtsHelper.TtsEventListener {
override fun onEvent(event: VolcanoTtsHelper.TtsEvent) {
when (event.type) {
VolcanoTtsHelper.TtsEventType.STARTED -> {
sendTtsEvent(mapOf("type" to "started"))
}
VolcanoTtsHelper.TtsEventType.COMPLETED -> {
sendTtsEvent(mapOf("type" to "completed"))
}
VolcanoTtsHelper.TtsEventType.CANCELED -> {
sendTtsEvent(mapOf("type" to "canceled"))
}
VolcanoTtsHelper.TtsEventType.ERROR -> {
sendTtsEvent(mapOf(
"type" to "error",
"errorCode" to event.errorCode,
"errorMessage" to event.errorMessage
))
}
}
}
})
// 添加TTS音频数据监听
volcanoTtsHelper.addAudioDataListener(object : VolcanoTtsHelper.AudioDataListener {
override fun onAudioData(data: ByteArray) {
sendTtsAudioData(data)
}
})
// 初始化ASR帮助类 // 初始化ASR帮助类
volcanoAsrHelper = VolcanoAsrHelper(context) volcanoAsrHelper = VolcanoAsrHelper(context)
@ -79,7 +125,6 @@ class VolcanoSpeechPlugin: FlutterPlugin {
// 发送ASR事件 // 发送ASR事件
private fun sendAsrEvent(event: Map<String, Any>) { private fun sendAsrEvent(event: Map<String, Any>) {
FileLogger.d(TAG, "发送ASR事件: $event")
if (asrEventSink == null) { if (asrEventSink == null) {
FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好")
return return
@ -88,7 +133,6 @@ class VolcanoSpeechPlugin: FlutterPlugin {
mainHandler.post { mainHandler.post {
try { try {
asrEventSink?.success(event) asrEventSink?.success(event)
FileLogger.d(TAG, "ASR事件发送成功")
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "发送ASR事件失败: ${e.message}") FileLogger.e(TAG, "发送ASR事件失败: ${e.message}")
} }
@ -112,6 +156,21 @@ class VolcanoSpeechPlugin: FlutterPlugin {
} }
} }
} }
// 发送TTS音频数据
private fun sendTtsAudioData(data: ByteArray) {
if (ttsAudioDataSink == null) {
return
}
mainHandler.post {
try {
ttsAudioDataSink?.success(data)
} catch (e: Exception) {
FileLogger.e(TAG, "发送TTS音频数据失败: ${e.message}")
}
}
}
// ASR方法处理器 // ASR方法处理器
inner class AsrMethodHandler : MethodCallHandler { inner class AsrMethodHandler : MethodCallHandler {
@ -122,7 +181,7 @@ class VolcanoSpeechPlugin: FlutterPlugin {
} }
"initialize" -> { "initialize" -> {
val appId = call.argument<String>("appId") ?: "" val appId = call.argument<String>("appId") ?: ""
val apiKey = call.argument<String>("apiKey") ?: "" val apiKey = call.argument<String>("appToken") ?: ""
val resourceId = call.argument<String>("resourceId") ?: "" val resourceId = call.argument<String>("resourceId") ?: ""
if (appId.isEmpty() || apiKey.isEmpty() || resourceId.isEmpty()) { if (appId.isEmpty() || apiKey.isEmpty() || resourceId.isEmpty()) {
@ -141,35 +200,20 @@ class VolcanoSpeechPlugin: FlutterPlugin {
val hotWordsId = call.argument<String>("hotWordsId") ?: "" val hotWordsId = call.argument<String>("hotWordsId") ?: ""
result.success(volcanoAsrHelper.setHotWords(hotWordsId)) result.success(volcanoAsrHelper.setHotWords(hotWordsId))
} }
"setVadParams" -> {
val forceToSpeechTime = call.argument<Int>("forceToSpeechTime") ?: 0
val endWindowSize = call.argument<Int>("endWindowSize") ?: 800
result.success(volcanoAsrHelper.setVadParams(forceToSpeechTime, endWindowSize))
}
"setCorrectWords" -> {
val correctWordsJson = call.argument<String>("correctWordsJson") ?: "{}"
result.success(volcanoAsrHelper.setCorrectWords(correctWordsJson))
}
"setEnableVolume" -> {
val enable = call.argument<Boolean>("enable") ?: false
result.success(volcanoAsrHelper.setEnableVolume(enable))
}
"setShowUtterances" -> {
val enable = call.argument<Boolean>("enable") ?: false
result.success(volcanoAsrHelper.setShowUtterances(enable))
}
"recognizeOnce" -> { "recognizeOnce" -> {
// 旧方式,不再推荐使用
// 确保当前不在连续识别中 // 确保当前不在连续识别中
if (volcanoAsrHelper.isContinuousRecognitionActive()) { if (volcanoAsrHelper.isRecognizing()) {
result.error("ASR_BUSY", "当前正在连续识别中", null) result.error("ASR_BUSY", "当前正在识别中", null)
return return
} }
volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.ASRCallback { volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.RecognizeCallback {
override fun onSuccess(text: String) { override fun onResult(text: String, detectedLanguage: String) {
mainHandler.post { mainHandler.post {
result.success(mapOf( result.success(mapOf(
"text" to text "text" to text,
"language" to detectedLanguage
)) ))
} }
} }
@ -181,6 +225,41 @@ class VolcanoSpeechPlugin: FlutterPlugin {
} }
}) })
} }
"startOnceRecognition" -> {
// 新方式,自动返回结果
// 确保当前不在连续识别中
if (volcanoAsrHelper.isRecognizing()) {
result.error("ASR_BUSY", "当前正在识别中", null)
return
}
// 设置一次性识别,触发事件而不是直接返回结果
volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.RecognizeCallback {
override fun onResult(text: String, detectedLanguage: String) {
mainHandler.post {
// 发送最终结果事件
sendAsrEvent(mapOf(
"type" to "result",
"text" to text,
"language" to detectedLanguage
))
}
}
override fun onError(error: String) {
mainHandler.post {
// 发送错误事件
sendAsrEvent(mapOf(
"type" to "error",
"message" to error
))
}
}
})
// 返回成功启动
result.success(true)
}
"startContinuousRecognition" -> { "startContinuousRecognition" -> {
// 确保事件通道已准备好 // 确保事件通道已准备好
if (asrEventSink == null) { if (asrEventSink == null) {
@ -188,18 +267,20 @@ class VolcanoSpeechPlugin: FlutterPlugin {
return return
} }
val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ASRContinuousCallback { val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ContinuousRecognizeCallback {
override fun onResult(text: String) { override fun onResult(text: String, detectedLanguage: String) {
sendAsrEvent(mapOf( sendAsrEvent(mapOf(
"type" to "result", "type" to "result",
"text" to text "text" to text,
"language" to detectedLanguage
)) ))
} }
override fun onRecognizing(text: String) { override fun onRecognizing(text: String, detectedLanguage: String) {
sendAsrEvent(mapOf( sendAsrEvent(mapOf(
"type" to "recognizing", "type" to "recognizing",
"text" to text "text" to text,
"language" to detectedLanguage
)) ))
} }
@ -210,13 +291,7 @@ class VolcanoSpeechPlugin: FlutterPlugin {
override fun onSessionStopped() { override fun onSessionStopped() {
sendAsrEvent(mapOf("type" to "sessionStopped")) sendAsrEvent(mapOf("type" to "sessionStopped"))
} }
override fun onVolumeChanged(volume: Int) {
sendAsrEvent(mapOf(
"type" to "volumeChanged",
"volume" to volume
))
}
override fun onError(error: String) { override fun onError(error: String) {
sendAsrEvent(mapOf( sendAsrEvent(mapOf(
@ -237,7 +312,14 @@ class VolcanoSpeechPlugin: FlutterPlugin {
result.success(success) result.success(success)
} }
"isContinuousRecognitionActive" -> { "isContinuousRecognitionActive" -> {
result.success(volcanoAsrHelper.isContinuousRecognitionActive()) result.success(volcanoAsrHelper.isRecognizing())
}
"feedAudioData" -> {
val data = call.argument<ByteArray>("data") ?: ByteArray(0)
result.success(volcanoAsrHelper.feedAudio(data))
}
"feedAudioEnd" -> {
result.success(volcanoAsrHelper.feedAudioEnd())
} }
"release" -> { "release" -> {
volcanoAsrHelper.release() volcanoAsrHelper.release()
@ -259,15 +341,14 @@ class VolcanoSpeechPlugin: FlutterPlugin {
} }
"initialize" -> { "initialize" -> {
val appId = call.argument<String>("appId") ?: "" val appId = call.argument<String>("appId") ?: ""
val token = call.argument<String>("token") ?: "" val token = call.argument<String>("appToken") ?: ""
val resourceId = call.argument<String>("resourceId") ?: ""
if (appId.isEmpty() || token.isEmpty() || resourceId.isEmpty()) { if (appId.isEmpty() || token.isEmpty()) {
result.error("INVALID_ARGUMENTS", "appId、token和resourceId不能为空", null) result.error("INVALID_ARGUMENTS", "appId和token不能为空", null)
return return
} }
val success = volcanoTtsHelper.initialize(appId, token, resourceId) val success = volcanoTtsHelper.initialize(appId, token)
result.success(success) result.success(success)
} }
"setVoice" -> { "setVoice" -> {
@ -280,11 +361,7 @@ class VolcanoSpeechPlugin: FlutterPlugin {
result.success(volcanoTtsHelper.setVoice(voice)) result.success(volcanoTtsHelper.setVoice(voice))
} }
"setContinuousMode" -> { "speakOnce" -> {
val isContinuous = call.argument<Boolean>("isContinuous") ?: false
result.success(volcanoTtsHelper.setContinuousMode(isContinuous))
}
"speak" -> {
val text = call.argument<String>("text") ?: "" val text = call.argument<String>("text") ?: ""
if (text.isEmpty()) { if (text.isEmpty()) {
@ -292,48 +369,10 @@ class VolcanoSpeechPlugin: FlutterPlugin {
return return
} }
volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback { val success = volcanoTtsHelper.speakOnce(text)
override fun onStart(reqId: String) { result.success(success)
sendTtsEvent(mapOf(
"type" to "start",
"reqId" to reqId
))
}
override fun onProgress(reqId: String, progress: Double) {
sendTtsEvent(mapOf(
"type" to "progress",
"reqId" to reqId,
"progress" to progress
))
}
override fun onComplete(reqId: String) {
sendTtsEvent(mapOf(
"type" to "complete",
"reqId" to reqId
))
mainHandler.post {
result.success(true)
}
}
override fun onError(reqId: String, errorCode: Int, errorMsg: String) {
sendTtsEvent(mapOf(
"type" to "error",
"reqId" to reqId,
"errorCode" to errorCode,
"errorMsg" to errorMsg
))
mainHandler.post {
result.error("TTS_ERROR", errorMsg, null)
}
}
})
} }
"synthesisNext" -> { "speakStream" -> {
val text = call.argument<String>("text") ?: "" val text = call.argument<String>("text") ?: ""
if (text.isEmpty()) { if (text.isEmpty()) {
@ -341,90 +380,30 @@ class VolcanoSpeechPlugin: FlutterPlugin {
return return
} }
val success = volcanoTtsHelper.synthesisNext(text) val success = volcanoTtsHelper.speakStream(text)
result.success(success) result.success(success)
} }
"pause" -> { "flushStream" -> {
result.success(volcanoTtsHelper.pause()) val success = volcanoTtsHelper.flushStream()
} result.success(success)
"resume" -> {
result.success(volcanoTtsHelper.resume())
} }
"stop" -> { "stop" -> {
result.success(volcanoTtsHelper.stop()) result.success(volcanoTtsHelper.stop())
} }
"release" -> { "isSpeaking" -> {
volcanoTtsHelper.release() result.success(volcanoTtsHelper.isSpeaking())
result.success(true)
} }
// 以下方法用于与Azure Speech版本兼容 "getCurrentVoice" -> {
"setSpeechSynthesisVoice" -> { result.success(volcanoTtsHelper.getCurrentVoice())
val voiceName = call.argument<String>("voiceName") ?: ""
if (voiceName.isEmpty()) {
result.error("INVALID_ARGUMENTS", "语音名称不能为空", null)
return
}
result.success(volcanoTtsHelper.setVoice(voiceName))
} }
"speakText" -> { "enableAudioCallback" -> {
val text = call.argument<String>("text") ?: "" val enable = call.argument<Boolean>("enable") ?: false
volcanoTtsHelper.setEnableAudioCallback(enable)
if (text.isEmpty()) { result.success(true)
result.error("INVALID_ARGUMENTS", "合成文本不能为空", null)
return
}
volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback {
override fun onStart(reqId: String) {
sendTtsEvent(mapOf(
"type" to "start",
"reqId" to reqId
))
}
override fun onProgress(reqId: String, progress: Double) {
sendTtsEvent(mapOf(
"type" to "progress",
"reqId" to reqId,
"progress" to progress
))
}
override fun onComplete(reqId: String) {
sendTtsEvent(mapOf(
"type" to "complete",
"reqId" to reqId
))
mainHandler.post {
result.success(true)
}
}
override fun onError(reqId: String, errorCode: Int, errorMsg: String) {
sendTtsEvent(mapOf(
"type" to "error",
"reqId" to reqId,
"errorCode" to errorCode,
"errorMsg" to errorMsg
))
mainHandler.post {
result.error("TTS_ERROR", errorMsg, null)
}
}
})
}
"stopSpeaking" -> {
result.success(volcanoTtsHelper.stop())
}
"pauseSpeaking" -> {
result.success(volcanoTtsHelper.pause())
} }
"resumeSpeaking" -> { "release" -> {
result.success(volcanoTtsHelper.resume()) volcanoTtsHelper.dispose()
result.success(true)
} }
else -> { else -> {
result.notImplemented() result.notImplemented()
@ -438,8 +417,9 @@ class VolcanoSpeechPlugin: FlutterPlugin {
ttsChannel.setMethodCallHandler(null) ttsChannel.setMethodCallHandler(null)
asrEventChannel.setStreamHandler(null) asrEventChannel.setStreamHandler(null)
ttsEventChannel.setStreamHandler(null) ttsEventChannel.setStreamHandler(null)
ttsAudioDataChannel.setStreamHandler(null)
volcanoTtsHelper.release() volcanoTtsHelper.dispose()
volcanoAsrHelper.release() volcanoAsrHelper.release()
} }
} }

647
local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt

@ -8,9 +8,10 @@ import com.bytedance.speech.speechengine.SpeechEngineDefines
import com.bytedance.speech.speechengine.SpeechEngineGenerator import com.bytedance.speech.speechengine.SpeechEngineGenerator
import com.yunqiinnovation.volcano_speech.utils.FileLogger import com.yunqiinnovation.volcano_speech.utils.FileLogger
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.ConcurrentLinkedQueue
/** /**
* 火山语音合成帮助类 (大模型版本) * 火山语音合成服务 - 符合TtsService接口要求
*/ */
class VolcanoTtsHelper(private val context: Context) { class VolcanoTtsHelper(private val context: Context) {
private val TAG = "VolcanoTtsHelper" private val TAG = "VolcanoTtsHelper"
@ -21,44 +22,157 @@ class VolcanoTtsHelper(private val context: Context) {
private var engineHandler: Long = -1 private var engineHandler: Long = -1
private var isInitialized = false private var isInitialized = false
// 当前回调 // 语音合成状态
private var currentTtsCallback: TTSCallback? = null private var isEngineStarted = false
// 合成状态
private var isPlaying = false private var isPlaying = false
private var currentReqId = "" private var currentReqId = ""
// 配置参数 // 配置参数
private var voice = "zh_male_M392_conversation_wvae_bigtts" private var voice = "zh_male_M392_conversation_wvae_bigtts"
private var ttsText = ""
private var isContinuous = false // 文本队列管理
private val pendingTexts = ConcurrentLinkedQueue<String>()
private var isProcessingQueue = false
// 连续合成相关
private var currentText = ""
private var isContinuousMode = false
private var synthesisFromPlayer = false
private var isSynthesisInProgress = false
// 错误处理
private val MAX_RETRY_COUNT = 3
private var retryCount = MAX_RETRY_COUNT
// 音频数据相关
private var enableAudioCallback = false
private val audioDataListeners = mutableListOf<AudioDataListener>()
// 事件监听
private val listeners = mutableListOf<TtsEventListener>()
/** /**
* TTS合成回调接口 * TTS事件类型
*/ */
interface TTSCallback { enum class TtsEventType {
fun onStart(reqId: String) STARTED,
fun onProgress(reqId: String, progress: Double) COMPLETED,
fun onComplete(reqId: String) CANCELED,
fun onError(reqId: String, errorCode: Int, errorMsg: String) ERROR
} }
/**
* TTS事件数据类
*/
data class TtsEvent(
val type: TtsEventType,
val errorCode: Int = 0,
val errorMessage: String = ""
)
/**
* TTS事件监听接口
*/
interface TtsEventListener {
fun onEvent(event: TtsEvent)
}
/**
* 音频数据监听接口
*/
interface AudioDataListener {
fun onAudioData(data: ByteArray)
}
/**
* 添加事件监听器
*/
fun addListener(listener: TtsEventListener) {
if (!listeners.contains(listener)) {
listeners.add(listener)
}
}
/**
* 移除事件监听器
*/
fun removeListener(listener: TtsEventListener) {
listeners.remove(listener)
}
/**
* 添加音频数据监听器
*/
fun addAudioDataListener(listener: AudioDataListener) {
if (!audioDataListeners.contains(listener)) {
audioDataListeners.add(listener)
// 如果添加了音频监听器,自动开启音频回调
enableAudioCallback = true
}
}
/**
* 移除音频数据监听器
*/
fun removeAudioDataListener(listener: AudioDataListener) {
audioDataListeners.remove(listener)
// 如果没有监听器了,可以关闭音频回调
if (audioDataListeners.isEmpty()) {
enableAudioCallback = false
}
}
/**
* 分发事件到所有监听器
*/
private fun dispatchEvent(event: TtsEvent) {
mainHandler.post {
listeners.forEach { it.onEvent(event) }
}
}
/**
* 分发音频数据到所有监听器
*/
private fun dispatchAudioData(data: ByteArray) {
if (audioDataListeners.isEmpty()) return
mainHandler.post {
audioDataListeners.forEach { it.onAudioData(data) }
}
}
/**
* 当前选择的语音
*/
fun getCurrentVoice(): String = voice
/**
* 当前是否正在播放
*/
fun isSpeaking(): Boolean = isPlaying
/**
* 设置是否启用音频数据回调
*/
fun setEnableAudioCallback(enable: Boolean) {
this.enableAudioCallback = enable
}
/** /**
* 初始化语音合成引擎 * 初始化语音合成引擎
*/ */
fun initialize(appId: String, token: String, resourceId: String): Boolean { fun initialize(appId: String, token: String): Boolean {
if (isInitialized) { if (isInitialized) {
FileLogger.i(TAG, "引擎已经初始化")
return true return true
} }
FileLogger.d(TAG, "初始化语音合成引擎: $appId, $token, $resourceId")
try { try {
// 准备环境 // 准备环境
try { try {
SpeechEngineGenerator.PrepareEnvironment(context, null) SpeechEngineGenerator.PrepareEnvironment(context, null)
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") FileLogger.w(TAG, "准备环境时发生异常: ${e.message}")
// 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类
} }
// 创建引擎 // 创建引擎
@ -66,235 +180,318 @@ class VolcanoTtsHelper(private val context: Context) {
engineHandler = engine?.createEngine() ?: -1 engineHandler = engine?.createEngine() ?: -1
if (engineHandler == -1L) { if (engineHandler == -1L) {
FileLogger.e(TAG, "创建引擎失败") handleError(-1, "创建引擎失败")
return false return false
} }
// 设置上下文 // 设置基本参数
engine?.setContext(context) engine?.setContext(context)
// 设置引擎类型为TTS
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.TTS_ENGINE) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.TTS_ENGINE)
// 设置日志级别
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_DEBUG) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_DEBUG)
// 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值)
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id")
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id")
// 设置授权信息 - 大模型版本不需要Bearer前缀 // 设置授权信息
FileLogger.d(TAG, "设置授权信息: $appId, $token, $resourceId")
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId)
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, "Bearer;$token") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, "Bearer;${token}")
// 设置资源ID - 大模型必需
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId)
// 设置协议类型为Seed - 大模型必需 // 设置协议和工作模式
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED)
// 设置合成策略为在线合成
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, SpeechEngineDefines.TTS_WORK_MODE_ONLINE) engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, SpeechEngineDefines.TTS_WORK_MODE_ONLINE)
// 设置在线请求资源配置 // 设置服务地址和发音人
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, "wss://openspeech.bytedance.com") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, "wss://openspeech.bytedance.com")
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v1/tts/ws_binary") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v1/tts/ws_binary")
//【必需配置】语音合成服务所用集群
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, "volcano_tts") engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, "volcano_tts")
// 设置发音人 engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, voice)
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice)
// 设置播放进度回调 // 设置播放进度回调和播放器
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_FRONTEND_INT, 1) engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_FRONTEND_INT, 1)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA)
engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ENABLE_PLAYER_BOOL, true)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA);
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL)
// 初始化引擎 // 初始化引擎
val result = engine?.initEngine(engineHandler) val result = engine?.initEngine(engineHandler)
isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR
if (isInitialized) { if (isInitialized) {
FileLogger.i(TAG, "引擎初始化成功")
// 设置回调监听 // 设置回调监听
engine?.setListener(object : SpeechEngine.SpeechListener { engine?.setListener(object : SpeechEngine.SpeechListener {
override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) {
val stdData = String(data) handleEngineEvent(type, String(data), data)
handleEngineEvent(type, stdData)
} }
}) })
return true return true
} else { } else {
// 错误码说明: handleError(result ?: -1, "引擎初始化失败")
// -1:通用错误
// -2:无效参数
// -3:无效状态
// -4:无效引擎
// -5:操作失败
// -6:缺少配置
// -7:参数无效
// -8:超时
// -9:资源不足
FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}")
return false return false
} }
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "初始化异常: ${e.message}", e) handleError(-1, "初始化异常: ${e.message}")
return false return false
} }
} }
/** /**
* 设置发音人 * 统一处理错误
*/ */
fun setVoice(voice: String): Boolean { private fun handleError(code: Int, message: String, e: Exception? = null) {
if (!isInitialized) { if (e != null) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, message, e)
return false } else {
} FileLogger.e(TAG, message)
try {
this.voice = voice
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice)
return true
} catch (e: Exception) {
FileLogger.e(TAG, "设置发音人失败: ${e.message}", e)
return false
} }
dispatchEvent(TtsEvent(TtsEventType.ERROR, code, message))
} }
/** /**
* 设置合成场景(单次或连续) * 设置语音
*/ */
fun setContinuousMode(isContinuous: Boolean): Boolean { fun setVoice(voiceName: String): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化") FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
try { try {
this.isContinuous = isContinuous this.voice = voiceName
val scenarioType = if (isContinuous) { engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, voiceName)
SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL
} else {
SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL
}
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, scenarioType)
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "设置合成场景失败: ${e.message}", e) handleError(-1, "设置发音人失败: ${e.message}", e)
return false return false
} }
} }
/** /**
* 开始合成并播放 * 单次播放文本
*/ */
fun speak(text: String, callback: TTSCallback? = null): Boolean { fun speakOnce(text: String): Boolean {
FileLogger.d(TAG, "开始合成并播放: $text") if (text.isEmpty()) {
if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
// 停止当前播放
stop()
// 清空队列
pendingTexts.clear()
isProcessingQueue = false
isContinuousMode = false
synthesisFromPlayer = false
isSynthesisInProgress = false
// 设置单次播放模式
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL)
// 启用音频数据回调(如果需要)
configAudioCallback()
// 预备要合成的文本
currentText = text
// 单次播放模式:直接启动引擎并设置文本,自动开始合成
return startEngineWithText(text, false)
}
/**
* 将文本加入播放队列,支持连续播放多段文本
*/
fun speakStream(text: String): Boolean {
if (text.isEmpty()) { if (text.isEmpty()) {
FileLogger.e(TAG, "合成文本为空")
return false return false
} }
try { // 设置连续播放模式
this.ttsText = text if (!isContinuousMode) {
this.currentTtsCallback = callback isContinuousMode = true
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL)
// 设置要合成的文本
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text)
// 启用音频数据回调(如果需要)
configAudioCallback()
}
// 加入队列
pendingTexts.add(text)
// 如果当前引擎未启动,启动引擎
if (!isEngineStarted) {
return startEngine()
} else if (!isSynthesisInProgress && !isPlaying && !synthesisFromPlayer) {
// 如果引擎已启动但没有正在进行的合成或播放,触发下一个合成
return triggerNextSynthesis()
}
return true
}
/**
* 配置音频数据回调
*/
private fun configAudioCallback() {
// 如果有音频数据监听器或明确要求启用回调,则设置回调模式
if (enableAudioCallback) {
// 启用音频数据回调,使用流式模式(2)
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, 2)
} else {
// 禁用音频数据回调
engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, 0)
}
}
/**
* 启动引擎,但不自动开始合成(适用于连续模式)
*/
private fun startEngine(): Boolean {
if (!isInitialized) {
return false
}
try {
// 停止当前引擎 // 停止当前引擎
engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "")
// 启动引擎开始合成 // 启动引擎
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "启动合成失败: $ret") handleError(ret ?: -1, "启动引擎失败")
isProcessingQueue = false
return false return false
} }
isEngineStarted = true
retryCount = MAX_RETRY_COUNT
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "合成异常: ${e.message}", e) handleError(-1, "启动引擎异常: ${e.message}", e)
isProcessingQueue = false
return false return false
} }
} }
/** /**
* 仅用于连续合成场景:在引擎启动后添加新的文本进行合成 * 启动引擎并设置文本(适用于单次模式)
*/ */
fun synthesisNext(text: String): Boolean { private fun startEngineWithText(text: String, isStream: Boolean): Boolean {
if (!isInitialized || !isContinuous) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化或非连续合成模式")
return false
}
if (text.isEmpty()) {
FileLogger.e(TAG, "合成文本为空")
return false return false
} }
try { try {
// 停止当前引擎
engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "")
// 设置要合成的文本 // 设置要合成的文本
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text)
// 发送合成指令 // 启动引擎开始合成
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "") val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
FileLogger.e(TAG, "添加合成文本失败: $ret") handleError(ret ?: -1, "启动合成失败")
isProcessingQueue = false
return false return false
} }
isEngineStarted = true
retryCount = MAX_RETRY_COUNT
return true return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "添加合成文本异常: ${e.message}", e) handleError(-1, "合成异常: ${e.message}", e)
isProcessingQueue = false
return false return false
} }
} }
/** /**
* 暂停播放 * 播放队列中剩余文本并标记流式输入结束
*/ */
fun pause(): Boolean { fun flushStream(): Boolean {
if (!isInitialized || !isPlaying) { if (pendingTexts.isEmpty() && !isEngineStarted) {
FileLogger.e(TAG, "引擎未初始化或未在播放") isProcessingQueue = false
return false return true
}
// 如果引擎未启动,则启动引擎
if (!isEngineStarted) {
val result = startEngine()
if (result && !pendingTexts.isEmpty()) {
// 引擎启动成功后,在引擎启动回调中会自动触发第一句合成
}
return result
}
// 如果队列为空且当前没有播放,则关闭引擎
if (pendingTexts.isEmpty() && !isPlaying && !isSynthesisInProgress) {
closeEngine()
}
return true
}
/**
* 关闭引擎
*/
private fun closeEngine(): Boolean {
if (!isEngineStarted) {
return true
} }
try { try {
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_PAUSE_PLAYER, "") val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "")
isEngineStarted = false
isSynthesisInProgress = false
return ret == SpeechEngineDefines.ERR_NO_ERROR return ret == SpeechEngineDefines.ERR_NO_ERROR
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "暂停播放异常: ${e.message}", e) FileLogger.e(TAG, "关闭引擎异常", e)
return false return false
} }
} }
/** /**
* 恢复播放 * 在回调中触发下一句合成
*/ */
fun resume(): Boolean { private fun triggerNextSynthesis(): Boolean {
if (!isInitialized) { if (!isEngineStarted || pendingTexts.isEmpty() || isSynthesisInProgress) {
FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
val nextText = pendingTexts.poll()
currentText = nextText
try { try {
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_RESUME_PLAYER, "") // 设置要合成的文本
return ret == SpeechEngineDefines.ERR_NO_ERROR engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, nextText)
// 标记合成开始
isSynthesisInProgress = true
// 发送合成指令
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "")
if (ret != SpeechEngineDefines.ERR_NO_ERROR) {
// 特殊处理播放器忙的情况
if (ret == SpeechEngineDefines.ERR_SYNTHESIS_PLAYER_IS_BUSY) {
// 当播放器忙时,将文本放回队列前面,等待播放完成后再合成
pendingTexts.add(nextText)
synthesisFromPlayer = true
isSynthesisInProgress = false
return true
} else {
isSynthesisInProgress = false
handleError(ret ?: -1, "合成失败")
return false
}
}
return true
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "恢复播放异常: ${e.message}", e) isSynthesisInProgress = false
handleError(-1, "触发合成异常: ${e.message}", e)
return false return false
} }
} }
@ -304,16 +501,32 @@ class VolcanoTtsHelper(private val context: Context) {
*/ */
fun stop(): Boolean { fun stop(): Boolean {
if (!isInitialized) { if (!isInitialized) {
FileLogger.e(TAG, "引擎未初始化")
return false return false
} }
// 已经不在播放,无需操作
if (!isPlaying && !isEngineStarted) {
return true
}
try { try {
val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "")
isPlaying = false isPlaying = false
isEngineStarted = false
isSynthesisInProgress = false
// 清空队列
pendingTexts.clear()
isProcessingQueue = false
isContinuousMode = false
synthesisFromPlayer = false
// 发送取消事件
dispatchEvent(TtsEvent(TtsEventType.CANCELED))
return ret == SpeechEngineDefines.ERR_NO_ERROR return ret == SpeechEngineDefines.ERR_NO_ERROR
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "停止播放异常: ${e.message}", e) FileLogger.e(TAG, "停止播放异常", e)
return false return false
} }
} }
@ -321,7 +534,7 @@ class VolcanoTtsHelper(private val context: Context) {
/** /**
* 释放资源 * 释放资源
*/ */
fun release() { fun dispose() {
if (!isInitialized) { if (!isInitialized) {
return return
} }
@ -335,104 +548,164 @@ class VolcanoTtsHelper(private val context: Context) {
engineHandler = -1 engineHandler = -1
engine = null engine = null
isInitialized = false isInitialized = false
pendingTexts.clear()
FileLogger.i(TAG, "引擎已释放") listeners.clear()
audioDataListeners.clear()
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "释放引擎异常: ${e.message}", e) FileLogger.e(TAG, "释放引擎异常", e)
} }
} }
/** /**
* 处理引擎事件 * 处理引擎事件
*/ */
private fun handleEngineEvent(type: Int, data: String) { private fun handleEngineEvent(type: Int, stringData: String, rawData: ByteArray? = null) {
when (type) { when (type) {
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> {
// 引擎启动成功,获取请求ID // 引擎启动成功
currentReqId = data currentReqId = stringData
isPlaying = true isEngineStarted = true
mainHandler.post {
currentTtsCallback?.onStart(currentReqId)
}
if (isContinuous) { // 如果是连续模式且队列不为空,在引擎启动后立即触发第一句合成
// 在连续合成模式下,需要单独发送合成指令 if (isContinuousMode && !pendingTexts.isEmpty() && !isSynthesisInProgress) {
synthesisNext(ttsText) triggerNextSynthesis()
} }
} }
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> {
// 引擎停止 // 引擎停止
isPlaying = false isPlaying = false
isEngineStarted = false
isSynthesisInProgress = false
}
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_BEGIN -> {
// 合成开始
isSynthesisInProgress = true
}
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_END -> {
// 合成结束
isSynthesisInProgress = false
// 重置重试计数
retryCount = MAX_RETRY_COUNT
// 如果是连续模式且不是从播放器触发的合成,尝试处理下一个文本
if (isContinuousMode && !synthesisFromPlayer && !pendingTexts.isEmpty()) {
// 在合成结束回调中触发下一句合成
triggerNextSynthesis()
}
} }
SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> {
// 开始播放 // 开始播放
FileLogger.d(TAG, "开始播放: $data") isPlaying = true
dispatchEvent(TtsEvent(TtsEventType.STARTED))
} }
SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> {
// 播放结束 // 播放结束
isPlaying = false isPlaying = false
mainHandler.post { // 如果是从播放器触发的合成,现在可以继续处理队列了
currentTtsCallback?.onComplete(currentReqId) if (synthesisFromPlayer) {
synthesisFromPlayer = false
// 播放器空闲,尝试触发下一句合成
triggerNextSynthesis()
} else if (pendingTexts.isEmpty()) {
// 如果队列为空,发送播放完成事件并关闭引擎
dispatchEvent(TtsEvent(TtsEventType.COMPLETED))
isProcessingQueue = false
// 如果不是连续模式,关闭引擎
if (!isContinuousMode) {
closeEngine()
}
} else if (!isSynthesisInProgress) {
// 如果队列不为空且当前没有正在进行的合成,触发下一句合成
triggerNextSynthesis()
} }
} }
SpeechEngineDefines.MESSAGE_TYPE_TTS_PLAYBACK_PROGRESS -> { SpeechEngineDefines.MESSAGE_TYPE_TTS_AUDIO_DATA -> {
// 播放进度 // 音频数据回调
try { if (rawData != null && rawData.isNotEmpty()) {
val json = JSONObject(data) dispatchAudioData(rawData)
val progress = json.optDouble("progress", 0.0)
val reqId = json.optString("reqid", "")
mainHandler.post {
currentTtsCallback?.onProgress(reqId, progress)
}
} catch (e: Exception) {
FileLogger.e(TAG, "解析进度信息异常: ${e.message}", e)
} }
} }
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> {
// 错误信息 // 错误信息
try { try {
val json = JSONObject(data) val json = JSONObject(stringData)
val reqId = json.optString("reqid", "")
val errCode = json.optInt("err_code", -1) val errCode = json.optInt("err_code", -1)
val errMsg = json.optString("err_msg", "未知错误") val errMsg = json.optString("err_msg", "未知错误")
FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") FileLogger.e(TAG, "引擎错误: code=$errCode, msg=$errMsg")
isPlaying = false // 标记当前合成结束
isSynthesisInProgress = false
mainHandler.post { // 通知错误
currentTtsCallback?.onError(reqId, errCode, errMsg) dispatchEvent(TtsEvent(TtsEventType.ERROR, errCode, errMsg))
// 尝试恢复或停止
when (errCode) {
// 轻微合成错误,继续下一个文本
SpeechEngineDefines.CODE_TTS_LIMIT_QPS,
SpeechEngineDefines.CODE_TTS_LIMIT_COUNT,
SpeechEngineDefines.CODE_TTS_SERVER_BUSY,
SpeechEngineDefines.CODE_TTS_LONG_TEXT,
SpeechEngineDefines.CODE_TTS_INVALID_TEXT,
SpeechEngineDefines.CODE_TTS_SYNTHESIS_TIMEOUT,
SpeechEngineDefines.CODE_TTS_SYNTHESIS_ERROR,
SpeechEngineDefines.CODE_TTS_SYNTHESIS_WAITING_TIMEOUT,
SpeechEngineDefines.CODE_TTS_ERROR_UNKNOWN -> {
if (isContinuousMode && !pendingTexts.isEmpty()) {
// 轻微错误,直接尝试下一句
triggerNextSynthesis()
} else {
stop()
}
}
// 网络错误,尝试重试
SpeechEngineDefines.CODE_CONNECT_TIMEOUT,
SpeechEngineDefines.CODE_RECEIVE_TIMEOUT,
SpeechEngineDefines.CODE_NET_LIB_ERROR -> {
retryCount--
if (retryCount > 0) {
// 1秒后重试当前文本
mainHandler.postDelayed({
if (isEngineStarted) {
if (isContinuousMode) {
// 设置当前文本并触发合成
engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, currentText)
isSynthesisInProgress = true
engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "")
} else {
// 非连续模式,重新启动引擎
startEngineWithText(currentText, false)
}
}
}, 1000)
} else {
stop()
}
}
// 其他错误,停止引擎
else -> {
stop()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) isSynthesisInProgress = false
handleError(-1, "解析错误信息异常: ${e.message}", e)
stop()
} }
} }
} }
} }
/**
* 获取错误描述
*/
private fun getErrorDescription(errorCode: Int?): String {
return when (errorCode) {
-1 -> "通用错误"
-2 -> "无效参数,检查appId、token、resourceId等参数是否正确"
-3 -> "无效状态,引擎状态异常"
-4 -> "无效引擎句柄"
-5 -> "操作失败,可能网络连接问题"
-6 -> "缺少配置参数"
-7 -> "参数设置无效,请检查appId、token等参数是否有效"
-8 -> "操作超时"
-9 -> "资源不足"
else -> "未知错误: $errorCode"
}
}
} }

603
local_plugins/volcano_speech/lib/volcano_speech.dart

@ -1,603 +0,0 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// ASR 事件类型
enum AsrEventType {
/// 会话开始
sessionStarted,
/// 会话结束
sessionStopped,
/// 正在识别(中间结果)
recognizing,
/// 识别结果(最终结果)
result,
/// 音量变化
volumeChanged,
/// 错误
error
}
/// TTS 工作模式
enum TtsWorkMode {
/// 在线合成
online,
/// 离线合成
offline,
/// 同时在线离线
both,
/// 先在线再离线(网络不好时自动切换)
alternate,
/// 文件模式
file
}
/// TTS 文本类型
enum TtsTextType {
/// 纯文本
plain,
/// SSML格式
ssml
}
/// 协议类型
enum ProtocolType {
/// 默认协议
defaultProtocol,
/// Seed协议(用于大模型)
seed
}
/// TTS 播放进度事件
class TtsProgressEvent {
/// 播放进度 0.0-1.0
final double progress;
/// 请求ID
final String reqId;
const TtsProgressEvent({
required this.progress,
required this.reqId,
});
@override
String toString() {
return 'TtsProgressEvent{progress: $progress, reqId: $reqId}';
}
}
/// ASR 事件
class AsrEvent {
/// 事件类型
final AsrEventType type;
/// 识别文本(仅在recognizing和result类型时有效)
final String? text;
/// 识别语言(仅在recognizing和result类型时有效)
final String? language;
/// 音量值(仅在volumeChanged类型时有效)
final int? volume;
/// 错误信息(仅在error类型时有效)
final String? errorMessage;
const AsrEvent({
required this.type,
this.text,
this.language,
this.volume,
this.errorMessage,
});
factory AsrEvent.fromMap(Map<dynamic, dynamic> map) {
final typeStr = map['type'] as String;
AsrEventType type;
switch (typeStr) {
case 'sessionStarted':
type = AsrEventType.sessionStarted;
break;
case 'sessionStopped':
type = AsrEventType.sessionStopped;
break;
case 'recognizing':
type = AsrEventType.recognizing;
break;
case 'result':
type = AsrEventType.result;
break;
case 'volumeChanged':
type = AsrEventType.volumeChanged;
break;
case 'error':
type = AsrEventType.error;
break;
default:
throw ArgumentError('未知的事件类型: $typeStr');
}
return AsrEvent(
type: type,
text: map['text'] as String?,
language: map['language'] as String?,
volume: map['volume'] as int?,
errorMessage: map['message'] as String?,
);
}
@override
String toString() {
return 'AsrEvent{type: $type, text: $text, language: $language, volume: $volume, errorMessage: $errorMessage}';
}
}
/// 火山引擎语音服务插件
class VolcanoSpeech {
static final VolcanoSpeechAsr asr = VolcanoSpeechAsr._();
static final VolcanoSpeechTts tts = VolcanoSpeechTts._();
/// 释放资源
static Future<void> dispose() async {
await asr.dispose();
await tts.dispose();
}
}
/// 火山引擎语音识别服务
class VolcanoSpeechAsr {
static const MethodChannel _channel = MethodChannel('volcano_speech/asr');
static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events');
/// ASR事件流控制器
static final StreamController<AsrEvent> _eventStreamController = StreamController<AsrEvent>.broadcast();
/// ASR事件流
Stream<AsrEvent> get events => _eventStreamController.stream;
/// 是否已初始化事件监听
bool _eventListenerInitialized = false;
VolcanoSpeechAsr._() {
_initEventListener();
}
/// 初始化ASR事件监听
void _initEventListener() {
if (_eventListenerInitialized) return;
_eventChannel.receiveBroadcastStream().listen((dynamic event) {
if (event is Map) {
_eventStreamController.add(AsrEvent.fromMap(event));
}
});
_eventListenerInitialized = true;
}
/// 获取平台版本信息
Future<String?> getPlatformVersion() async {
return await _channel.invokeMethod('getPlatformVersion');
}
/// 初始化语音识别引擎
///
/// [appId] 火山引擎AppID
/// [apiKey] 火山引擎ApiKey
/// [supportedLanguages] 支持的语言列表
/// [useBigModel] 是否使用大模型识别
/// [resourceId] 大模型资源ID(仅当useBigModel为true时有效)
Future<bool> initialize({
required String appId,
required String apiKey,
List<String> supportedLanguages = const ['zh-CN'],
bool useBigModel = false,
String resourceId = '',
}) async {
return await _channel.invokeMethod('initialize', {
'appId': appId,
'apiKey': apiKey,
'supportedLanguages': supportedLanguages,
'useBigModel': useBigModel,
'resourceId': resourceId,
}) ?? false;
}
/// 设置识别语言
///
/// [language] 语言代码,例如 zh-CN、en-US
Future<bool> setLanguage(String language) async {
return await _channel.invokeMethod('setLanguage', {
'language': language,
}) ?? false;
}
/// 设置热词
///
/// [hotWords] 热词JSON字符串,例如 {"hotwords":[{"word":"快速入门","scale":2.0}]}
Future<bool> setHotWords(String hotWords) async {
return await _channel.invokeMethod('setHotWords', {
'hotWords': hotWords,
}) ?? false;
}
/// 设置ASR请求参数
///
/// [params] 请求参数JSON字符串
Future<bool> setRequestParams(String params) async {
return await _channel.invokeMethod('setRequestParams', {
'params': params,
}) ?? false;
}
/// 启用语音停顿、分句、分词信息输出
///
/// [enable] 是否启用
Future<bool> setShowUtterances(bool enable) async {
return await _channel.invokeMethod('setShowUtterances', {
'enable': enable,
}) ?? false;
}
/// 一次性识别(直到说话结束)
///
/// 返回识别结果文本和语言
Future<Map<String, String>> recognizeOnce() async {
final result = await _channel.invokeMethod('recognizeOnce');
return {
'text': result['text'] ?? '',
'language': result['language'] ?? 'zh-CN',
};
}
/// 开始连续识别
///
/// 通过[events]流监听识别结果
Future<bool> startContinuousRecognition() async {
return await _channel.invokeMethod('startContinuousRecognition') ?? false;
}
/// 停止连续识别
Future<bool> stopContinuousRecognition() async {
return await _channel.invokeMethod('stopContinuousRecognition') ?? false;
}
/// 检查是否正在连续识别
Future<bool> isContinuousRecognitionActive() async {
return await _channel.invokeMethod('isContinuousRecognitionActive') ?? false;
}
/// 开始长按识别(按下开始,抬起结束)
///
/// 通过[events]流监听识别结果
Future<bool> startListening() async {
return await _channel.invokeMethod('startListening') ?? false;
}
/// 停止长按识别
Future<bool> stopListening() async {
return await _channel.invokeMethod('stopListening') ?? false;
}
/// 释放ASR资源
Future<bool> dispose() async {
return await _channel.invokeMethod('dispose') ?? false;
}
}
/// 火山引擎语音合成服务
class VolcanoSpeechTts {
static const MethodChannel _channel = MethodChannel('volcano_speech/tts');
static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events');
/// TTS进度事件流控制器
static final StreamController<TtsProgressEvent> _progressStreamController = StreamController<TtsProgressEvent>.broadcast();
/// TTS进度事件流
Stream<TtsProgressEvent> get progressEvents => _progressStreamController.stream;
/// 是否已初始化事件监听
bool _eventListenerInitialized = false;
VolcanoSpeechTts._() {
_initEventListener();
}
/// 初始化TTS事件监听
void _initEventListener() {
if (_eventListenerInitialized) return;
_eventChannel.receiveBroadcastStream().listen((dynamic event) {
if (event is Map) {
final progress = event['progress'] as double?;
final reqId = event['reqId'] as String?;
if (progress != null && reqId != null) {
_progressStreamController.add(TtsProgressEvent(
progress: progress,
reqId: reqId,
));
}
}
});
_eventListenerInitialized = true;
}
/// 获取平台版本信息
Future<String?> getPlatformVersion() async {
return await _channel.invokeMethod('getPlatformVersion');
}
/// 启用大模型TTS
///
/// [enable] 是否启用大模型TTS
/// [resourceId] 资源ID
Future<bool> enableBigModelTts({
required bool enable,
String resourceId = '',
}) async {
return await _channel.invokeMethod('enableBigModelTts', {
'enable': enable,
'resourceId': resourceId,
}) ?? false;
}
/// 初始化语音合成引擎
///
/// [appId] 火山引擎AppID
/// [apiKey] 火山引擎ApiKey
Future<bool> initialize({
required String appId,
required String apiKey,
}) async {
return await _channel.invokeMethod('initialize', {
'appId': appId,
'apiKey': apiKey,
}) ?? false;
}
/// 设置音色
///
/// [voiceName] 音色名称
/// [voiceType] 音色类型,默认 qingxin
Future<bool> setVoice({
required String voiceName,
String voiceType = 'qingxin',
}) async {
return await _channel.invokeMethod('setVoice', {
'voiceName': voiceName,
'voiceType': voiceType,
}) ?? false;
}
/// 设置离线音色
///
/// [voiceName] 音色名称
/// [voiceType] 音色类型,默认 qingxin
Future<bool> setOfflineVoice({
required String voiceName,
String voiceType = 'qingxin',
}) async {
return await _channel.invokeMethod('setOfflineVoice', {
'voiceName': voiceName,
'voiceType': voiceType,
}) ?? false;
}
/// 设置大模型声音ID
///
/// [voiceId] 声音ID
Future<bool> setBigModelVoiceId(String voiceId) async {
return await _channel.invokeMethod('setBigModelVoiceId', {
'voiceId': voiceId,
}) ?? false;
}
/// 设置大模型请求参数
///
/// [params] 请求参数,JSON字符串
Future<bool> setBigModelRequestParams(String params) async {
return await _channel.invokeMethod('setBigModelRequestParams', {
'params': params,
}) ?? false;
}
/// 设置工作模式
///
/// [mode] 工作模式
Future<bool> setWorkMode(TtsWorkMode mode) async {
String modeStr;
switch (mode) {
case TtsWorkMode.online:
modeStr = 'online';
break;
case TtsWorkMode.offline:
modeStr = 'offline';
break;
case TtsWorkMode.both:
modeStr = 'both';
break;
case TtsWorkMode.alternate:
modeStr = 'alternate';
break;
case TtsWorkMode.file:
modeStr = 'file';
break;
}
return await _channel.invokeMethod('setWorkMode', {
'mode': modeStr,
}) ?? false;
}
/// 设置文本类型
///
/// [type] 文本类型,plain或ssml
Future<bool> setTextType(TtsTextType type) async {
String typeStr;
switch (type) {
case TtsTextType.plain:
typeStr = 'plain';
break;
case TtsTextType.ssml:
typeStr = 'ssml';
break;
}
return await _channel.invokeMethod('setTextType', {
'type': typeStr,
}) ?? false;
}
/// 设置是否启用缓存
///
/// [enable] 是否启用
Future<bool> setEnableCache(bool enable) async {
return await _channel.invokeMethod('setEnableCache', {
'enable': enable,
}) ?? false;
}
/// 设置情感
///
/// [emotion] 情感,例如 neutral、happy、angry、sad等
Future<bool> setEmotion(String emotion) async {
return await _channel.invokeMethod('setEmotion', {
'emotion': emotion,
}) ?? false;
}
/// 设置是否启用情感预测
///
/// [enable] 是否启用
Future<bool> setEnableEmotionPredict(bool enable) async {
return await _channel.invokeMethod('setEnableEmotionPredict', {
'enable': enable,
}) ?? false;
}
/// 设置是否启用声音克隆
///
/// [enable] 是否启用
/// [backendCluster] 后端集群
Future<bool> setEnableVoiceClone({
required bool enable,
String backendCluster = '',
}) async {
return await _channel.invokeMethod('setEnableVoiceClone', {
'enable': enable,
'backendCluster': backendCluster,
}) ?? false;
}
/// 设置是否启用回声消除
///
/// [enable] 是否启用
Future<bool> setEnableAEC(bool enable) async {
return await _channel.invokeMethod('setEnableAEC', {
'enable': enable,
}) ?? false;
}
/// 下载离线资源
///
/// [voiceTypes] 音色类型列表
/// [languages] 语言列表
Future<bool> downloadOfflineResource({
List<String> voiceTypes = const ['qingxin'],
List<String> languages = const ['zh-CN'],
}) async {
return await _channel.invokeMethod('downloadOfflineResource', {
'voiceTypes': voiceTypes,
'languages': languages,
}) ?? false;
}
/// 设置语音参数
///
/// [rate] 语速 -500~500
/// [volume] 音量 0~100
/// [pitch] 音调 -500~500
/// [silenceDuration] 静音时长,毫秒
Future<bool> setSpeechParams({
int rate = 0,
int volume = 100,
int pitch = 0,
int silenceDuration = 0,
}) async {
return await _channel.invokeMethod('setSpeechParams', {
'rate': rate,
'volume': volume,
'pitch': pitch,
'silenceDuration': silenceDuration,
}) ?? false;
}
/// 设置音频输出类型
///
/// [outputType] 输出类型,speaker(扬声器),earpiece(听筒),auto(自动)
Future<bool> setAudioOutputType(String outputType) async {
return await _channel.invokeMethod('setAudioOutputType', {
'outputType': outputType,
}) ?? false;
}
/// 合成并播放文本
///
/// [text] 待合成的文本
Future<bool> speakText(String text) async {
return await _channel.invokeMethod('speakText', {
'text': text,
}) ?? false;
}
/// 使用大模型合成并播放文本
///
/// [text] 待合成的文本
/// [voiceId] 声音ID
/// [params] 额外参数,JSON字符串
Future<bool> speakWithBigModel({
required String text,
required String voiceId,
String params = '',
}) async {
return await _channel.invokeMethod('speakWithBigModel', {
'text': text,
'voiceId': voiceId,
'params': params,
}) ?? false;
}
/// 暂停播放
Future<bool> pausePlayback() async {
return await _channel.invokeMethod('pausePlayback') ?? false;
}
/// 恢复播放
Future<bool> resumePlayback() async {
return await _channel.invokeMethod('resumePlayback') ?? false;
}
/// 停止播放
Future<bool> stopSpeaking() async {
return await _channel.invokeMethod('stopSpeaking') ?? false;
}
/// 释放TTS资源
Future<bool> dispose() async {
return await _channel.invokeMethod('dispose') ?? false;
}
}

10
pubspec.yaml

@ -58,8 +58,10 @@ dependencies:
firebase_auth: ^5.5.1 firebase_auth: ^5.5.1
google_sign_in: ^6.2.2 google_sign_in: ^6.2.2
flutter_facebook_auth: ^7.1.1 flutter_facebook_auth: ^7.1.1
common_utils: ^2.1.0
flutter_image_compress: ^2.4.0 flutter_image_compress: ^2.4.0
common_utils: ^2.1.0
deepvoice_utils:
path: local_plugins/deepvoice_utils
# image_cropper: ^9.0.0 # image_cropper: ^9.0.0
dio: ^5.8.0+1 dio: ^5.8.0+1
sqflite: ^2.4.2 sqflite: ^2.4.2
@ -68,8 +70,6 @@ dependencies:
path: local_plugins/azure_speech path: local_plugins/azure_speech
open_ai_service: open_ai_service:
path: local_plugins/open_ai_service path: local_plugins/open_ai_service
volcano_speech:
path: local_plugins/volcano_speech
jieli: jieli:
path: local_plugins/jieli path: local_plugins/jieli
chat_storage: chat_storage:
@ -80,6 +80,10 @@ dependencies:
path: amap_flutter path: amap_flutter
jl_opus: jl_opus:
path: local_plugins/jl_opus path: local_plugins/jl_opus
ble_service:
path: local_plugins/ble_service
classic_bluetooth:
path: local_plugins/classic_bluetooth
dev_dependencies: dev_dependencies:

64
test.json

@ -1,20 +1,22 @@
curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ curl 'https: //ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \
-H "Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6" \ -H "Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"model": "bot-20250405211523-l7c9r", "model": "bot-20250405211523-l7c9r",
"stream": true, "stream": true,
"stream_options": {"include_usage": true}, "stream_options": {
"messages": [ "include_usage": true
{ },
"role": "system", "messages": [
"content": "You are a helpful assistant." {
}, "role": "system",
{ "content": "You are a helpful assistant."
"role": "user", },
"content": "Hello!" {
} "role": "user",
] "content": "Hello!"
}
]
}' }'
@ -29,7 +31,7 @@ curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \
} }
curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ curl 'https: //ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' \ -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' \
-d '{ -d '{
@ -178,7 +180,19 @@ curl -X POST "https://www.deapsound.com/messages?sessionId=f156e609-6875-420b-b0
-H "Accept: */*" \ -H "Accept: */*" \
-H "User-Agent: ktor-client" \ -H "User-Agent: ktor-client" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"id":6,"method":"tools/call","params":{"name":"tool_weather","arguments":{"city":"上海"},"_meta":{},"method":"tools/call"},"jsonrpc":"2.0"}' -d '{
"id": 6,
"method": "tools/call",
"params": {
"name": "tool_weather",
"arguments": {
"city": "上海"
},
"_meta": {},
"method": "tools/call"
},
"jsonrpc": "2.0"
}'
@ -188,10 +202,24 @@ curl -X POST "https://www.deapsound.com/messages?sessionId=91c6e740-9a24-48f1-8c
-H "Accept: */*" \ -H "Accept: */*" \
-H "User-Agent: ktor-client" \ -H "User-Agent: ktor-client" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"method":"notifications/initialized","params":{"method":"notifications/initialized"},"jsonrpc":"2.0"}' -d '{
"method": "notifications/initialized",
"params": {
"method": "notifications/initialized"
},
"jsonrpc": "2.0"
}'
curl -X GET "https://www.deapsound.com/sse" \ curl -X GET "https://www.deapsound.com/sse" \
-H "Accept: text/event-stream,text/event-stream" \ -H "Accept: text/event-stream,text/event-stream" \
-H "Cache-Control: no-cache,no-store" \ -H "Cache-Control: no-cache,no-store" \
-H "Accept-Charset: UTF-8" \ -H "Accept-Charset: UTF-8" \
-H "User-Agent: ktor-client" -H "User-Agent: ktor-client"{
"app_alive": 270180271,
"build_connection_time": 919,
"disconnect_times": 1,
"is_background": false,
"last_connect_time": 919,
"last_duration": 438,
"payload_sign_time": 0,
"report_time": 1745779540.932181,

Loading…
Cancel
Save