From fb5df39fbae8290630b4927b63e12b6cee619169 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Wed, 30 Apr 2025 20:45:53 +0100 Subject: [PATCH] add --- .cursorrules | 42 +- android/app/build.gradle.kts | 4 +- android/app/src/debug/AndroidManifest.xml | 7 - android/app/src/main/AndroidManifest.xml | 31 +- .../deepsound/BleBroadcastReceiver.kt | 45 - .../deepsound/BleWakeService.kt | 335 ------- .../deepsound/ClassicBluetoothHelper.kt | 242 ----- .../yunqiinnovation/deepsound/MainActivity.kt | 808 +-------------- .../deepsound/VoiceInteractionHandler.kt | 444 --------- .../deepsound/VoiceInteractionService.kt | 670 ------------- .../com/yunqiinnovation/deepsound/utils.kt | 144 +++ android/settings.gradle.kts | 6 +- lib/data/services/speech_factory.dart | 3 + .../speech_impl/azure_asr_service.dart | 72 +- .../speech_impl/azure_tts_service.dart | 397 ++++---- .../speech_impl/volcano_asr_service.dart | 350 +++++++ .../speech_impl/volcano_tts_service.dart | 285 ++++++ .../ble_test/bindings/ble_test_binding.dart | 11 + .../controllers/ble_test_controller.dart | 433 ++++++++ lib/modules/ble_test/views/ble_test_view.dart | 279 ++++++ .../controllers/pairing_controller.dart | 6 - lib/modules/settings/views/settings_view.dart | 45 +- .../bindings/speech_test_binding.dart | 12 + .../controllers/speech_test_controller.dart | 416 ++++++++ .../speech_test/views/speech_test_view.dart | 349 +++++++ lib/routes/app_pages.dart | 14 + lib/routes/app_routes.dart | 2 + .../agent_service/android/build.gradle.kts | 42 + .../agent_service/android/settings.gradle.kts | 13 + .../android/src/main/AndroidManifest.xml | 5 + .../agent_service/AgentService.kt | 577 +++++++++++ .../agent_service/AgentServicePlugin.kt | 212 ++++ local_plugins/agent_service/pubspec.yaml | 34 + .../azure_speech/AzureAsrHelper.kt | 752 ++++++++------ .../azure_speech/AzureSpeechPlugin.kt | 338 ++++--- .../azure_speech/AzureTtsHelper.kt | 489 ++++----- .../azure_speech/SimpleAudioPlayer.kt | 345 +++++++ .../ble_service/android/build.gradle.kts | 35 + .../android/src/main/AndroidManifest.xml | 38 + .../yunqiinnovation/ble_service/BleConst.kt | 89 ++ .../ble_service/BleForegroundService.kt | 311 ++++++ .../ble_service/BleManagerWrapper.kt | 234 +++++ .../yunqiinnovation/ble_service/BleService.kt | 905 +++++++++++++++++ .../ble_service/BleServicePlugin.kt | 425 ++++++++ .../ble_service/BleWakeReceiver.kt | 34 + .../ble_service/BootReceiver.kt | 19 + .../ble_service/lib/ble_service.dart | 356 +++++++ local_plugins/ble_service/pubspec.yaml | 24 + local_plugins/bytedance_speech/README.md | 175 ++++ .../bytedance_speech/android/build.gradle.kts | 36 + .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 8 + .../bytedance_speech/BytedanceASR.kt | 750 ++++++++++++++ .../bytedance_speech/BytedanceSpeechPlugin.kt | 331 ++++++ .../bytedance_speech/BytedanceTTS.kt | 938 ++++++++++++++++++ local_plugins/bytedance_speech/pubspec.yaml | 26 + .../chat_storage/android/build.gradle.kts | 29 +- .../chat_storage/android/settings.gradle.kts | 2 +- .../chat_storage/ChatStorageHelper.kt | 367 +++++++ .../chat_storage/ChatStoragePlugin.kt | 216 ++-- local_plugins/chat_storage/pubspec.yaml | 8 +- .../android/build.gradle.kts | 40 + .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 17 + .../ClassicBluetoothHelper.kt | 266 +++++ .../ClassicBluetoothPlugin.kt | 186 ++++ .../lib/classic_bluetooth.dart | 119 +++ local_plugins/classic_bluetooth/pubspec.yaml | 23 + .../deepvoice_utils/android/build.gradle.kts | 46 + .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 7 + .../deepvoice_utils/DeepvoiceUtilsPlugin.kt | 33 + .../deepvoice_utils}/FileLogger.kt | 2 +- .../deepvoice_utils/lib/deepvoice_utils.dart | 3 + .../deepvoice_utils/lib/src/file_logger.dart | 31 + local_plugins/deepvoice_utils/pubspec.yaml | 27 + .../open_ai_service/MCPClient.kt | 10 +- .../open_ai_service/OpenAIService.kt | 4 +- .../.gradle/8.10/fileHashes/fileHashes.bin | Bin 18547 -> 18597 bytes .../.gradle/8.10/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .../buildOutputCleanup/cache.properties | 4 +- .../speech_to_text/SpeechToTextPlugin.kt | 863 ---------------- .../volcano_speech/android/build.gradle.kts | 3 +- .../volcano_speech/VolcanoAsrHelper.kt | 559 +++++++---- .../volcano_speech/VolcanoSpeechPlugin.kt | 310 +++--- .../volcano_speech/VolcanoTtsHelper.kt | 647 ++++++++---- .../volcano_speech/lib/volcano_speech.dart | 603 ----------- pubspec.yaml | 10 +- test.json | 64 +- 90 files changed, 11745 insertions(+), 5750 deletions(-) delete mode 100644 android/app/src/debug/AndroidManifest.xml delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleBroadcastReceiver.kt delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleWakeService.kt delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt delete mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt create mode 100644 android/app/src/main/kotlin/com/yunqiinnovation/deepsound/utils.kt create mode 100644 lib/data/services/speech_impl/volcano_asr_service.dart create mode 100644 lib/data/services/speech_impl/volcano_tts_service.dart create mode 100644 lib/modules/ble_test/bindings/ble_test_binding.dart create mode 100644 lib/modules/ble_test/controllers/ble_test_controller.dart create mode 100644 lib/modules/ble_test/views/ble_test_view.dart create mode 100644 lib/modules/speech_test/bindings/speech_test_binding.dart create mode 100644 lib/modules/speech_test/controllers/speech_test_controller.dart create mode 100644 lib/modules/speech_test/views/speech_test_view.dart create mode 100644 local_plugins/agent_service/android/build.gradle.kts create mode 100644 local_plugins/agent_service/android/settings.gradle.kts create mode 100644 local_plugins/agent_service/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt create mode 100644 local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt create mode 100644 local_plugins/agent_service/pubspec.yaml create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt create mode 100644 local_plugins/ble_service/android/build.gradle.kts create mode 100644 local_plugins/ble_service/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt create mode 100644 local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt create mode 100644 local_plugins/ble_service/lib/ble_service.dart create mode 100644 local_plugins/ble_service/pubspec.yaml create mode 100644 local_plugins/bytedance_speech/README.md create mode 100644 local_plugins/bytedance_speech/android/build.gradle.kts create mode 100644 local_plugins/bytedance_speech/android/settings.gradle.kts create mode 100644 local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt create mode 100644 local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt create mode 100644 local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt create mode 100644 local_plugins/bytedance_speech/pubspec.yaml create mode 100644 local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt create mode 100644 local_plugins/classic_bluetooth/android/build.gradle.kts create mode 100644 local_plugins/classic_bluetooth/android/settings.gradle.kts create mode 100644 local_plugins/classic_bluetooth/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt create mode 100644 local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt create mode 100644 local_plugins/classic_bluetooth/lib/classic_bluetooth.dart create mode 100644 local_plugins/classic_bluetooth/pubspec.yaml create mode 100644 local_plugins/deepvoice_utils/android/build.gradle.kts create mode 100644 local_plugins/deepvoice_utils/android/settings.gradle.kts create mode 100644 local_plugins/deepvoice_utils/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/DeepvoiceUtilsPlugin.kt rename {android/app/src/main/kotlin/com/yunqiinnovation/deepsound/core/utils => local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils}/FileLogger.kt (99%) create mode 100644 local_plugins/deepvoice_utils/lib/deepvoice_utils.dart create mode 100644 local_plugins/deepvoice_utils/lib/src/file_logger.dart create mode 100644 local_plugins/deepvoice_utils/pubspec.yaml delete mode 100644 local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt delete mode 100644 local_plugins/volcano_speech/lib/volcano_speech.dart diff --git a/.cursorrules b/.cursorrules index a81ae52e0..ce77e46f4 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,14 +1,34 @@ -1. 请严格遵循 Effective Dart 的风格指南来编写代码 -2. 不要主动运行程序, 如flutter run -3. 不要改动android, iOS项目的配置信息,包括SDK, NDK, JAVA版本等 -4. 不要使用print, 而是使用logger, 尽可能避免打印日志,仅在出错时记录日志 -5. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget -6. 不要增加重试逻辑,出现失败直接返回错误 -7. 尽可能避免使用try catch -8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 -9. 请尽可能使用中文回复问题 -10. 重要:不要过度设计!! -11. 本项目使用 Kotlin DSL 进行开发 +# 代码简明规范 +## Dart / Flutter + 1. 风格统一 + • 严格遵循 Effective Dart 格式与命名惯例。 + 2. 日志与调试 + • 禁止 print;仅在异常场景调用 logger.e(...)。 + 3. 业务逻辑 + • 默认使用 GetX(状态、路由、依赖注入等),避免手写 StatefulWidget。 + 4. 异常处理 + • 不写重试逻辑,失败即返回错误。 + • 谨慎使用 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构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index be42f1334..9853fa6a0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,7 +67,7 @@ android { applicationId = "com.yunqiinnovation.deepsound" // You can update the following values to match your application needs. // 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 权限的要求 versionCode = flutter.versionCode versionName = flutter.versionName @@ -126,10 +126,10 @@ dependencies { // 添加本地插件模块依赖 implementation(project(":azure_speech")) implementation(project(":open_ai_service")) - implementation(project(":volcano_speech")) implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件 implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件 implementation(project(":spotify_sdk")) + implementation(project(":deepvoice_utils")) // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981d..000000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8b3012b08..941f7665f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,12 +7,15 @@ + + + @@ -75,6 +78,8 @@ + + - - - - - - - - - - - - - - - - - - - = Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleWakeService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleWakeService.kt deleted file mode 100644 index 89a1494a2..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/BleWakeService.kt +++ /dev/null @@ -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}") - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt deleted file mode 100644 index 423f4b8e7..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/ClassicBluetoothHelper.kt +++ /dev/null @@ -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>) - fun onError(code: String, message: String) - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt index b01ec4fe6..2c69d1803 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -1,816 +1,108 @@ package com.yunqiinnovation.deepsound -import android.Manifest import android.content.Intent import android.os.Build import android.os.Bundle -import android.util.Log 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.embedding.android.FlutterActivity import android.content.Context -import android.content.BroadcastReceiver -import android.content.IntentFilter import android.os.Handler import android.os.Looper -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import androidx.security.crypto.EncryptedSharedPreferences -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 +import com.yunqiinnovation.deepvoice_utils.FileLogger +import com.yunqiinnovation.ble_service.BleForegroundService +import com.yunqiinnovation.ble_service.BleManagerWrapper + 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 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 { - // Azure 语音服务配置 - var azureSpeechKey: String = "" - 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 = "" + // 配置字典,替代原来的单独配置变量 + val configMap: MutableMap = mutableMapOf() // 安全存储相关常量 - private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" - private const val KEY_AZURE_SPEECH_KEY = "azure_speech_key" - private const val KEY_AZURE_SPEECH_REGION = "azure_speech_region" - private const val KEY_VOLCANO_AI_API_KEY = "volcano_ai_api_key" - private const val KEY_OPENAI_API_KEY = "openai_api_key" - private const val KEY_OPENAI_BASE_URL = "openai_base_url" - private const val KEY_OPENAI_MODEL = "openai_model" - private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" - private const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id" - private const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token" - private const val KEY_MCP_SERVER = "mcp_server" - private const val KEY_SPOTIFY_CLIENT_ID = "spotify_client_id" - private const val KEY_SPOTIFY_REDIRECT_URL = "spotify_redirect_url" - - // 会话管理 - private const val KEY_SESSION_ID = "session_id" - private var currentSessionId = "" + const val KEY_AZURE_SPEECH_KEY = "azure_speech_key" + const val KEY_AZURE_SPEECH_REGION = "azure_speech_region" + const val KEY_VOLCANO_AI_API_KEY = "volcano_ai_api_key" + const val KEY_OPENAI_API_KEY = "openai_api_key" + const val KEY_OPENAI_BASE_URL = "openai_base_url" + const val KEY_OPENAI_MODEL = "openai_model" + const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" + const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id" + const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token" + const val KEY_MCP_SERVER = "mcp_server" + const val KEY_SPOTIFY_CLIENT_ID = "spotify_client_id" + const val KEY_SPOTIFY_REDIRECT_URL = "spotify_redirect_url" - /** - * 将密钥安全地存储到 EncryptedSharedPreferences - */ - 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() - } + // 获取配置的便捷方法 + fun getConfig(key: String, defaultValue: String = ""): String { + return configMap[key] ?: defaultValue } - - /** - * 从 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?) { super.onCreate(savedInstanceState) - // 初始化 FileLogger + // 初始化FileLogger FileLogger.init(applicationContext) - // 请求必要权限 - requestRequiredPermissions() + // 初始化ble_service后台唤醒功能 + // initBleBackgroundScan() - // 初始化 Azure 语音服务 - classicBluetoothHelper = ClassicBluetoothHelper(applicationContext) + // 加载配置 + // loadConfigFromSecureStorage() - // 注册广播接收器 - registerReceivers() - - // 确保 Flutter 引擎准备好后立即设置事件通道 - flutterEngine?.let { engine -> - Log.d(TAG, "Flutter 引擎已存在,立即设置事件通道") - setupEventChannels(engine) - } - - // 初始化BLE唤醒服务 - initBleWakeService() + FileLogger.d(TAG, "MainActivity.onCreate 已初始化") } - private fun registerReceivers() { - // 注册语音交互广播接收器 - val voiceInteractionFilter = IntentFilter().apply { - addAction(VoiceInteractionService.ACTION_RECOGNITION_STARTED) - addAction(VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED) - addAction(VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE) - } - - // 打印已注册的广播 - FileLogger.d(TAG, "已注册语音交互广播:${VoiceInteractionService.ACTION_RECOGNITION_STARTED}, " + - "${VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED}, " + - "${VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE}") + /** + * 从安全存储中加载配置 + */ + private fun loadConfigFromSecureStorage() { + val data = Utils.loadAllSecureData(applicationContext) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - registerReceiver(voiceInteractionReceiver, voiceInteractionFilter, Context.RECEIVER_NOT_EXPORTED) - } else { - registerReceiver(voiceInteractionReceiver, voiceInteractionFilter) - } + // 将所有数据加载到全局配置字典中 + configMap.clear() + configMap.putAll(data) - // 注册蓝牙状态变化广播接收器 - 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) - } + FileLogger.d(TAG, "已从安全存储加载配置") } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - Log.d(TAG, "开始配置 Flutter 引擎") - - // 先设置事件通道,确保在插件注册之前就已准备好 - setupEventChannels(flutterEngine) - - // 然后调用父类方法和注册插件 super.configureFlutterEngine(flutterEngine) GeneratedPluginRegistrant.registerWith(flutterEngine) - // 设置方法通道 - 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) - } + FileLogger.d(TAG, "Flutter 引擎配置完成") } - - /** - * 设置方法通道 - */ - 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>) { - 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>) { - 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("azure_speech_key") ?: "" - azureSpeechRegion = call.argument("azure_speech_region") ?: "" - openaiApiKey = call.argument("openai_api_key") ?: "" - openaiBaseUrl = call.argument("openai_base_url") - openaiModel = call.argument("openai_model") - volcanoSpeechAppId = call.argument("volcano_speech_app_id") ?: "" - volcanoSpeechAppToken = call.argument("volcano_speech_app_token") ?: "" - mcpServer = call.argument("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) { - 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) { - 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() { - Log.d(TAG, "onDestroy") - // 注销所有广播接收器 - try { - unregisterReceiver(voiceInteractionReceiver) - unregisterReceiver(bluetoothStateReceiver) - } catch (e: Exception) { - Log.e(TAG, "注销广播接收器失败: ${e.message}") - } - - // 释放资源 - classicBluetoothHelper.dispose() - + FileLogger.d(TAG, "onDestroy") super.onDestroy() } /** - * 请求必要权限 - */ - private fun requestRequiredPermissions() { - val permissionsToRequest = ArrayList() - - 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 - ) - } - } - - /** - * 处理权限请求结果 + * 初始化BLE后台扫描和唤醒功能 */ - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - - if (requestCode == PERMISSION_REQUEST_CODE) { - val deniedPermissions = ArrayList() - - for (i in permissions.indices) { - if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { - deniedPermissions.add(permissions[i]) - } - } + private fun initBleBackgroundScan() { + try { + // 初始化BleManagerWrapper + val bleManager = BleManagerWrapper.instance + bleManager.init(applicationContext) - if (deniedPermissions.isNotEmpty()) { - // 记录未授权的权限 - 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) + // 启动BLE前台服务以确保后台扫描运行 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) + startForegroundService(Intent(this, BleForegroundService::class.java)) } 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唤醒服务") - // 可以在这里请求权限 - requestBluetoothPermissions() - } - } - - /** - * 检查是否有蓝牙权限 - */ - 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 - ) + FileLogger.d(TAG, "成功启动BLE后台唤醒功能") + } catch (e: Exception) { + FileLogger.e(TAG, "启动BLE后台唤醒功能失败", e) } } - - /** - * 检查权限是否已授予 - */ - private fun hasPermission(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED - } } diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt deleted file mode 100644 index c31e6c319..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt deleted file mode 100644 index 1b94ceaa2..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/utils.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/utils.kt new file mode 100644 index 000000000..cf8d20831 --- /dev/null +++ b/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): 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 { + return try { + val resultMap = mutableMapOf() + + // 创建或获取主密钥 + 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 + } + } +} \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 173129661..92eb380f8 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -33,14 +33,16 @@ plugins { include(":app") include(":azure_speech") include(":open_ai_service") -include(":volcano_speech") include(":chat_storage") include(":jieli") include(":spotify_sdk") +include(":ble_service") +include(":deepvoice_utils") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/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(":jieli").projectDir = file("../local_plugins/jieli/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") \ No newline at end of file diff --git a/lib/data/services/speech_factory.dart b/lib/data/services/speech_factory.dart index 95f3165ee..b1c7a1ec5 100644 --- a/lib/data/services/speech_factory.dart +++ b/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_tts_service.dart'; + import '../../core/utils/logger.dart'; /// 语音服务类型 @@ -15,6 +16,7 @@ enum SpeechServiceType { /// Flutter 本地语音服务 flutter, + } /// 语音服务工厂类 @@ -61,6 +63,7 @@ class SpeechFactory extends GetxService { Get.lazyPut(() => FlutterAsrService()); Get.lazyPut(() => FlutterTtsService()); break; + } } diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 878206ef3..2d8c96396 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -1,25 +1,30 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/services.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_storage/get_storage.dart'; import '../../../core/utils/logger.dart'; import 'package:get/get.dart'; import '../asr_service.dart'; +/// 音频源类型 +enum AudioSourceType { + microphone, // 使用设备麦克风 + external // 使用外部提供的音频数据 +} + /// Azure 语音识别服务 /// -/// 该服务提供了通过平台通道与 Android 上的 Microsoft Speech SDK 交互的接口 +/// 该服务提供了通过平台通道与原生 Microsoft Speech SDK 交互的接口 class AzureAsrService extends GetxService implements AsrService { static final AzureAsrService to = Get.put(AzureAsrService()); static const MethodChannel _channel = MethodChannel('azure_speech/asr'); - static const EventChannel _eventChannel = - EventChannel('azure_speech/asr_events'); + static const EventChannel _eventChannel = EventChannel('azure_speech/asr_events'); final GetStorage _storage = GetStorage(); bool _isInitialized = false; late final String _subscriptionKey; late final String _serviceRegion; - final List _defaultSupportedLanguages = ['zh-CN']; + final List _defaultSupportedLanguages = ['zh-CN', 'en-US']; @override List get supportedLanguages => _defaultSupportedLanguages; @@ -36,6 +41,9 @@ class AzureAsrService extends GetxService implements AsrService { String _latestDetectedLanguage = ''; String get latestDetectedLanguage => _latestDetectedLanguage; + // 当前音频源类型 + AudioSourceType _audioSourceType = AudioSourceType.microphone; + AzureAsrService() { _loadConfig(); } @@ -54,6 +62,7 @@ class AzureAsrService extends GetxService implements AsrService { /// 设置事件通道 void _setupEventChannel() { + _eventSubscription?.cancel(); _eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) { if (event is Map) { _handleRecognitionEvent(event); @@ -64,21 +73,28 @@ class AzureAsrService extends GetxService implements AsrService { @override Future initialize({ List? supportedLanguages, + bool useExternalAudio = false, + bool useEchoCancellation = false, }) async { try { - final List languages = - supportedLanguages ?? _defaultSupportedLanguages; + final List languages = supportedLanguages ?? _defaultSupportedLanguages; // 检查是否需要重新初始化 if (_isInitialized) { await dispose(); } + // 设置音频源类型 + _audioSourceType = useExternalAudio ? AudioSourceType.external : AudioSourceType.microphone; + final bool result = await _channel.invokeMethod('initialize', { 'subscriptionKey': _subscriptionKey, 'region': _serviceRegion, 'supportedLanguages': languages, + 'audioSourceType': _audioSourceType.toString().split('.').last, + 'useEchoCancellation': useEchoCancellation, }); + _setupEventChannel(); _isInitialized = result; @@ -98,12 +114,15 @@ class AzureAsrService extends GetxService implements AsrService { } try { + // 调用同步的recognizeOnce方法 final result = await _channel.invokeMethod('recognizeOnce'); if (result is Map) { + // 处理新格式返回 final String text = result['text'] as String? ?? ''; - final String detectedLanguage = - result['detectedLanguage'] as String? ?? ''; + final String detectedLanguage = result['detectedLanguage'] as String? ?? ''; + + // 更新最新识别结果 _latestRecognizedText = text; _latestDetectedLanguage = detectedLanguage; @@ -138,8 +157,7 @@ class AzureAsrService extends GetxService implements AsrService { _eventStreamController = StreamController.broadcast(); // 开始连续识别 - final bool result = - await _channel.invokeMethod('startContinuousRecognition'); + final bool result = await _channel.invokeMethod('startContinuousRecognition'); if (!result) { _cleanupEventStream(); @@ -162,8 +180,7 @@ class AzureAsrService extends GetxService implements AsrService { } try { - final bool result = - await _channel.invokeMethod('stopContinuousRecognition'); + final bool result = await _channel.invokeMethod('stopContinuousRecognition'); _cleanupEventStream(); return result; } catch (e) { @@ -191,8 +208,7 @@ class AzureAsrService extends GetxService implements AsrService { switch (eventType) { case 'result': final String text = eventMap['text'] as String? ?? ''; - final String detectedLanguage = - eventMap['detectedLanguage'] as String? ?? ''; + final String detectedLanguage = eventMap['detectedLanguage'] as String? ?? ''; _latestRecognizedText = text; _latestDetectedLanguage = detectedLanguage; _eventStreamController?.add(RecognitionEvent( @@ -204,8 +220,7 @@ class AzureAsrService extends GetxService implements AsrService { case 'recognizing': final String text = eventMap['text'] as String? ?? ''; - final String detectedLanguage = - eventMap['detectedLanguage'] as String? ?? ''; + final String detectedLanguage = eventMap['detectedLanguage'] as String? ?? ''; _eventStreamController?.add(RecognitionEvent( type: RecognitionEventType.intermediateResult, text: text, @@ -290,20 +305,29 @@ class AzureAsrService extends GetxService implements AsrService { } @override - Future pushAudioData(Uint8List data) async { + Future pushAudioData(Uint8List data) async { + if (!_isInitialized) { + await initialize(useExternalAudio: true); + } + + if (_audioSourceType != AudioSourceType.external) { + Logger.error('当前非外部音频模式,不能推送音频数据'); + return false; + } + try { - // 开始连续识别 final bool result = await _channel.invokeMethod('pushAudioData', { - 'AudioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer + 'audioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer }); if (!result) { - _cleanupEventStream(); - throw Exception('开始pushAudioData失败'); + throw Exception('推送音频数据失败'); } + + return true; } catch (e) { - Logger.error('开始pushAudioData失败: $e'); - rethrow; + Logger.error('推送音频数据失败: $e'); + return false; } } } diff --git a/lib/data/services/speech_impl/azure_tts_service.dart b/lib/data/services/speech_impl/azure_tts_service.dart index 66bd501bc..cf6c98bf7 100644 --- a/lib/data/services/speech_impl/azure_tts_service.dart +++ b/lib/data/services/speech_impl/azure_tts_service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_storage/get_storage.dart'; import '../../../core/utils/logger.dart'; import '../tts_service.dart'; @@ -14,6 +13,8 @@ import '../tts_service.dart'; class AzureTtsService extends GetxService implements TtsService { static final AzureTtsService to = Get.put(AzureTtsService()); static const MethodChannel _channel = MethodChannel('azure_speech/tts'); + static const EventChannel _eventChannel = EventChannel('azure_speech/tts_events'); + final GetStorage _storage = GetStorage(); bool _isInitialized = false; late final String _subscriptionKey; @@ -21,56 +22,30 @@ class AzureTtsService extends GetxService implements TtsService { // 当前设置 String _currentVoice = 'zh-CN-XiaoxiaoNeural'; - int _currentRate = 0; - int _currentPitch = 0; - int _currentVolume = 100; - - // 支持的语音列表 - final List _supportedVoices = [ - 'zh-CN-XiaoxiaoNeural', - 'zh-CN-YunxiNeural', - 'zh-CN-YunjianNeural', - 'en-US-JennyNeural', - 'en-US-GuyNeural' - ]; - + // 语音合成队列 - final List<_SpeechItem> _textQueue = []; + final List _textQueue = []; bool _isProcessingQueue = false; + // 事件流 + final StreamController _eventStreamController = StreamController.broadcast(); + StreamSubscription? _eventSubscription; + // 可观察状态 final isEnabled = true.obs; + final _isSpeaking = false.obs; // 流式文本缓冲区 String _streamBuffer = ''; - /// 缓存的播放状态 - bool _speakingCache = false; - DateTime _lastSpeakingCheck = DateTime.now(); - @override String get currentVoice => _currentVoice; @override - bool get isSpeaking { - // 如果未初始化,直接返回false - if (!_isInitialized) return false; - - // 如果距离上次检查不到50ms,则使用缓存值 - if (DateTime.now().difference(_lastSpeakingCheck).inMilliseconds < 50) { - return _speakingCache; - } - - // 否则异步更新缓存(但仍返回当前缓存值) - _checkSpeakingStatus(); - return _speakingCache; - } - - @override - Stream get onEvent => const Stream.empty(); + bool get isSpeaking => _isSpeaking.value; @override - List get supportedVoices => _supportedVoices; + Stream get onEvent => _eventStreamController.stream; AzureTtsService() { _loadConfig(); @@ -83,6 +58,74 @@ class AzureTtsService extends GetxService implements TtsService { _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 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 Future initialize({ List? supportedLanguages, @@ -98,6 +141,9 @@ class AzureTtsService extends GetxService implements TtsService { return false; } + // 先设置事件通道,确保在调用initialize方法前已准备好 + _setupEventChannel(); + // 更新支持的语音列表(如果提供) final String language = supportedLanguages?.first ?? 'zh-CN'; @@ -107,7 +153,7 @@ class AzureTtsService extends GetxService implements TtsService { 'language': language, }); - _isInitialized = true; + _isInitialized = result; return result; } catch (e) { Logger.error('初始化失败: $e'); @@ -133,99 +179,24 @@ class AzureTtsService extends GetxService implements TtsService { } } - /// 设置语音参数 - Future 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 Future speakOnce(String text) async { if (!_isInitialized) await initialize(); if (!isEnabled.value || text.isEmpty) return false; try { - // 主动设置缓存状态为true - _speakingCache = true; - - final result = await _channel.invokeMethod('speakText', {'text': text}); - - // 调用完成后主动检查状态 - _checkSpeakingStatus(); - - return result == "OK"; - } catch (e) { - // 发生错误时重置状态 - _speakingCache = false; - Logger.error('1语音合成失败: $e'); - return false; - } - } - - /// 播放SSML - Future 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"; + // 开始合成,传递参数 + final result = await _channel.invokeMethod('speakText', { + 'text': text, + }); + + return result == true; } catch (e) { - // 发生错误时重置状态 - _speakingCache = false; - Logger.error('SSML语音合成失败: $e'); + Logger.error('语音合成失败: $e'); return false; } } - /// 等待语音播放完成 - Future 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('isSpeaking').then((result) { - if (result != null) { - _speakingCache = result; - _lastSpeakingCheck = DateTime.now(); - } - }); - } - } - @override Future speakStream(String text) async { if (!isEnabled.value || text.isEmpty) return false; @@ -240,7 +211,7 @@ class AzureTtsService extends GetxService implements TtsService { // 使用正则表达式匹配句子,包括结束符号 // 匹配任意字符,直到遇到句子结束符号 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; @@ -260,12 +231,7 @@ class AzureTtsService extends GetxService implements TtsService { // 处理找到的句子 for (final sentence in sentences) { - _textQueue.add(_SpeechItem( - text: sentence, - rate: _currentRate, - pitch: _currentPitch, - volume: _currentVolume, - )); + _textQueue.add(sentence); hasProcessed = true; } @@ -296,12 +262,7 @@ class AzureTtsService extends GetxService implements TtsService { // 如果缓冲区仍有剩余文本,将其添加到播放队列 if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { - _textQueue.add(_SpeechItem( - text: _streamBuffer, - rate: _currentRate, - pitch: _currentPitch, - volume: _currentVolume, - )); + _textQueue.add(_streamBuffer); // 清空缓冲区 _streamBuffer = ''; @@ -315,14 +276,39 @@ class AzureTtsService extends GetxService implements TtsService { Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); // 等待队列处理完成 - await _checkSpeakingStatus(); - while (_isProcessingQueue || _speakingCache) { - await Future.delayed(const Duration(milliseconds: 100)); - await _checkSpeakingStatus(); + final completer = Completer(); + + // 监听事件流检查队列是否完成 + 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队列播放完成'); - return true; + return result; } catch (e) { Logger.error('刷新TTS流失败: $e'); return false; @@ -334,36 +320,51 @@ class AzureTtsService extends GetxService implements TtsService { if (_textQueue.isEmpty || _isProcessingQueue) return; _isProcessingQueue = true; - + + // 处理队列中的第一项 + await _processNextInQueue(); + } + + /// 处理队列中的下一项 + Future _processNextInQueue() async { + // 检查队列是否为空或已禁用 + if (_textQueue.isEmpty || !isEnabled.value) { + _textQueue.clear(); + _isProcessingQueue = false; + return; + } + try { - while (_textQueue.isNotEmpty) { - if (!isEnabled.value) { - _textQueue.clear(); - break; - } - - final item = _textQueue.removeAt(0); - - if (item.rate != _currentRate || - item.pitch != _currentPitch || - item.volume != _currentVolume) { - await setSpeechParams( - rate: item.rate, - pitch: item.pitch, - volume: item.volume, - ); - } - - // 使用speakOnce播放当前项 - await speakOnce(item.text); - - // 等待播放完成 - await waitForSpeechCompletion(); + // 获取当前项但暂不从队列中移除 + final text = _textQueue.first; + + // 开始合成 + final success = await speakOnce(text); + + // 仅当合成操作失败时才从队列移除并继续处理下一项 + // 成功的话会等待合成完成事件后自动处理下一项 + if (!success) { + _textQueue.removeAt(0); + await _processNextInQueue(); + } else { + // 成功启动合成,从队列中移除当前项 + // 注意:下一项的处理将在合成完成事件中触发 + _textQueue.removeAt(0); } } catch (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 { _textQueue.clear(); - if (_speakingCache) { + if (_isSpeaking.value) { final result = await _channel.invokeMethod('stopSpeaking'); // 重置缓存状态 - _speakingCache = false; + _isSpeaking.value = false; + _isProcessingQueue = false; 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 ''' - - - - $text - - - - '''; - } - /// 切换启用状态 void toggleEnabled() { isEnabled.toggle(); @@ -430,40 +400,11 @@ class AzureTtsService extends GetxService implements TtsService { try { await stop(); + await _eventSubscription?.cancel(); await _channel.invokeMethod('dispose'); _isInitialized = false; } catch (e) { Logger.error('释放资源失败: $e'); } } - - /// 异步检查并更新播放状态缓存 - Future _checkSpeakingStatus() async { - _lastSpeakingCheck = DateTime.now(); - try { - final result = await _channel.invokeMethod('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, - }); } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart new file mode 100644 index 000000000..7018af3c5 --- /dev/null +++ b/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 _eventStreamController = + StreamController.broadcast(); + + /// 事件订阅 + StreamSubscription? _eventSubscription; + + /// 是否初始化完成 + bool _isInitialized = false; + + /// 是否正在连续识别 + bool _isContinuousRecognitionActive = false; + + /// 当前语言 + String _currentLanguage = 'zh-CN'; + + /// 支持的语言列表 + final List _supportedLanguages = ['zh-CN', 'en-US']; + + @override + List get supportedLanguages => _supportedLanguages; + + /// 构造函数 + VolcanoAsrService() { + _setupEventListener(); + } + + /// 初始化语音识别服务 + @override + Future initialize({ + List? 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('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 _configureAsr() async { + // 设置默认语言 + await _channel.invokeMethod('setLanguage', { + 'language': _currentLanguage, + }); + + } + + /// 设置事件监听 + void _setupEventListener() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is! Map) return; + _handleAsrEvent(Map.from(event as Map)); + }, + onError: (Object error) { + debugPrint('ASR事件流错误: $error'); + _eventStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: '事件流错误: $error', + )); + }, + ); + } + + /// 处理ASR事件 + void _handleAsrEvent(Map 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 _ensureInitialized() async { + if (!_isInitialized) { + return await initialize(); + } + return true; + } + + /// 执行一次性语音识别 + @override + Future recognizeOnce() async { + if (!await _ensureInitialized()) { + return RecognitionEvent( + type: RecognitionEventType.error, + error: '语音识别服务未初始化', + ); + } + + try { + // 创建一个Completer用于等待最终结果 + final completer = Completer(); + + // 创建一次性事件监听 + 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('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> startContinuousRecognition() async { + if (!await _ensureInitialized()) { + _eventStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: '语音识别服务未初始化', + )); + return _eventStreamController.stream; + } + + try { + final success = await _channel.invokeMethod('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 stopContinuousRecognition() async { + if (!_isInitialized || !_isContinuousRecognitionActive) { + return false; + } + + try { + final success = await _channel.invokeMethod('stopContinuousRecognition') ?? false; + + if (success) { + _isContinuousRecognitionActive = false; + } + return success; + } catch (e) { + debugPrint('停止连续识别异常:$e'); + return false; + } + } + + /// 推送音频数据到连续识别中 + @override + Future pushAudioData(Uint8List data) async { + if (!_isInitialized || !_isContinuousRecognitionActive) { + return; + } + + // 注意:此功能需要在引擎初始化时设置音频输入类型为stream + // 由于当前实现默认使用麦克风输入,此方法暂时不会实际处理数据 + debugPrint('警告:当前火山语音ASR使用麦克风输入,pushAudioData方法不会起作用'); + } + + /// 检查连续识别是否活跃 + @override + bool isContinuousRecognitionActive() { + return _isContinuousRecognitionActive; + } + + /// 释放资源 + @override + Future dispose() async { + if (_isContinuousRecognitionActive) { + await stopContinuousRecognition(); + } + + await _eventSubscription?.cancel(); + _eventSubscription = null; + + if (!_eventStreamController.isClosed) { + await _eventStreamController.close(); + } + + if (_isInitialized) { + await _channel.invokeMethod('release'); + _isInitialized = false; + } + } +} \ No newline at end of file diff --git a/lib/data/services/speech_impl/volcano_tts_service.dart b/lib/data/services/speech_impl/volcano_tts_service.dart new file mode 100644 index 000000000..47d7aa2eb --- /dev/null +++ b/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 _eventStreamController = + StreamController.broadcast(); + + /// 音频数据流控制器 + final StreamController _audioDataStreamController = + StreamController.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 get onEvent => _eventStreamController.stream; + + /// 音频数据流 + Stream get onAudioData => _audioDataStreamController.stream; + + /// 构造函数 + VolcanoTtsService() { + _setupEventListeners(); + _setupAudioDataListeners(); + } + + /// 设置事件监听 + void _setupEventListeners() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is! Map) return; + + final Map 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 initialize({List? 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('initialize', { + 'appId': appId, + 'appToken': appToken, + }) ?? false; + + if (!result) { + debugPrint('火山语音TTS初始化失败'); + return false; + } + + _isInitialized = true; + return true; + } catch (e) { + debugPrint('火山语音TTS初始化异常:$e'); + return false; + } + } + + /// 检查初始化状态 + Future _ensureInitialized() async { + if (!_isInitialized) { + return await initialize(); + } + return true; + } + + @override + Future setVoice(String voiceName) async { + if (!await _ensureInitialized()) return false; + + try { + final result = await _channel.invokeMethod('setVoice', { + 'voice': voiceName, + }) ?? false; + + if (result) { + _currentVoice = voiceName; + } + return result; + } catch (e) { + debugPrint('设置语音失败:$e'); + return false; + } + } + + /// 设置是否启用音频数据回调 + Future setEnableAudioCallback(bool enable) async { + if (!await _ensureInitialized()) return false; + + try { + final result = await _channel.invokeMethod('enableAudioCallback', { + 'enable': enable, + }) ?? false; + + if (result) { + _isAudioCallbackEnabled = enable; + } + return result; + } catch (e) { + debugPrint('设置音频回调失败:$e'); + return false; + } + } + + @override + Future speakOnce(String text) async { + if (!await _ensureInitialized()) return false; + + try { + // 单次播放 + final result = await _channel.invokeMethod('speakOnce', { + 'text': text, + }) ?? false; + + return result; + } catch (e) { + debugPrint('单次播放失败:$e'); + _eventStreamController.add(TtsEvent.error('单次播放失败:$e')); + return false; + } + } + + @override + Future speakStream(String text) async { + if (!await _ensureInitialized()) return false; + + try { + // 流式添加文本 + final result = await _channel.invokeMethod('speakStream', { + 'text': text, + }) ?? false; + + return result; + } catch (e) { + debugPrint('流式播放失败:$e'); + _eventStreamController.add(TtsEvent.error('流式播放失败:$e')); + return false; + } + } + + @override + Future flushStream() async { + if (!await _ensureInitialized()) return false; + + try { + // 刷新流式文本 + return await _channel.invokeMethod('flushStream') ?? false; + } catch (e) { + debugPrint('刷新流式文本失败:$e'); + return false; + } + } + + @override + Future stop() async { + if (!_isInitialized) return false; + + try { + return await _channel.invokeMethod('stop') ?? false; + } catch (e) { + debugPrint('停止播放失败:$e'); + return false; + } + } + + @override + Future 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('release'); + _isInitialized = false; + } + } +} \ No newline at end of file diff --git a/lib/modules/ble_test/bindings/ble_test_binding.dart b/lib/modules/ble_test/bindings/ble_test_binding.dart new file mode 100644 index 000000000..0d4e90920 --- /dev/null +++ b/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(), + ); + } +} \ No newline at end of file diff --git a/lib/modules/ble_test/controllers/ble_test_controller.dart b/lib/modules/ble_test/controllers/ble_test_controller.dart new file mode 100644 index 000000000..09700fda7 --- /dev/null +++ b/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 = [].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 _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 _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 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 stopScan() async { + try { + await _bleService.stopScan(); + isScanning.value = false; + statusMessage.value = '扫描已停止'; + } catch (e) { + statusMessage.value = '停止扫描异常: $e'; + Get.snackbar('停止扫描失败', '无法停止BLE扫描: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 连接到设备 + Future 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 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 getDeviceVersion() async { + try { + statusMessage.value = '正在获取设备版本...'; + + await _bleService.getDeviceVersion(); + + statusMessage.value = '版本获取命令已发送'; + } catch (e) { + statusMessage.value = '获取版本异常: $e'; + Get.snackbar('获取版本失败', '无法获取设备版本: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 获取电池信息 + Future getBatteryInfo() async { + try { + statusMessage.value = '正在获取电池信息...'; + + await _bleService.getBatteryInfo(); + + statusMessage.value = '电池信息获取命令已发送'; + } catch (e) { + statusMessage.value = '获取电池信息异常: $e'; + Get.snackbar('获取电池信息失败', '无法获取电池信息: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 打开编码器 + Future openEncoder() async { + try { + statusMessage.value = '正在打开编码器...'; + + await _bleService.openEncoder(); + + statusMessage.value = '编码器打开命令已发送'; + } catch (e) { + statusMessage.value = '打开编码器异常: $e'; + Get.snackbar('操作失败', '无法打开编码器: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 打开解码器 + Future openDecoder() async { + try { + statusMessage.value = '正在打开解码器...'; + + await _bleService.openDecoder(); + + statusMessage.value = '解码器打开命令已发送'; + } catch (e) { + statusMessage.value = '打开解码器异常: $e'; + Get.snackbar('操作失败', '无法打开解码器: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 关闭编解码器 + Future closeCodec() async { + try { + statusMessage.value = '正在关闭编解码器...'; + + await _bleService.closeCodec(); + + statusMessage.value = '编解码器关闭命令已发送'; + } catch (e) { + statusMessage.value = '关闭编解码器异常: $e'; + Get.snackbar('操作失败', '无法关闭编解码器: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 播放A2DP + Future 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 volumeUp() async { + try { + statusMessage.value = '正在增加音量...'; + + await _bleService.volumeUp(); + + statusMessage.value = '音量增加命令已发送'; + } catch (e) { + statusMessage.value = '增加音量异常: $e'; + Get.snackbar('操作失败', '无法增加音量: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 音量减小 + Future volumeDown() async { + try { + statusMessage.value = '正在减小音量...'; + + await _bleService.volumeDown(); + + statusMessage.value = '音量减小命令已发送'; + } catch (e) { + statusMessage.value = '减小音量异常: $e'; + Get.snackbar('操作失败', '无法减小音量: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + /// 发送音频数据 + Future 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 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(); + } +} \ No newline at end of file diff --git a/lib/modules/ble_test/views/ble_test_view.dart b/lib/modules/ble_test/views/ble_test_view.dart new file mode 100644 index 000000000..58ed92eae --- /dev/null +++ b/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 { + 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('音量-'), + ), + ), + ], + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 4532cd496..bcb4a1fd6 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -16,10 +16,6 @@ class PairingController extends GetxController { final ClassicBluetoothService _bluetoothService = Get.find(); - // 语音交互服务 - final VoiceInteractionService _voiceInteractionService = - Get.find(); - // 是否开始搜索 final _isSearching = false.obs; bool get isSearching => _isSearching.value; @@ -111,8 +107,6 @@ class PairingController extends GetxController { Future _initialize() async { try { - // 初始化语音交互服务 - _voiceInteractionService.initialize(); isLoading.value = true; statusDescription.value = 'initializing'.tr; // 正在初始化...; diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 6f2f4d411..66f2d4749 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -526,6 +526,45 @@ class SettingsView extends GetView { ? Colors.white.withOpacity(0.1) : 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( title: 'logout'.tr, // 退出登录 @@ -1152,8 +1191,10 @@ class SettingsView extends GetView { final serviceType = controller.speechServiceType.value; final serviceName = controller.availableSpeechServices .firstWhere( - (service) => service['type'] == serviceType)['name']; - return Flexible( + (service) => service['type'] == serviceType, + orElse: () => {'name': 'unknown'})['name']; + return Container( + constraints: BoxConstraints(maxWidth: 120.w), // 限制最大宽度 child: Text( '$serviceName'.tr, style: TextStyle( diff --git a/lib/modules/speech_test/bindings/speech_test_binding.dart b/lib/modules/speech_test/bindings/speech_test_binding.dart new file mode 100644 index 000000000..71e981a92 --- /dev/null +++ b/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(), + ); + } +} \ No newline at end of file diff --git a/lib/modules/speech_test/controllers/speech_test_controller.dart b/lib/modules/speech_test/controllers/speech_test_controller.dart new file mode 100644 index 000000000..9274f369f --- /dev/null +++ b/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(); + + // 当前使用的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 selectedServiceType = SpeechServiceType.azure.obs; + + // 连续识别结果流 + StreamSubscription? _recognitionSubscription; + + // TTS事件流 + StreamSubscription? _ttsEventSubscription; + + // 文本控制器 + final TextEditingController textController = TextEditingController(); + + // 连续播放的测试句子 + final List testSentences = [ + '这是第一句测试语音。', + '这是第二句测试语音,语音合成支持连续播放。', + '这是第三句测试语音,将在连续模式下播放完毕。' + ]; + + @override + void onInit() { + super.onInit(); + // 设置默认待播放文本 + textController.text = '欢迎使用语音测试,这是一段默认的测试文本。您可以修改此文本,然后点击播放按钮测试语音合成效果。'; + _initServices(); + } + + /// 初始化服务 + Future _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 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 stopAll() async { + // 停止ASR + if (isListening.value) { + await stopContinuousRecognition(); + } + + // 停止TTS + if (isSpeaking.value) { + await stopSpeaking(); + } + + // 取消所有订阅 + await _recognitionSubscription?.cancel(); + _recognitionSubscription = null; + } + + /// 单次播放文本 + Future 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 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 stopSpeaking() async { + if (!isInitialized.value) return; + + try { + await _ttsService.stop(); + } catch (e) { + ttsStatus.value = '停止播放异常: $e'; + Logger.error('停止播放异常: $e'); + } + } + + /// 一次性识别 + Future 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 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 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(); + } +} \ No newline at end of file diff --git a/lib/modules/speech_test/views/speech_test_view.dart b/lib/modules/speech_test/views/speech_test_view.dart new file mode 100644 index 000000000..a87ec9676 --- /dev/null +++ b/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 { + 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; + } + } +} \ No newline at end of file diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 7585c46d6..457d75308 100644 --- a/lib/routes/app_pages.dart +++ b/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/opus_test/views/opus_test_view.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 { static final pages = [ @@ -140,5 +144,15 @@ abstract class AppPages { page: () => const OpusTestView(), binding: OpusTestBinding(), ), + GetPage( + name: Routes.bleTest, + page: () => const BleTestView(), + binding: BleTestBinding(), + ), + GetPage( + name: Routes.speechTest, + page: () => const SpeechTestView(), + binding: SpeechTestBinding(), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 944cecf4c..bbe618f5c 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -27,4 +27,6 @@ abstract class Routes { static const feedback = '/feedback'; static const jieliTest = '/jieli_test'; static const opusTest = '/opus_test'; + static const bleTest = '/ble_test'; + static const speechTest = '/speech_test'; } diff --git a/local_plugins/agent_service/android/build.gradle.kts b/local_plugins/agent_service/android/build.gradle.kts new file mode 100644 index 000000000..ed613c41b --- /dev/null +++ b/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")) +} \ No newline at end of file diff --git a/local_plugins/agent_service/android/settings.gradle.kts b/local_plugins/agent_service/android/settings.gradle.kts new file mode 100644 index 000000000..efdbada26 --- /dev/null +++ b/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") \ No newline at end of file diff --git a/local_plugins/agent_service/android/src/main/AndroidManifest.xml b/local_plugins/agent_service/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..08d048432 --- /dev/null +++ b/local_plugins/agent_service/android/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt new file mode 100644 index 000000000..cc3bc2869 --- /dev/null +++ b/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) +} + +/** + * 代理服务 - 处理语音识别、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): 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) { + // 使用监听器回调发送事件 + 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 + } +} \ No newline at end of file diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt new file mode 100644 index 000000000..082f5b7a2 --- /dev/null +++ b/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>("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("text") + if (text.isNullOrEmpty()) { + result.error("INVALID_ARGUMENTS", "文本不能为空", null) + return + } + + val speakResponse = call.argument("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("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) { + // 将事件转换为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 + } +} \ No newline at end of file diff --git a/local_plugins/agent_service/pubspec.yaml b/local_plugins/agent_service/pubspec.yaml new file mode 100644 index 000000000..867cae4a0 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 26595d9cd..8cb9c51b8 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -13,50 +13,92 @@ import com.yunqiinnovation.azure_speech.utils.FileLogger import com.microsoft.cognitiveservices.speech.* import com.microsoft.cognitiveservices.speech.audio.* import com.microsoft.cognitiveservices.speech.util.EventHandler +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext -class AzureAsrHelper(private val context: Context) { - private var recognizer: SpeechRecognizer? = null +/** + * Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 + */ +class AzureAsrHelper(private val context: Context) : CoroutineScope { + private val tag = "AzureAsrHelper" + + // 核心组件 private var speechConfig: SpeechConfig? = null - private val TAG = "AzureAsrHelper" + private var recognizer: SpeechRecognizer? = null + private var audioConfig: AudioConfig? = null + + // 状态管理 private var isContinuousRecognitionActive = false + + // 配置参数 private var currentLanguage = "zh-CN" + private var supportedLanguages = arrayOf("zh-CN") + private var isAutoDetectLanguage = false private var subscriptionKey = "" private var region = "" - private var isAutoDetectLanguage = false - private var supportedLanguages = arrayOf("zh-CN") - // 是否使用回音消除 - 内部控制常量 - private val useEchoCancellation = true + // 音频源配置 + private var audioSourceType = AudioSourceType.MICROPHONE + private var useEchoCancellation = true - // 自定义音频处理相关 - private var customAudioProcessor: CustomAudioProcessor? = null + // 音频处理 private var pushStream: PushAudioInputStream? = null - private var audioConfig: AudioConfig? = null - // 公共方法:允许外部写入音频数据 - fun pushAudioData(data: ByteArray) { - FileLogger.d(TAG, "pushAudioData,data=${data},pushStream=${pushStream}") - pushStream?.write(data) // 使用 SDK 的正确方法(如 push) + private var microphoneProcessor: MicrophoneProcessor? = null + + // 协程管理 + private val parentJob = SupervisorJob() + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + parentJob + + /** + * 音频来源类型 + */ + enum class AudioSourceType { + /** 使用设备麦克风 */ + MICROPHONE, + + /** 使用外部提供的音频数据 */ + EXTERNAL } - - // 初始化SDK并创建recognizer - fun initialize(subscriptionKey: String, region: String, - supportedLanguages: Array = arrayOf("zh-CN")): Boolean { + + /** + * 初始化Azure语音服务 + * + * @param subscriptionKey Azure 订阅密钥 + * @param region Azure 区域 + * @param supportedLanguages 支持的语言数组 + * @param audioSourceType 音频源类型 + * @param useEchoCancellation 是否开启回音消除 + * @return 初始化是否成功 + */ + fun initialize( + subscriptionKey: String, + region: String, + supportedLanguages: Array = arrayOf("zh-CN"), + audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE, + useEchoCancellation: Boolean = true + ): Boolean { try { - FileLogger.d(TAG, "初始化 Azure 语音服务") + FileLogger.d(tag, "初始化 Azure 语音服务, 音频源类型: $audioSourceType, 回音消除: $useEchoCancellation") // 检查配置是否为空 if (subscriptionKey.isEmpty() || region.isEmpty()) { - FileLogger.e(TAG, "Azure 配置信息不完整") + FileLogger.e(tag, "Azure 配置信息不完整") return false } // 释放之前的资源 dispose() + // 保存配置 this.subscriptionKey = subscriptionKey this.region = region + this.audioSourceType = audioSourceType + this.useEchoCancellation = useEchoCancellation // 设置语言 if (supportedLanguages.isNotEmpty()) { @@ -72,132 +114,133 @@ class AzureAsrHelper(private val context: Context) { } // 创建语音配置 - speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region) - - // 设置语言配置 - if (isAutoDetectLanguage) { - // 设置自动语言检测 - speechConfig?.setProperty(PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous") - } else { - // 设置指定的识别语言 - speechConfig?.speechRecognitionLanguage = currentLanguage - } - - // 创建识别器 - try { - if (useEchoCancellation) { - FileLogger.d(TAG, "如果使用回音消除,创建自定义音频输入流") - // 如果使用回音消除,创建自定义音频输入流 - setupCustomAudioProcessing() - - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) - } else { - recognizer = SpeechRecognizer(speechConfig, audioConfig) - } + speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region).apply { + if (isAutoDetectLanguage) { + // 直接启用语言检测模式 + setProperty(PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous") } else { - // // 使用默认麦克风输入 - // if (isAutoDetectLanguage) { - // val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - // recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) - // } else { - // recognizer = SpeechRecognizer(speechConfig) - // } + // 设置指定的识别语言 + speechRecognitionLanguage = currentLanguage } - - FileLogger.d(TAG, "Azure 语音服务初始化成功") - return true - } catch (e: Exception) { - FileLogger.e(TAG, "创建识别器失败: ${e.message}") - stopCustomAudioProcessing() - return false } + + // 创建识别器 + return setupRecognizer() } catch (e: Exception) { - FileLogger.e(TAG, "初始化失败: ${e.message}") + FileLogger.e(tag, "初始化失败: ${e.message}") return false } } - - // 重置 recognizer - private fun resetRecognizer(): Boolean { + + /** + * 设置识别器 + */ + private fun setupRecognizer(): Boolean { try { - // 释放之前的 recognizer + // 清理旧的识别器 recognizer?.close() recognizer = null - // 停止当前的音频处理 - stopCustomAudioProcessing() - - // 使用现有配置重新创建 recognizer - if (speechConfig != null) { - if (useEchoCancellation) { - // 如果使用回音消除,创建自定义音频输入流 - setupCustomAudioProcessing() - - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) + // 设置音频配置 + when (audioSourceType) { + AudioSourceType.MICROPHONE -> { + if (useEchoCancellation) { + // 使用自定义麦克风处理(带回音消除) + setupCustomMicrophoneProcessing() } else { - recognizer = SpeechRecognizer(speechConfig, audioConfig) + // 使用默认麦克风 + audioConfig = AudioConfig.fromDefaultMicrophoneInput() } - } else { - // // 使用默认麦克风输入 - // if (isAutoDetectLanguage) { - // val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - // recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) - // } else { - // recognizer = SpeechRecognizer(speechConfig) - // } } - return true + AudioSourceType.EXTERNAL -> { + // 使用推送流 + pushStream = PushAudioInputStream.create() + audioConfig = AudioConfig.fromStreamInput(pushStream) + } + } + + // 创建识别器 + recognizer = if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) } else { - FileLogger.e(TAG, "语音配置未初始化") - return false + SpeechRecognizer(speechConfig, audioConfig) } + + FileLogger.d(tag, "Azure 语音服务初始化成功") + return true } catch (e: Exception) { - FileLogger.e(TAG, "重置识别器失败: ${e.message}") + FileLogger.e(tag, "创建识别器失败: ${e.message}") + stopAudioProcessing() return false } } - - // 开始一次性语音识别 + + /** + * 向推送流写入音频数据 + * 仅当音频源设置为EXTERNAL时有效 + * + * @param data 音频数据字节数组 + */ + fun pushAudioData(data: ByteArray) { + if (audioSourceType != AudioSourceType.EXTERNAL) { + FileLogger.w(tag, "当前未使用外部音频源,忽略推送的音频数据") + return + } + + pushStream?.write(data) + } + + /** + * 执行一次性语音识别 + * + * @param callback 识别结果回调 + */ fun recognizeOnce(callback: RecognizeCallback) { if (speechConfig == null) { callback.onError("语音服务未初始化") return } - // 重置 recognizer - if (!resetRecognizer()) { + // 确保不在连续识别中 + if (isContinuousRecognitionActive) { + stopContinuousRecognition() + } + + // 重置识别器 + if (!setupRecognizer()) { callback.onError("重置识别器失败") return } try { // 启动音频处理 - startCustomAudioProcessing() + startAudioProcessing() - // 执行识别 + // 执行同步识别 val result = recognizer?.recognizeOnceAsync()?.get() // 停止音频处理 - stopCustomAudioProcessing() + stopAudioProcessing() if (result != null && result.reason == ResultReason.RecognizedSpeech) { - val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language - callback.onResult(result.text, detectedLanguage ?: "") + val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language ?: supportedLanguages[0] + callback.onResult(result.text, detectedLanguage) } else { callback.onError("未能识别语音") } } catch (e: Exception) { - // 停止音频处理 - stopCustomAudioProcessing() + stopAudioProcessing() callback.onError("识别异常: ${e.message}") } } - - // 开始连续语音识别 + + /** + * 开始连续语音识别 + * + * @param callback 连续识别结果回调 + * @return 是否成功开始识别 + */ fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { if (speechConfig == null) { callback.onError("语音服务未初始化") @@ -205,160 +248,179 @@ class AzureAsrHelper(private val context: Context) { } if (isContinuousRecognitionActive) { - FileLogger.w(TAG, "已在进行连续识别,忽略请求") return true } - // 重置 recognizer - if (!resetRecognizer()) { + // 重置识别器 + if (!setupRecognizer()) { callback.onError("重置识别器失败") return false } try { + // 设置各种事件监听 + setupEventListeners(callback) + // 启动音频处理 - startCustomAudioProcessing() - - // 识别中事件 - recognizer?.recognizing?.addEventListener( - EventHandler { _, event -> - val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(event.result)?.language - FileLogger.d(TAG, "识别中: ${event.result.text}, 语言: $detectedLanguage") - callback.onRecognizing(event.result.text, detectedLanguage ?: "") - } - ) - - // 识别完成事件 - recognizer?.recognized?.addEventListener( - EventHandler { _, event -> - if (event.result.reason == ResultReason.RecognizedSpeech) { - val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(event.result)?.language - FileLogger.d(TAG, "识别完成: ${event.result.text}, 语言: $detectedLanguage") - callback.onResult(event.result.text, detectedLanguage ?: "") - } - } - ) - - // 会话开始事件 - recognizer?.sessionStarted?.addEventListener( - EventHandler { _, _ -> - FileLogger.d(TAG, "识别会话已开始") - callback.onSessionStarted() - callback.onSuccess("开始识别") // 兼容旧接口 - } - ) - - // 会话结束事件 - recognizer?.sessionStopped?.addEventListener( - EventHandler { _, _ -> - FileLogger.d(TAG, "识别会话已结束") - isContinuousRecognitionActive = false - stopCustomAudioProcessing() - callback.onSessionStopped() - } - ) - - // 取消事件 - recognizer?.canceled?.addEventListener( - EventHandler { _, event -> - val errorDetails = try { - event.errorDetails ?: "未知错误" - } catch (e: Exception) { - "未知错误" - } - val reason = event.reason.toString() - FileLogger.e(TAG, "识别取消: $errorDetails") - isContinuousRecognitionActive = false - stopCustomAudioProcessing() - callback.onCanceled(reason, errorDetails) - callback.onError("识别取消: $errorDetails") // 兼容旧接口 - } - ) - + startAudioProcessing() + // 开始连续识别 recognizer?.startContinuousRecognitionAsync() isContinuousRecognitionActive = true - FileLogger.d(TAG, "连续识别已启动") return true } catch (e: Exception) { - // 停止音频处理 - stopCustomAudioProcessing() - FileLogger.e(TAG, "开始连续识别失败: ${e.message}") - e.printStackTrace() - callback.onError("开始连续识别失败: ${e.message}") + stopAudioProcessing() + isContinuousRecognitionActive = false + callback.onError("启动连续识别失败: ${e.message}") return false } } + + /** + * 设置事件监听器 + */ + private fun setupEventListeners(callback: ContinuousRecognizeCallback) { + // 识别中事件 + recognizer?.recognizing?.addEventListener( + EventHandler { _, event -> + val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(event.result)?.language ?: "" + FileLogger.d(tag, "识别中: ${event.result.text}, 语言: $detectedLanguage") + + // 直接在当前线程调用回调 + callback.onRecognizing(event.result.text, detectedLanguage) + } + ) + + // 识别完成事件 + recognizer?.recognized?.addEventListener( + EventHandler { _, event -> + if (event.result.reason == ResultReason.RecognizedSpeech) { + val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(event.result)?.language ?: "" + FileLogger.d(tag, "识别完成: ${event.result.text}, 语言: $detectedLanguage") + + // 直接在当前线程调用回调 + callback.onResult(event.result.text, detectedLanguage) + } + } + ) + + // 会话开始事件 + recognizer?.sessionStarted?.addEventListener( + EventHandler { _, _ -> + FileLogger.d(tag, "识别会话已开始") + + // 直接在当前线程调用回调 + callback.onSessionStarted() + } + ) + + // 会话结束事件 + recognizer?.sessionStopped?.addEventListener( + EventHandler { _, _ -> + FileLogger.d(tag, "识别会话已结束") + isContinuousRecognitionActive = false + stopAudioProcessing() + + // 直接在当前线程调用回调 + callback.onSessionStopped() + } + ) - // 停止连续语音识别 - fun stopContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { + // 取消事件 + recognizer?.canceled?.addEventListener( + EventHandler { _, event -> + val errorDetails = try { + event.errorDetails ?: "未知错误" + } catch (e: Exception) { + "未知错误" + } + val reason = event.reason.toString() + + FileLogger.e(tag, "识别取消: $errorDetails") + isContinuousRecognitionActive = false + stopAudioProcessing() + + // 直接在当前线程调用回调 + callback.onCanceled(reason, errorDetails) + callback.onError("识别取消: $errorDetails") // 兼容旧接口 + } + ) + } + + /** + * 停止连续语音识别 + * + * @return 是否成功停止 + */ + fun stopContinuousRecognition(): Boolean { if (speechConfig == null) { - callback.onError("语音服务未初始化") + FileLogger.e(tag, "语音服务未初始化") return false } if (!isContinuousRecognitionActive) { - FileLogger.d(TAG, "未进行连续识别,忽略停止请求") + FileLogger.d(tag, "未进行连续识别,忽略停止请求") return true } try { - FileLogger.d(TAG, "停止连续语音识别") + FileLogger.d(tag, "停止连续语音识别") if (recognizer == null) { - if (isContinuousRecognitionActive) { - FileLogger.w(TAG, "识别器为空,但状态显示活跃") - } + FileLogger.w(tag, "识别器为空,重置状态") isContinuousRecognitionActive = false return true } - // 停止连续识别 - val future = recognizer?.stopContinuousRecognitionAsync() - future?.get() + // 直接停止连续识别(SDK内部已是异步操作) + recognizer?.stopContinuousRecognitionAsync() // 停止音频处理 - stopCustomAudioProcessing() - - isContinuousRecognitionActive = false - FileLogger.d(TAG, "连续识别已停止") - callback.onSuccess("连续识别已停止") + stopAudioProcessing() + // 会话结束事件会设置isContinuousRecognitionActive = false + FileLogger.d(tag, "连续识别停止指令已发送") return true } catch (e: Exception) { // 强制重置状态 isContinuousRecognitionActive = false - FileLogger.e(TAG, "停止连续识别失败: ${e.message}") - e.printStackTrace() - callback.onError("停止连续识别失败: ${e.message}") + FileLogger.e(tag, "停止连续识别失败: ${e.message}") // 停止音频处理 - stopCustomAudioProcessing() + stopAudioProcessing() // 尝试强制关闭识别器 try { recognizer?.close() recognizer = null } catch (ex: Exception) { - FileLogger.e(TAG, "关闭识别器失败: ${ex.message}") + FileLogger.e(tag, "关闭识别器失败: ${ex.message}") } return false } } - - // 释放资源 + + /** + * 检查连续识别是否活跃 + */ + fun isContinuousRecognitionActive(): Boolean = isContinuousRecognitionActive + + /** + * 释放所有资源 + */ fun dispose() { try { // 如果正在进行连续识别,先停止 if (isContinuousRecognitionActive) { - recognizer?.stopContinuousRecognitionAsync()?.get() + // 直接停止,不等待结果 + recognizer?.stopContinuousRecognitionAsync() isContinuousRecognitionActive = false } // 停止音频处理 - stopCustomAudioProcessing() + stopAudioProcessing() // 释放recognizer recognizer?.close() @@ -368,27 +430,36 @@ class AzureAsrHelper(private val context: Context) { speechConfig?.close() speechConfig = null - FileLogger.d(TAG, "资源已释放") + // 释放音频配置 + audioConfig?.close() + audioConfig = null + + // 取消所有协程 + parentJob.cancel() + + FileLogger.d(tag, "资源已释放") } catch (e: Exception) { - FileLogger.e(TAG, "释放资源失败: ${e.message}") + FileLogger.e(tag, "释放资源失败: ${e.message}") // 确保状态被重置 isContinuousRecognitionActive = false - customAudioProcessor = null + microphoneProcessor = null pushStream = null audioConfig = null recognizer = null speechConfig = null + + // 确保协程被取消 + parentJob.cancel() } } - - // 检查连续识别是否处于活跃状态 - fun isContinuousRecognitionActive(): Boolean { - return isContinuousRecognitionActive - } - - // 设置自定义音频处理 - private fun setupCustomAudioProcessing() { + + // 音频处理相关方法 + + /** + * 设置自定义麦克风处理 + */ + private fun setupCustomMicrophoneProcessing() { try { // 创建音频推送流 pushStream = PushAudioInputStream.create() @@ -396,204 +467,281 @@ class AzureAsrHelper(private val context: Context) { // 创建音频配置 audioConfig = AudioConfig.fromStreamInput(pushStream) - // 创建自定义音频处理器 - customAudioProcessor = CustomAudioProcessor(pushStream!!) + // 创建麦克风处理器 + microphoneProcessor = MicrophoneProcessor(pushStream!!) } catch (e: Exception) { - FileLogger.e(TAG, "设置自定义音频处理失败: ${e.message}") + FileLogger.e(tag, "设置自定义麦克风处理失败: ${e.message}") e.printStackTrace() } } - - // 启动自定义音频处理 - private fun startCustomAudioProcessing() { - if (useEchoCancellation && customAudioProcessor != null) { - try { - customAudioProcessor?.startProcessing() - } catch (e: Exception) { - FileLogger.e(TAG, "启动音频处理器失败") - e.printStackTrace() + + /** + * 启动音频处理 + */ + private fun startAudioProcessing() { + when (audioSourceType) { + AudioSourceType.MICROPHONE -> { + if (useEchoCancellation && microphoneProcessor != null) { + try { + microphoneProcessor?.startProcessing() + } catch (e: Exception) { + FileLogger.e(tag, "启动麦克风处理失败: ${e.message}") + e.printStackTrace() + } + } + } + AudioSourceType.EXTERNAL -> { + // 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData } } } - - // 停止自定义音频处理 - private fun stopCustomAudioProcessing() { - if (customAudioProcessor != null) { + + /** + * 停止音频处理 + */ + private fun stopAudioProcessing() { + microphoneProcessor?.let { try { - customAudioProcessor?.stopProcessing() - customAudioProcessor = null + it.stopProcessing() + microphoneProcessor = null } catch (e: Exception) { - FileLogger.e(TAG, "停止音频处理器失败: ${e.message}") + FileLogger.e(tag, "停止麦克风处理失败: ${e.message}") e.printStackTrace() } } } - - // 修改认证取消事件处理代码 - private fun setupCancelledEventHandler(callback: ContinuousRecognizeCallback) { - recognizer?.canceled?.addEventListener( - EventHandler { _, event -> - val errorDetails = try { - event.errorDetails ?: "未知错误" - } catch (e: Exception) { - "未知错误" - } - val reason = event.reason.toString() - FileLogger.e(TAG, "识别取消: $errorDetails") - isContinuousRecognitionActive = false - stopCustomAudioProcessing() - callback.onCanceled(reason, errorDetails) - callback.onError("识别取消: $errorDetails") // 兼容旧接口 - } - ) - } - - // 自定义音频处理器 - private inner class CustomAudioProcessor(private val pushStream: PushAudioInputStream) { + + /** + * 麦克风处理器 + * 负责从麦克风读取音频数据,应用音频效果,并将数据写入推送流 + */ + private inner class MicrophoneProcessor(private val pushStream: PushAudioInputStream) { private val isProcessing = AtomicBoolean(false) private var audioRecord: AudioRecord? = null private var echoCanceler: AcousticEchoCanceler? = null private var noiseSuppressor: NoiseSuppressor? = null private var automaticGainControl: AutomaticGainControl? = null + private var processingJob: Job? = null - // 音频配置 - private val sampleRate = 16000 // 16kHz,适合语音识别 + // 音频配置 - 16kHz, 16bit, 单声道 + private val sampleRate = 16000 private val channelConfig = AudioFormat.CHANNEL_IN_MONO private val audioFormat = AudioFormat.ENCODING_PCM_16BIT - // 计算最小 buffer 大小 + // 计算最小缓冲区大小 private val bufferSize = AudioRecord.getMinBufferSize( sampleRate, channelConfig, audioFormat - ) + ).let { if (it < 0) 4096 else it * 2 } // 使用更大的缓冲区确保不丢失数据 - // 启动音频处理 + /** + * 启动音频处理 + */ fun startProcessing() { if (isProcessing.get()) return - // 创建录音对象 try { + // 创建录音对象 audioRecord = AudioRecord( MediaRecorder.AudioSource.VOICE_RECOGNITION, sampleRate, channelConfig, audioFormat, - bufferSize * 2 // 使用更大的缓冲区以确保不会丢失数据 + bufferSize ) - // 创建音频处理效果 - if (AcousticEchoCanceler.isAvailable()) { - echoCanceler = AcousticEchoCanceler.create(audioRecord!!.audioSessionId) - echoCanceler?.enabled = true - } - - if (NoiseSuppressor.isAvailable()) { - noiseSuppressor = NoiseSuppressor.create(audioRecord!!.audioSessionId) - noiseSuppressor?.enabled = true - } - - if (AutomaticGainControl.isAvailable()) { - automaticGainControl = AutomaticGainControl.create(audioRecord!!.audioSessionId) - automaticGainControl?.enabled = true - } + // 应用音频效果 + applyAudioEffects() // 开始录音 audioRecord?.startRecording() - // 处理线程 - Thread { - android.os.Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO) - processAudio() - }.start() - + // 使用协程处理音频 isProcessing.set(true) - FileLogger.d(TAG, "音频处理已启动") + processingJob = launch(Dispatchers.IO) { + try { + processAudio() + } catch (e: Exception) { + if (isProcessing.get()) { + FileLogger.e(tag, "音频处理异常: ${e.message}") + } + } + } + + FileLogger.d(tag, "麦克风处理已启动") } catch (e: Exception) { - FileLogger.e(TAG, "创建音频处理器失败: ${e.message}") + FileLogger.e(tag, "创建麦克风处理器失败: ${e.message}") releaseAudioResources() throw e } } - // 停止音频处理 + /** + * 应用音频效果 + */ + private fun applyAudioEffects() { + val sessionId = audioRecord?.audioSessionId ?: return + + // 回音消除 + if (AcousticEchoCanceler.isAvailable()) { + echoCanceler = AcousticEchoCanceler.create(sessionId).apply { + enabled = true + } + } + + // 噪声抑制 + if (NoiseSuppressor.isAvailable()) { + noiseSuppressor = NoiseSuppressor.create(sessionId).apply { + enabled = true + } + } + + // 自动增益控制 + if (AutomaticGainControl.isAvailable()) { + automaticGainControl = AutomaticGainControl.create(sessionId).apply { + enabled = true + } + } + } + + /** + * 停止音频处理 + */ fun stopProcessing() { if (!isProcessing.get()) return isProcessing.set(false) + processingJob?.cancel() + processingJob = null releaseAudioResources() - FileLogger.d(TAG, "音频处理已停止") + FileLogger.d(tag, "麦克风处理已停止") } - // 释放音频资源 + /** + * 释放音频资源 + */ private fun releaseAudioResources() { try { - audioRecord?.stop() + // 停止录音 + try { audioRecord?.stop() } catch (e: Exception) {} - echoCanceler?.release() + // 释放音频效果 + echoCanceler?.let { try { it.release() } catch (e: Exception) {} } echoCanceler = null - noiseSuppressor?.release() + noiseSuppressor?.let { try { it.release() } catch (e: Exception) {} } noiseSuppressor = null - automaticGainControl?.release() + automaticGainControl?.let { try { it.release() } catch (e: Exception) {} } automaticGainControl = null - audioRecord?.release() + // 释放录音实例 + audioRecord?.let { try { it.release() } catch (e: Exception) {} } audioRecord = null } catch (e: Exception) { - FileLogger.e(TAG, "释放音频资源失败: ${e.message}") + FileLogger.e(tag, "释放音频资源失败: ${e.message}") } } - // 音频处理线程 - private fun processAudio() { - // 设置线程优先级 + /** + * 音频处理函数 + */ + private suspend fun processAudio() = withContext(Dispatchers.IO) { try { + // 设置线程优先级 Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) } catch (e: Exception) { - FileLogger.e(TAG, "设置线程优先级失败") + FileLogger.e(tag, "设置线程优先级失败") } val buffer = ByteArray(bufferSize) - while (isProcessing.get()) { + while (isProcessing.get() && isActive) { try { val readSize = audioRecord?.read(buffer, 0, buffer.size) ?: -1 if (readSize > 0) { - // 修复: 只传入buffer,不传readSize - // 创建新的byte数组,只包含读取到的数据 - val audioData = buffer.copyOfRange(0, readSize) + // 只写入有效数据 + val audioData = if (readSize == buffer.size) buffer else buffer.copyOfRange(0, readSize) pushStream.write(audioData) } - // 适当休眠,避免占用过多 CPU - Thread.sleep(5) + // 短暂延迟以避免过度消耗CPU + delay(5) + } catch (e: CancellationException) { + break } catch (e: Exception) { if (isProcessing.get()) { - FileLogger.e(TAG, "处理音频数据异常: ${e.message}") + FileLogger.e(tag, "处理音频数据异常: ${e.message}") } break } } } } - - // 一次性识别回调接口 + + /** + * 一次性识别回调接口 + */ interface RecognizeCallback { + /** + * 返回识别结果 + * + * @param text 识别的文本 + * @param detectedLanguage 检测到的语言 + */ fun onResult(text: String, detectedLanguage: String) + + /** + * 识别错误时调用 + * + * @param error 错误信息 + */ fun onError(error: String) } - - // 连续识别回调接口 + + /** + * 连续识别回调接口 + */ interface ContinuousRecognizeCallback { + /** + * 返回识别结果 + * + * @param text 识别的文本 + * @param detectedLanguage 检测到的语言 + */ fun onResult(text: String, detectedLanguage: String) + + /** + * 识别进行中调用 + * + * @param recognizing 正在识别的文本 + * @param detectedLanguage 检测到的语言 + */ fun onRecognizing(recognizing: String, detectedLanguage: String) + + /** + * 会话开始时调用 + */ fun onSessionStarted() + + /** + * 会话结束时调用 + */ fun onSessionStopped() + + /** + * 识别取消时调用 + * + * @param reason 取消原因 + * @param errorDetails 错误详情 + */ fun onCanceled(reason: String, errorDetails: String) - fun onError(error: String) - // 兼容旧版本的接口 - fun onSuccess(message: String) {} + /** + * 识别出错时调用 + * + * @param error 错误信息 + */ + fun onError(error: String) } } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index e809403ea..a9442b6c5 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -1,25 +1,30 @@ package com.yunqiinnovation.azure_speech import android.content.Context -import android.app.Activity import android.os.Handler import android.os.Looper -import android.util.Log import androidx.annotation.NonNull import com.yunqiinnovation.azure_speech.utils.FileLogger - 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.* +import kotlin.coroutines.CoroutineContext +import com.microsoft.cognitiveservices.speech.audio.* /** AzureSpeechPlugin */ -class AzureSpeechPlugin: FlutterPlugin { - private val TAG = "AzureSpeechPlugin" +class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { + private val tag = "AzureSpeechPlugin" private lateinit var context: Context private val mainHandler = Handler(Looper.getMainLooper()) + + // 协程相关 + private val job = SupervisorJob() + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job // ASR相关 private lateinit var asrChannel : MethodChannel @@ -29,26 +34,44 @@ class AzureSpeechPlugin: FlutterPlugin { // TTS相关 private lateinit var ttsChannel : MethodChannel + private lateinit var ttsEventChannel: EventChannel + private var ttsEventSink: EventChannel.EventSink? = null private lateinit var azureTtsHelper: AzureTtsHelper // ASR 事件发送方法 private fun sendAsrEvent(event: Map) { - FileLogger.d(TAG, "发送ASR事件: $event") if (asrEventSink == null) { - FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") + FileLogger.w(tag, "无法发送ASR事件:事件通道未准备好") return } - + mainHandler.post { try { asrEventSink?.success(event) - FileLogger.d(TAG, "ASR事件发送成功") } catch (e: Exception) { - FileLogger.e(TAG, "发送ASR事件失败: ${e.message}") + FileLogger.e(tag, "发送ASR事件失败: ${e.message}") } } } + + // TTS 事件发送方法 + private fun sendTtsEvent(event: Map) { + 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) { context = flutterPluginBinding.applicationContext @@ -71,6 +94,18 @@ class AzureSpeechPlugin: FlutterPlugin { 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语音服务 azureTtsHelper = AzureTtsHelper(context) @@ -85,46 +120,62 @@ class AzureSpeechPlugin: FlutterPlugin { val subscriptionKey = call.argument("subscriptionKey") ?: "" val region = call.argument("region") ?: "" val supportedLanguages = call.argument>("supportedLanguages") ?: listOf("zh-CN") + val useExternalAudio = call.argument("useExternalAudio") ?: false + val useEchoCancellation = call.argument("useEchoCancellation") ?: true 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) } catch (e: Exception) { result.error("INITIALIZATION_ERROR", e.message, null) } } - "pushAudioData" -> { - - val audioData = call.argument("AudioData") - -try { - Log.d("","AsrMethodHandler,pushAudioData${audioData}") - if (audioData != null) { - - val success = azureAsrHelper.pushAudioData(audioData) - result.success(success) - } -} catch (e: Exception) { - result.error("pushAudioData_ERROR", e.message, null) -} + "pushAudioData" -> { + val audioData = call.argument("audioData") + try { + if (audioData != null) { + azureAsrHelper.pushAudioData(audioData) + result.success(true) + } else { + result.error("INVALID_ARGUMENTS", "音频数据不能为空", null) + } + } catch (e: Exception) { + result.error("PUSH_AUDIO_ERROR", e.message, null) + } } "recognizeOnce" -> { - azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - mainHandler.post { + try { + // 直接使用同步方式进行一次性识别 + azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) { + // 直接返回结果,不发送事件 result.success(mapOf( "text" to text, "detectedLanguage" to detectedLanguage )) } - } - - override fun onError(error: String) { - mainHandler.post { + + override fun onError(error: String) { result.error("RECOGNITION_ERROR", error, null) } - } - }) + }) + } catch (e: Exception) { + result.error("RECOGNITION_ERROR", "识别过程异常: ${e.message}", null) + } } "startContinuousRecognition" -> { // 确保事件通道已准备好 @@ -133,70 +184,56 @@ try { 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 { - if (!azureAsrHelper.isContinuousRecognitionActive()) { - result.success(true) - return - } - - val success = azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) {} - override fun onRecognizing(recognizing: String, detectedLanguage: String) {} - override fun onSessionStarted() {} - override fun onSessionStopped() {} - override fun onCanceled(reason: String, errorDetails: String) {} + 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) { - mainHandler.post { - result.error("STOP_ERROR", error, null) - } + sendAsrEvent(mapOf( + "type" to "error", + "message" to error + )) } - override fun onSuccess(message: String) {} }) 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) { result.error("STOP_ERROR", e.message, null) } @@ -222,64 +259,45 @@ try { "initialize" -> { val subscriptionKey = call.argument("subscriptionKey") ?: "" val region = call.argument("region") ?: "" - val language = call.argument("language") ?: "zh-CN" + val useCustomAudioOutput = call.argument("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) } "setVoice" -> { val voiceName = call.argument("voiceName") ?: return result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) result.success(azureTtsHelper.setVoice(voiceName)) } - "setSpeechParams" -> { - val rate = call.argument("rate") ?: 0 - val pitch = call.argument("pitch") ?: 0 - val volume = call.argument("volume") ?: 100 - result.success(azureTtsHelper.setSpeechParams(rate, pitch, volume)) - } - "setAudioOutputType" -> { - val outputTypeStr = call.argument("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" -> { val text = call.argument("text") ?: return result.error("INVALID_ARGUMENTS", "文本不能为空", null) - azureTtsHelper.speakText(text, 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) - } - } - }) - } - "speakSsml" -> { - val ssml = call.argument("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) - } - } - }) + // 直接传递参数给speakText方法 + val success = azureTtsHelper.speakText(text) + result.success(success) } "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) { asrChannel.setMethodCallHandler(null) ttsChannel.setMethodCallHandler(null) asrEventChannel.setStreamHandler(null) + ttsEventChannel.setStreamHandler(null) try { + // 取消所有协程 + job.cancel() + + // 释放资源 azureTtsHelper.dispose() azureAsrHelper.dispose() } catch (e: Exception) { - FileLogger.e(TAG, "Dispose resources error: ${e.message}") + FileLogger.e(tag, "释放资源失败: ${e.message}") } } } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt index 0ae180fa9..5be50d6db 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt @@ -1,407 +1,307 @@ package com.yunqiinnovation.azure_speech 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.audio.* -import java.util.concurrent.Future -import java.util.concurrent.Semaphore +import com.yunqiinnovation.azure_speech.utils.FileLogger +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 调用 - * 基于微软官方文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis + * 基于微软Azure语音服务的TTS实现 + * 参考文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis */ -class AzureTtsHelper(private val context: Context) { - private val TAG = "AzureTtsHelper" +class AzureTtsHelper(private val context: Context){ + 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 synthesizer: SpeechSynthesizer? = null private var isInitialized = false - private var audioManager: AudioManager? = null - - // 跟踪当前是否正在播放语音 - private var speaking = false + private var isSpeaking = false - // 音频输出设备类型 - enum class AudioOutputType { - SPEAKER, // 扬声器 - EARPIECE, // 听筒 - AUTO // 自动选择(有耳机用耳机,没有则用听筒) - } + // 当前配置 + private var currentVoice = DEFAULT_VOICE + private var currentRate = "0%" + private var currentPitch = "0%" + 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" - private var currentSpeechRate = "0%" - private var currentPitch = "0%" - private var currentVolume = "100%" + /** + * 简化的TTS回调接口 + */ + interface TtsCallback { + /** + * 合成开始 + */ + fun onSynthesisStarted() {} + + /** + * 合成中 + */ + fun onSynthesizing() {} + + /** + * 合成完成 + */ + fun onSynthesisCompleted() {} + + /** + * 合成取消 + */ + fun onSynthesisCanceled() {} + } /** - * 初始化 TTS 引擎 + * 初始化TTS引擎 * - * @param subscriptionKey Azure 语音服务订阅密钥 - * @param region Azure 语音服务区域 - * @param language 可选,默认语言,默认为 "zh-CN" + * @param subscriptionKey Azure语音服务订阅密钥 + * @param region Azure语音服务区域 + * @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 { - audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + // 释放之前的资源 + dispose() + // 存储全局回调 + ttsCallback = callback // 创建语音配置 speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region) - - // 设置语音合成输出格式为高质量音频 speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm) + speechConfig?.setSpeechSynthesisVoiceName(currentVoice) - // 设置默认语言 - speechConfig?.setSpeechSynthesisLanguage(language) - - // 设置默认语音 - speechConfig?.setSpeechSynthesisVoiceName(currentVoiceName) - - // 创建音频配置 - 使用默认扬声器 - val audioConfig = AudioConfig.fromDefaultSpeakerOutput() + // 创建音频配置 + val audioConfig = if (customAudioOutputStream != null) { + // 使用自定义音频输出流 + AudioConfig.fromStreamOutput(customAudioOutputStream) + } else { + // 使用默认扬声器 + AudioConfig.fromDefaultSpeakerOutput() + } - // 创建语音合成器 + // 创建合成器 synthesizer = SpeechSynthesizer(speechConfig, audioConfig) // 设置事件监听 - synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> - speaking = false - semaphore.release() - } - synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> - speaking = false - semaphore.release() - } + setupEventListeners() isInitialized = true - FileLogger.d(TAG, "TTS 引擎初始化成功") - - // 设置默认音频输出类型为自动 - if (audioManager != null) { - setAudioOutputType(AudioOutputType.AUTO) - } + FileLogger.d(TAG, "TTS引擎初始化成功: 区域=$region") return true } catch (e: Exception) { - FileLogger.e(TAG, "TTS 引擎初始化失败: ${e.message}, ${region}") - e.printStackTrace() + FileLogger.e(TAG, "TTS引擎初始化失败: ${e.message}") return false } } - + /** - * 设置音频输出设备类型 + * 设置自定义音频输出流 * - * @param outputType 音频输出设备类型 + * @param outputStream 自定义音频输出流,如果为null则使用默认音频输出 * @return 是否设置成功 */ - fun setAudioOutputType(outputType: AudioOutputType): Boolean { - if (!isInitialized) { - FileLogger.e(TAG, "TTS 引擎尚未初始化") - return false - } - - if (audioManager == null) { - FileLogger.e(TAG, "AudioManager 未初始化,无法设置音频输出设备") - return false - } - + fun setCustomAudioOutputStream(outputStream: PushAudioOutputStream?): Boolean { try { - currentAudioOutputType = outputType + // 保存引用 + customAudioOutputStream = outputStream - when (outputType) { - AudioOutputType.SPEAKER -> { - // 使用扬声器 - 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, "已设置音频输出设备为听筒") - } - } + // 如果已初始化,需要重新创建合成器以应用新的音频输出流 + if (isInitialized) { + recreateSynthesizer() } - // 重新创建合成器以应用新的音频设置 - recreateSynthesizer() - return true } catch (e: Exception) { - FileLogger.e(TAG, "设置音频输出设备失败: ${e.message}") - e.printStackTrace() + FileLogger.e(TAG, "设置自定义音频输出流失败: ${e.message}") return false } } /** - * 检查是否有耳机连接 + * 设置TTS回调 + * + * @param callback TTS回调接口 */ - private fun isHeadsetConnected(): Boolean { - if (audioManager == null) return false - - 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 - } + fun setTtsCallback(callback: TtsCallback?) { + ttsCallback = callback } /** - * 重新创建语音合成器 + * 设置事件监听器 */ - private fun recreateSynthesizer() { - try { - // 关闭旧的合成器 - synthesizer?.close() - - // 创建音频配置 - val audioConfig = AudioConfig.fromDefaultSpeakerOutput() - - // 创建新的合成器 - synthesizer = SpeechSynthesizer(speechConfig, audioConfig) + private fun setupEventListeners() { + synthesizer?.apply { + // 合成开始事件 + SynthesisStarted?.addEventListener { _, _ -> + FileLogger.d(TAG, "语音合成开始") + ttsCallback?.onSynthesisStarted() + } - // 重新设置事件监听 - synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> - speaking = false - semaphore.release() + // 合成中事件 + Synthesizing?.addEventListener { _, _ -> + ttsCallback?.onSynthesizing() } - synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> - speaking = false - semaphore.release() + + // 合成完成事件 + SynthesisCompleted?.addEventListener { _, _ -> + FileLogger.d(TAG, "语音合成完成") + isSpeaking = false + ttsCallback?.onSynthesisCompleted() } - FileLogger.d(TAG, "已重新创建语音合成器") - } catch (e: Exception) { - FileLogger.e(TAG, "重新创建语音合成器失败: ${e.message}") - e.printStackTrace() + // 合成取消事件 + SynthesisCanceled?.addEventListener { _, _ -> + FileLogger.d(TAG, "语音合成取消") + isSpeaking = false + ttsCallback?.onSynthesisCanceled() + } } } - + /** * 设置语音 - * - * @param voiceName 语音名称,例如 "zh-CN-XiaoxiaoNeural" */ fun setVoice(voiceName: String): Boolean { - if (!isInitialized) { - FileLogger.e(TAG, "TTS 引擎尚未初始化") - return false - } - if (voiceName == currentVoiceName) { - FileLogger.d(TAG, "已设置语音: $voiceName") - return true - } + if (!isInitialized) return false + try { - currentVoiceName = voiceName + currentVoice = voiceName speechConfig?.setSpeechSynthesisVoiceName(voiceName) - - // 重新创建合成器以应用新的语音设置 recreateSynthesizer() - - FileLogger.d(TAG, "已设置语音: $voiceName") return true } catch (e: Exception) { FileLogger.e(TAG, "设置语音失败: ${e.message}") - e.printStackTrace() 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 { - if (!isInitialized) { - FileLogger.e(TAG, "TTS 引擎尚未初始化") - return false + private fun recreateSynthesizer() { + try { + synthesizer?.close() + + // 创建音频配置 + 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 { - currentSpeechRate = if (rate == 0) "0%" else if (rate < 0) "${(rate * 0.9).toInt()}%" else "$rate%" - currentPitch = if (pitch == 0) "0%" else "${(pitch * 0.5).toInt()}%" + currentRate = formatPercentage(rate) + currentPitch = formatPercentage(pitch) currentVolume = "${volume.coerceIn(0, 100)}%" - - FileLogger.d(TAG, "已设置语音参数: 语速=$currentSpeechRate, 音调=$currentPitch, 音量=$currentVolume") return true } catch (e: Exception) { FileLogger.e(TAG, "设置语音参数失败: ${e.message}") - e.printStackTrace() return false } } - + /** - * 合成文本为语音并播放 - * - * @param text 要合成的文本 - * @param callback 回调接口,用于返回结果或错误 + * 格式化百分比值 */ - fun speakText(text: String, callback: TTSCallback) { - if (!isInitialized) { - 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}") - } + private fun formatPercentage(value: Int): String { + return if (value >= 0) "+$value%" else "$value%" } - + /** - * 生成 SSML 文本 - * - * @param text 要转换的文本 - * @return SSML 格式的文本 + * 生成SSML */ private fun generateSsml(text: String): String { return """ - - + + $text """.trimIndent() } - + /** - * 合成 SSML 为语音并播放 + * 合成文本为语音 * - * @param ssml SSML 格式的文本 - * @param callback 回调接口,用于返回结果或错误 + * @param text 要合成的文本 + * @return 是否成功开始合成 */ - fun speakSsml(ssml: String, callback: TTSCallback) { + fun speakText(text: String): Boolean { if (!isInitialized) { - callback.onError("TTS 引擎尚未初始化") - return + return false } try { - FileLogger.d(TAG, "开始合成 SSML") - - // 重置信号量 - semaphore.drainPermits() + // 生成SSML并播放 + val ssml = generateSsml(text) - // 异步合成语音 - val task: Future = synthesizer!!.SpeakSsmlAsync(ssml) + isSpeaking = true - // 标记为正在播放 - speaking = true + // 使用纯异步方式 + synthesizer?.SpeakSsmlAsync(ssml) - // 等待合成完成 - 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() + return true } catch (e: Exception) { - FileLogger.e(TAG, "语音合成异常: ${e.message}") - e.printStackTrace() - callback.onError("语音合成异常: ${e.message}") + isSpeaking = false + return false } } - + /** * 停止当前语音合成 */ fun stopSpeaking(): Boolean { - if (!isInitialized) { - FileLogger.e(TAG, "TTS 引擎尚未初始化") - return false - } + if (!isInitialized) return false try { synthesizer?.StopSpeakingAsync() - speaking = false - - FileLogger.d(TAG, "已停止语音合成") + isSpeaking = false return true } catch (e: Exception) { - FileLogger.e(TAG, "停止语音合成失败: ${e.message}") - e.printStackTrace() return false } } - + /** * 释放资源 */ @@ -411,35 +311,28 @@ class AzureTtsHelper(private val context: Context) { synthesizer?.close() speechConfig?.close() - // 恢复音频模式 - if (audioManager != null) { - audioManager?.mode = AudioManager.MODE_NORMAL - audioManager?.isSpeakerphoneOn = false + // 关闭自定义音频输出流 + try { + customAudioOutputStream?.close() + customAudioOutputStream = null + } catch (e: Exception) { + // 忽略关闭异常 } + // 清理引用 + ttsCallback = null + isInitialized = false - speaking = false - FileLogger.d(TAG, "TTS 引擎已释放") + isSpeaking = false } 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 onSuccess(message: String) - fun onError(error: String) - } + fun isSpeaking(): Boolean = isSpeaking } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt new file mode 100644 index 000000000..84f846967 --- /dev/null +++ b/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 + } + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/build.gradle.kts b/local_plugins/ble_service/android/build.gradle.kts new file mode 100644 index 000000000..4708e1a67 --- /dev/null +++ b/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")) +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/AndroidManifest.xml b/local_plugins/ble_service/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..461eac927 --- /dev/null +++ b/local_plugins/ble_service/android/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt new file mode 100644 index 000000000..8da430404 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt new file mode 100644 index 000000000..5034153d0 --- /dev/null +++ b/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>) { + 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) {} +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt new file mode 100644 index 000000000..3129bf60d --- /dev/null +++ b/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接口,将回调分发给多个监听者 + */ + private inner class CallbackDispatcher : BleService.Callback { + override fun onScanResult(devices: List>) { + 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) { + 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> { + // 简单返回一个转换后的设备列表 + val devices = mutableListOf>() + bleService?.getBluetoothAdapter()?.bondedDevices?.forEach { device -> + devices.add(mapOf( + "id" to device.address, + "name" to (device.name ?: "未知设备"), + "rssi" to 0 // 已配对设备没有RSSI信息 + )) + } + return devices + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt new file mode 100644 index 000000000..6c2520030 --- /dev/null +++ b/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>) + + // 连接状态相关回调 + 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) + } + + // ====================================================================================================== + // 成员变量 + // ====================================================================================================== + // 日志与数据存储 + 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() + 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 + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt new file mode 100644 index 000000000..72834f77d --- /dev/null +++ b/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 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("timeout")?.toLong() ?: 10000L + val success = bleManager.startScan(timeout) + result.success(success) + } + "stopScan" -> { + val success = bleManager.stopScan() + result.success(success) + } + "connect" -> { + val address = call.argument("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("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, + grantResults: IntArray + ): Boolean { + // 简化权限处理 + return false + } + + // BleService.Callback 接口实现 + + override fun onScanResult(devices: List>) { + 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) { + sendEvent(statusEventSink, mapOf( + "type" to "statusUpdate", + "status" to status + ), "发送状态更新异常") + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt new file mode 100644 index 000000000..e9d441cf1 --- /dev/null +++ b/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( + 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) + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt new file mode 100644 index 000000000..3e7065a66 --- /dev/null +++ b/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)) + } + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart new file mode 100644 index 000000000..92d866a67 --- /dev/null +++ b/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 uuids; + + BleDevice({ + required this.id, + required this.name, + required this.rssi, + required this.uuids, + }); + + factory BleDevice.fromMap(Map map) { + final uuidsList = (map['uuids'] as List?)?.cast() ?? []; + + 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> _scanStream; + late final Stream _connectionStream; + late final Stream _dataStream; + late final Stream _statusStream; + + // 获取事件流 + Stream> get onScanResults => _scanStream; + Stream get onConnectionChanged => _connectionStream; + Stream get onDataReceived => _dataStream; + Stream 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>((event) { + final List devices = (event['devices'] as List?) ?? []; + return devices.map((device) => BleDevice.fromMap(device)).toList(); + }); + + _connectionStream = _connectionEventChannel.receiveBroadcastStream(); + _dataStream = _dataEventChannel.receiveBroadcastStream(); + _statusStream = _statusEventChannel.receiveBroadcastStream(); + } + + /// 初始化BLE服务 + Future initialize() async { + try { + final result = await _methodChannel.invokeMethod('initialize'); + return result ?? false; + } catch (e) { + print('初始化BLE服务失败: $e'); + return false; + } + } + + /// 注册BLE回调 + Future registerCallback() async { + try { + final result = await _methodChannel.invokeMethod('registerCallback'); + return result ?? false; + } catch (e) { + print('注册BLE回调失败: $e'); + return false; + } + } + + /// 设置BLE方法回调处理器 + void setMethodCallHandler(Future Function(MethodCall call) handler) { + _methodChannel.setMethodCallHandler(handler); + } + + /// 开始扫描设备 + Future startScan({int timeout = 10000}) async { + try { + final result = await _methodChannel.invokeMethod( + 'startScan', + {'timeout': timeout}, + ); + return result ?? false; + } catch (e) { + print('开始扫描设备失败: $e'); + return false; + } + } + + /// 停止扫描设备 + Future stopScan() async { + try { + final result = await _methodChannel.invokeMethod('stopScan'); + return result ?? false; + } catch (e) { + print('停止扫描设备失败: $e'); + return false; + } + } + + /// 连接到设备 + Future connect(String address) async { + try { + final result = await _methodChannel.invokeMethod( + 'connect', + {'address': address}, + ); + return result ?? false; + } catch (e) { + print('连接设备失败: $e'); + return false; + } + } + + /// 断开设备连接 + Future disconnect() async { + try { + final result = await _methodChannel.invokeMethod('disconnect'); + return result ?? false; + } catch (e) { + print('断开设备连接失败: $e'); + return false; + } + } + + /// 检查是否已连接 + Future isConnected() async { + try { + final result = await _methodChannel.invokeMethod('isConnected'); + return result ?? false; + } catch (e) { + print('检查连接状态失败: $e'); + return false; + } + } + + /// 获取连接状态 + Future getConnectionState() async { + try { + final result = await _methodChannel.invokeMethod('getConnectionState'); + return result ?? BleConnectionState.disconnected; + } catch (e) { + print('获取连接状态失败: $e'); + return BleConnectionState.disconnected; + } + } + + /// 获取已连接设备地址 + Future getConnectedDeviceAddress() async { + try { + return await _methodChannel.invokeMethod('getConnectedDeviceAddress'); + } catch (e) { + print('获取已连接设备地址失败: $e'); + return null; + } + } + + /// 获取电池电量 + Future getBatteryLevel() async { + try { + final result = await _methodChannel.invokeMethod('getBatteryLevel'); + return result ?? 0; + } catch (e) { + print('获取电池电量失败: $e'); + return 0; + } + } + + /// 获取固件版本 + Future getFirmwareVersion() async { + try { + return await _methodChannel.invokeMethod('getFirmwareVersion'); + } catch (e) { + print('获取固件版本失败: $e'); + return null; + } + } + + /// 获取设备版本信息 + Future getDeviceVersion() async { + try { + final result = await _methodChannel.invokeMethod('getDeviceVersion'); + return result ?? false; + } catch (e) { + print('获取设备版本失败: $e'); + return false; + } + } + + /// 获取电池信息 + Future getBatteryInfo() async { + try { + final result = await _methodChannel.invokeMethod('getBatteryInfo'); + return result ?? false; + } catch (e) { + print('获取电池信息失败: $e'); + return false; + } + } + + /// 打开编码器 + Future openEncoder() async { + try { + final result = await _methodChannel.invokeMethod('openEncoder'); + return result ?? false; + } catch (e) { + print('打开编码器失败: $e'); + return false; + } + } + + /// 打开解码器 + Future openDecoder() async { + try { + final result = await _methodChannel.invokeMethod('openDecoder'); + return result ?? false; + } catch (e) { + print('打开解码器失败: $e'); + return false; + } + } + + /// 关闭编解码器 + Future closeCodec() async { + try { + final result = await _methodChannel.invokeMethod('closeCodec'); + return result ?? false; + } catch (e) { + print('关闭编解码器失败: $e'); + return false; + } + } + + /// 播放A2DP + Future playA2DP() async { + try { + final result = await _methodChannel.invokeMethod('playA2DP'); + return result ?? false; + } catch (e) { + print('播放A2DP失败: $e'); + return false; + } + } + + /// 增加音量 + Future volumeUp() async { + try { + final result = await _methodChannel.invokeMethod('volumeUp'); + return result ?? false; + } catch (e) { + print('增加音量失败: $e'); + return false; + } + } + + /// 减小音量 + Future volumeDown() async { + try { + final result = await _methodChannel.invokeMethod('volumeDown'); + return result ?? false; + } catch (e) { + print('减小音量失败: $e'); + return false; + } + } + + /// 发送音频数据 + Future sendAudioData(Uint8List data) async { + try { + final result = await _methodChannel.invokeMethod( + 'sendAudioData', + {'data': data}, + ); + return result ?? false; + } catch (e) { + print('发送音频数据失败: $e'); + return false; + } + } + + /// 清理资源 + Future cleanup() async { + try { + final result = await _methodChannel.invokeMethod('cleanup'); + return result ?? false; + } catch (e) { + print('清理资源失败: $e'); + return false; + } + } + + /// 获取已扫描到的设备列表 + Future> getScannedDevices() async { + try { + final result = await _methodChannel.invokeMethod>('getScannedDevices'); + if (result != null) { + return result.map((item) => BleDevice.fromMap(Map.from(item as Map))).toList(); + } + return []; + } catch (e) { + print('获取扫描设备列表失败: $e'); + return []; + } + } + + /// 获取平台版本 + Future getPlatformVersion() async { + try { + return await _methodChannel.invokeMethod('getPlatformVersion'); + } catch (e) { + print('获取平台版本失败: $e'); + return null; + } + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/pubspec.yaml b/local_plugins/ble_service/pubspec.yaml new file mode 100644 index 000000000..a0c48bfaa --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/bytedance_speech/README.md b/local_plugins/bytedance_speech/README.md new file mode 100644 index 000000000..1c81cc3fb --- /dev/null +++ b/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) \ No newline at end of file diff --git a/local_plugins/bytedance_speech/android/build.gradle.kts b/local_plugins/bytedance_speech/android/build.gradle.kts new file mode 100644 index 000000000..f6404d154 --- /dev/null +++ b/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") +} \ No newline at end of file diff --git a/local_plugins/bytedance_speech/android/settings.gradle.kts b/local_plugins/bytedance_speech/android/settings.gradle.kts new file mode 100644 index 000000000..0ee463e31 --- /dev/null +++ b/local_plugins/bytedance_speech/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "bytedance_speech" \ No newline at end of file diff --git a/local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml b/local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9f3f16d19 --- /dev/null +++ b/local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt b/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt new file mode 100644 index 000000000..34eefdf4b --- /dev/null +++ b/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 = 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? = 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 + } + } +} \ No newline at end of file diff --git a/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt b/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt new file mode 100644 index 000000000..0ee9b1092 --- /dev/null +++ b/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) { + 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) { + 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("appId") ?: "" + val accessToken = call.argument("accessToken") ?: "" + val streamMode = call.argument("streamMode") ?: true + val enablePunctuation = call.argument("enablePunctuation") ?: true + val sampleRate = call.argument("sampleRate") ?: 16000 + val bits = call.argument("bits") ?: 16 + val channels = call.argument("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("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("appId") ?: "" + val accessToken = call.argument("accessToken") ?: "" + val speaker = call.argument("speaker") ?: "" + val format = call.argument("format") ?: "mp3" + val sampleRate = call.argument("sampleRate") ?: 24000 + val enableTimestamp = call.argument("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("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 + } +} \ No newline at end of file diff --git a/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt b/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt new file mode 100644 index 000000000..d78be4d24 --- /dev/null +++ b/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 + } + } +} \ No newline at end of file diff --git a/local_plugins/bytedance_speech/pubspec.yaml b/local_plugins/bytedance_speech/pubspec.yaml new file mode 100644 index 000000000..3bb55f401 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/chat_storage/android/build.gradle.kts b/local_plugins/chat_storage/android/build.gradle.kts index 458280df7..842b98276 100644 --- a/local_plugins/chat_storage/android/build.gradle.kts +++ b/local_plugins/chat_storage/android/build.gradle.kts @@ -30,31 +30,12 @@ android { 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 { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") - implementation("com.squareup.okhttp3:okhttp:4.9.3") - implementation("com.anthropic:anthropic-java:0.8.0") - implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") -} - -dependencies { - // SQLite相关依赖 + 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") + // SQLite相关依赖 implementation("androidx.sqlite:sqlite:2.3.1") -} \ No newline at end of file +} diff --git a/local_plugins/chat_storage/android/settings.gradle.kts b/local_plugins/chat_storage/android/settings.gradle.kts index 2a25170b4..37623d8de 100644 --- a/local_plugins/chat_storage/android/settings.gradle.kts +++ b/local_plugins/chat_storage/android/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "chat_storage" \ No newline at end of file +rootProject.name = "chat_storage" diff --git a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt new file mode 100644 index 000000000..930662395 --- /dev/null +++ b/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? = 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> { + 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>() + + 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( + "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) + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt index 9e4711531..91b610d80 100644 --- a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt +++ b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt @@ -1,80 +1,30 @@ 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 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 org.json.JSONArray -import org.json.JSONObject -import java.text.SimpleDateFormat -import java.util.* -// 定义数据库帮助类 -private class ChatDatabaseHelper(context: Context) : - SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { - - 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 */ +/** + * ChatStoragePlugin - Flutter插件接口 + * 负责处理Flutter端的方法调用,并委托给ChatStorageHelper + */ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { + private val TAG = "ChatStoragePlugin" private lateinit var channel : MethodChannel private lateinit var context: Context - private lateinit var dbHelper: ChatDatabaseHelper + private lateinit var chatStorageHelper: ChatStorageHelper override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage") channel.setMethodCallHandler(this) - dbHelper = ChatDatabaseHelper(context) + chatStorageHelper = ChatStorageHelper(context) + + Log.d(TAG, "聊天存储插件已加载") } override fun onMethodCall(call: MethodCall, result: Result) { @@ -86,27 +36,20 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { val sender = call.argument("sender") ?: "" val metadata = call.argument("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) { result.success(mapOf("success" to true, "id" to id)) } else { result.error("DB_ERROR", "插入消息失败", null) } } catch (e: Exception) { + Log.e(TAG, "保存消息时发生错误", e) result.error("EXCEPTION", "保存消息时发生错误: ${e.message}", null) } } @@ -116,121 +59,80 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { val page = call.argument("page") ?: 1 val pageSize = call.argument("pageSize") ?: 20 - val db = dbHelper.readableDatabase - val offset = (page - 1) * 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 messagesJson = chatStorageHelper.getMessages( + sessionId = sessionId, + page = page, + pageSize = pageSize + ) - val response = JSONObject().apply { - put("messages", messages) - put("page", page) - put("pageSize", pageSize) - put("totalCount", totalCount) - put("totalPages", totalPages) - } - - result.success(response.toString()) + result.success(messagesJson.toString()) } catch (e: Exception) { + Log.e(TAG, "获取消息时发生错误", e) 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" -> { try { - val sessionId = call.argument("sessionId") ?: "" + val sessionId = call.argument("sessionId") val messageIds = call.argument>("messageIds") - val db = dbHelper.writableDatabase + val success = chatStorageHelper.deleteMessages( + sessionId = sessionId, + messageIds = messageIds + ) - if (messageIds != null && messageIds.isNotEmpty()) { - // 删除指定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)) + if (success) { result.success(mapOf("success" to true)) } else { result.error("INVALID_ARGS", "必须提供sessionId或messageIds", null) } } catch (e: Exception) { + Log.e(TAG, "删除消息时发生错误", e) result.error("EXCEPTION", "删除消息时发生错误: ${e.message}", null) } } "clearDatabase" -> { try { - val db = dbHelper.writableDatabase - db.execSQL("DELETE FROM messages") - result.success(mapOf("success" to true)) + val success = chatStorageHelper.clearDatabase() + result.success(mapOf("success" to success)) } catch (e: Exception) { + Log.e(TAG, "清空数据库时发生错误", e) result.error("EXCEPTION", "清空数据库时发生错误: ${e.message}", null) } } + "getRecentMessages" -> { + try { + val sessionId = call.argument("sessionId") ?: "" + val limit = call.argument("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 -> { 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) { channel.setMethodCallHandler(null) + Log.d(TAG, "聊天存储插件已卸载") } } \ No newline at end of file diff --git a/local_plugins/chat_storage/pubspec.yaml b/local_plugins/chat_storage/pubspec.yaml index 0a68a4a25..65f192318 100644 --- a/local_plugins/chat_storage/pubspec.yaml +++ b/local_plugins/chat_storage/pubspec.yaml @@ -1,7 +1,7 @@ name: chat_storage -description: A Flutter plugin for storing chat messages using SQLite. +description: 聊天历史存储插件,提供存储和检索聊天历史的功能 version: 0.0.1 -# homepage: +homepage: environment: sdk: ">=2.17.0 <3.0.0" @@ -10,15 +10,13 @@ environment: dependencies: flutter: sdk: flutter - sqflite: ^2.3.0 - path: ^1.8.3 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 -# The following section is specific to Flutter packages. +# 插件平台配置 flutter: # This section identifies this Flutter project as a plugin project. plugin: diff --git a/local_plugins/classic_bluetooth/android/build.gradle.kts b/local_plugins/classic_bluetooth/android/build.gradle.kts new file mode 100644 index 000000000..526816e6b --- /dev/null +++ b/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") +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/android/settings.gradle.kts b/local_plugins/classic_bluetooth/android/settings.gradle.kts new file mode 100644 index 000000000..22e118edf --- /dev/null +++ b/local_plugins/classic_bluetooth/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "classic_bluetooth" diff --git a/local_plugins/classic_bluetooth/android/src/main/AndroidManifest.xml b/local_plugins/classic_bluetooth/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..518c1e377 --- /dev/null +++ b/local_plugins/classic_bluetooth/android/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt b/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt new file mode 100644 index 000000000..6cad68441 --- /dev/null +++ b/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) { + val event = mutableMapOf( + "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> { + if (!hasBluetoothPermission()) { + log("缺少蓝牙权限", true) + return emptyList() + } + + val devices = mutableSetOf() + 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) + } + + /** + * 释放资源 + */ + fun dispose() { + try { + context.unregisterReceiver(bluetoothStateReceiver) + log("已注销蓝牙状态广播接收器") + } catch (e: Exception) { + log("注销蓝牙状态广播接收器失败: ${e.message}", true) + } + + // 清除监听器 + bluetoothEventListener = null + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt b/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt new file mode 100644 index 000000000..c723af77c --- /dev/null +++ b/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, 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) { + if (eventSink == null) return + + try { + activity?.runOnUiThread { + try { + eventSink?.success(event) + } catch (e: Exception) { + // 忽略异常 + } + } + } catch (e: Exception) { + // 忽略异常 + } + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/lib/classic_bluetooth.dart b/local_plugins/classic_bluetooth/lib/classic_bluetooth.dart new file mode 100644 index 000000000..a9389d6b9 --- /dev/null +++ b/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 map) { + return BluetoothDevice( + name: map['name'] as String? ?? '未知设备', + address: map['address'] as String? ?? '', + type: map['type'] as String? ?? '未知类型', + ); + } + + /// 转换为Map + Map toMap() { + return { + 'name': name, + 'address': address, + 'type': type, + }; + } + + @override + String toString() => '$name ($address) [$type]'; +} + +/// 蓝牙事件 +class BluetoothEvent { + /// 事件类型 + final String type; + + /// 时间戳 + final int timestamp; + + /// 事件数据 + final Map data; + + /// 构造函数 + BluetoothEvent({ + required this.type, + required this.timestamp, + required this.data, + }); + + /// 从Map创建事件 + factory BluetoothEvent.fromMap(Map map) { + return BluetoothEvent( + type: map['type'] as String, + timestamp: map['timestamp'] as int, + data: Map.from(map) + ..remove('type') + ..remove('timestamp'), + ); + } + + /// 获取关联的设备(如果有) + BluetoothDevice? get device { + if (data.containsKey('device')) { + return BluetoothDevice.fromMap(Map.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? _eventStream; + + /// 获取蓝牙事件流 + static Stream get events { + _eventStream ??= _eventChannel.receiveBroadcastStream() + .map((dynamic event) => BluetoothEvent.fromMap(Map.from(event as Map))); + return _eventStream!; + } + + /// 获取当前连接的蓝牙设备 + static Future> getConnectedDevices() async { + final List devices = await _methodChannel.invokeMethod('getConnectedDevices'); + return devices + .map((device) => BluetoothDevice.fromMap(Map.from(device as Map))) + .toList(); + } + + /// 检查蓝牙是否启用 + static Future isBluetoothEnabled() async { + return await _methodChannel.invokeMethod('isBluetoothEnabled'); + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/pubspec.yaml b/local_plugins/classic_bluetooth/pubspec.yaml new file mode 100644 index 000000000..7ab5fd1f3 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/android/build.gradle.kts b/local_plugins/deepvoice_utils/android/build.gradle.kts new file mode 100644 index 000000000..4bfdcceaa --- /dev/null +++ b/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") + } + } +} diff --git a/local_plugins/deepvoice_utils/android/settings.gradle.kts b/local_plugins/deepvoice_utils/android/settings.gradle.kts new file mode 100644 index 000000000..f1b0c086e --- /dev/null +++ b/local_plugins/deepvoice_utils/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "deepvoice_utils" \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/android/src/main/AndroidManifest.xml b/local_plugins/deepvoice_utils/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c043918b0 --- /dev/null +++ b/local_plugins/deepvoice_utils/android/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/DeepvoiceUtilsPlugin.kt b/local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/DeepvoiceUtilsPlugin.kt new file mode 100644 index 000000000..a330e85b8 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/core/utils/FileLogger.kt b/local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/FileLogger.kt similarity index 99% rename from android/app/src/main/kotlin/com/yunqiinnovation/deepsound/core/utils/FileLogger.kt rename to local_plugins/deepvoice_utils/android/src/main/kotlin/com/yunqiinnovation/deepvoice_utils/FileLogger.kt index 03df82a3b..b31331689 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/core/utils/FileLogger.kt +++ b/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.util.Log diff --git a/local_plugins/deepvoice_utils/lib/deepvoice_utils.dart b/local_plugins/deepvoice_utils/lib/deepvoice_utils.dart new file mode 100644 index 000000000..e3387f790 --- /dev/null +++ b/local_plugins/deepvoice_utils/lib/deepvoice_utils.dart @@ -0,0 +1,3 @@ +library deepvoice_utils; + +export 'src/file_logger.dart'; \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/lib/src/file_logger.dart b/local_plugins/deepvoice_utils/lib/src/file_logger.dart new file mode 100644 index 000000000..eae665185 --- /dev/null +++ b/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()); + } + } +} \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/pubspec.yaml b/local_plugins/deepvoice_utils/pubspec.yaml new file mode 100644 index 000000000..24796b435 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt index e2796ee85..e81425f86 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -222,7 +222,7 @@ class MCPSubClient( // 使用toString()获取完整JSON字符串 val schemaStr = inputSchema.toString() - Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr") + // Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr") // 解析properties和required val propertiesMap = HashMap() @@ -278,7 +278,7 @@ class MCPSubClient( propertiesMap[fieldName] = fieldMap } - Log.d(TAG, "成功解析属性: $propertiesMap") + // Log.d(TAG, "成功解析属性: $propertiesMap") } // 解析required @@ -299,7 +299,7 @@ class MCPSubClient( } } requiredList.addAll(fieldNames) - Log.d(TAG, "解析到required字段: $requiredList") + // Log.d(TAG, "解析到required字段: $requiredList") } } } catch (e: Exception) { @@ -903,8 +903,8 @@ private fun createSslTrustAllClient(): HttpClient { .hostnameVerifier { _, _ -> true } .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) - .addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 - .addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 + // .addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 + // .addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 .build() // 创建使用OkHttp引擎的HttpClient diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index b659ba411..cadcad476 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ b/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() // 输出用于测试的curl命令 - val curlCommand = buildCurlCommand(request, requestBody.toString()) - Log.d(TAG, "curl command: $curlCommand") + // val curlCommand = buildCurlCommand(request, requestBody.toString()) + // Log.d(TAG, "curl command: $curlCommand") client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { diff --git a/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.bin b/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.bin index c73975cdc1f50950f9a31cf6cb225995f1b92e2c..47192677b76dcf390474d23c2f1599048fbbbe3a 100644 GIT binary patch delta 104 zcmex7fpO_X#tkMCl1#h5^-d1je20kv3_ObeL&0QI31vpdjfy|SHyYd!pX}&iWMm9f rq6x%`AS^I%VrKH&btP<1p3lwlJWvEQfN@%ejng`?IgC+0Kw1v~Wd0+) delta 32 ocmZ2Fk@52c#tkMCjEs|gC6pO8H!A)R-)Qhse4+vSWJeDp0J&-k*8l(j diff --git a/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock b/local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock index afdb194b93cb214b79111d22989afd087c620b76..a6fdf5ae2ab9cd81e316cdbd1c5bdf5732bdbd35 100644 GIT binary patch literal 17 UcmZQRo$b{-r|q*H0|bZx05?JedH?_b literal 17 UcmZQRo$b{-r|q*H0|YPw05>lLX8-^I diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock index a2d8b75c89b15fd04945a85eb2b33d6443b8fcc1..ee70b56b581d54729bb4e58882287ca0d3c97fe1 100644 GIT binary patch literal 17 TcmZQ}Dp$CADo{Lz0Rp4|DIEh^ literal 17 TcmZQ}Dp$CADo{Lz0Ror-DFy>W diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties b/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties index 816b483f3..9463138c9 100644 --- a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties +++ b/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Thu Apr 24 10:52:53 CST 2025 -gradle.version=8.9 +#Wed Apr 30 15:54:50 IST 2025 +gradle.version=8.10 diff --git a/local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt b/local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt deleted file mode 100644 index b50ba749c..000000000 --- a/local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt +++ /dev/null @@ -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? = 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("debugLogging") - if (null != dlog) { - debugLogging = dlog - } - var ausOpt = call.argument("alwaysUseStop") - if (null != ausOpt) { - alwaysUseStop = ausOpt == true - } - var iOpt = call.argument("intentLookup") - if (null != iOpt) { - intentLookup = iOpt == true - } - var noBtOpt = call.argument("noBluetooth") - if (null != noBtOpt) { - noBluetoothOpt = noBtOpt == true - } - initialize(result) - } - "listen" -> { - var localeId = call.argument("localeId") - if (null == localeId) { - localeId = defaultLanguageTag - } - var partialResults = call.argument("partialResults") - if (null == partialResults) { - partialResults = true - } - var onDevice = call.argument("onDevice") - if ( null == onDevice ) { - onDevice = false - } - val listenModeIndex = call.argument("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 = 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, 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? = 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()) - } - } - - public fun createResponse(supportedLanguages: List?) { - val currentLocale = Locale.getDefault() - val localeNames = ArrayList() - 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(); - } - } - } -} \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/build.gradle.kts b/local_plugins/volcano_speech/android/build.gradle.kts index 713a21a59..d46265ee5 100644 --- a/local_plugins/volcano_speech/android/build.gradle.kts +++ b/local_plugins/volcano_speech/android/build.gradle.kts @@ -60,5 +60,6 @@ tasks.withType { dependencies { // 添加火山引擎语音合成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") } \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt index d9b63819a..92073c7b4 100644 --- a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt +++ b/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.SpeechEngineGenerator import com.yunqiinnovation.volcano_speech.utils.FileLogger -import org.json.JSONArray import org.json.JSONException 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 currentAsrCallback: ASRCallback? = null - private var currentContinuousCallback: ASRContinuousCallback? = null + private var recognizeCallback: RecognizeCallback? = 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 enableVolume = false - private var showUtterances = false + private var recorderType = SpeechEngineDefines.RECORDER_TYPE_RECORDER + + // 音频参数 + 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 { - fun onSuccess(text: String) + interface RecognizeCallback { + fun onResult(text: String, detectedLanguage: String) fun onError(error: String) } /** - * ASR连续识别回调 + * 连续识别回调接口 */ - interface ASRContinuousCallback { - fun onResult(text: String) - fun onRecognizing(text: String) + interface ContinuousRecognizeCallback { + fun onResult(text: String, detectedLanguage: String) + fun onRecognizing(recognizing: String, detectedLanguage: String) fun onSessionStarted() fun onSessionStopped() - fun onVolumeChanged(volume: Int) fun onError(error: String) } @@ -64,13 +88,13 @@ class VolcanoAsrHelper(private val context: Context) { return true } + state = AsrState.INITIALIZING try { // 准备环境 try { SpeechEngineGenerator.PrepareEnvironment(context, null) } catch (e: Exception) { FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") - // 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类 } // 创建引擎 @@ -79,6 +103,7 @@ class VolcanoAsrHelper(private val context: Context) { if (engineHandler == -1L) { FileLogger.e(TAG, "创建引擎失败") + state = AsrState.ERROR 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) - // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) + // 设置用户ID和设备ID engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_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_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秒) 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_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) isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR if (isInitialized) { FileLogger.i(TAG, "引擎初始化成功") + state = AsrState.READY // 设置回调监听 engine?.setListener(object : SpeechEngine.SpeechListener { @@ -139,269 +189,396 @@ class VolcanoAsrHelper(private val context: Context) { return true } else { - // 错误码说明: - // -1:通用错误 - // -2:无效参数 - // -3:无效状态 - // -4:无效引擎 - // -5:操作失败 - // -6:缺少配置 - // -7:参数无效 - // -8:超时 - // -9:资源不足 - FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}") + FileLogger.e(TAG, "引擎初始化失败: $result") + state = AsrState.ERROR return false } } catch (e: Exception) { FileLogger.e(TAG, "初始化异常: ${e.message}", e) + state = AsrState.ERROR return false } } /** - * 设置语言 + * 设置音频输入类型 */ - fun setLanguage(language: String): Boolean { + fun setAudioInputType(type: String): Boolean { if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } + if (isEngineStarted) { + FileLogger.e(TAG, "引擎已启动,无法更改配置") + return false + } + try { - this.language = language - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language) + when (type) { + "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 } catch (e: Exception) { - FileLogger.e(TAG, "设置语言失败: ${e.message}", e) + FileLogger.e(TAG, "设置音频输入类型失败: ${e.message}", e) return false } } - + /** - * 设置热词 + * 设置音频参数 */ - fun setHotWords(hotWordsId: String): Boolean { + fun setAudioParams(sampleRate: Int, channels: Int): Boolean { if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } + if (isEngineStarted) { + FileLogger.e(TAG, "引擎已启动,无法更改配置") + return false + } + try { - if (hotWordsId.isNotEmpty()) { - // 大模型ASR需要通过请求参数设置热词 - val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}" - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + this.sampleRate = sampleRate + this.channels = channels + + // 设置采样率和通道数 + 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 } catch (e: Exception) { - FileLogger.e(TAG, "设置热词失败: ${e.message}", e) + FileLogger.e(TAG, "设置音频参数失败: ${e.message}", e) return false } } - + /** - * 设置是否返回音量 + * 设置语言 */ - fun setEnableVolume(enable: Boolean): Boolean { + fun setLanguage(language: String): Boolean { if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } try { - this.enableVolume = enable - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_GET_VOLUME_BOOL, enable) + this.language = language + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language) return true } catch (e: Exception) { - FileLogger.e(TAG, "设置音量返回失败: ${e.message}", e) + FileLogger.e(TAG, "设置语言失败: ${e.message}", e) return false } } - + /** - * 设置是否显示语音停顿、分句、分词信息 + * 设置热词 */ - fun setShowUtterances(show: Boolean): Boolean { + fun setHotWords(hotWordsId: String): Boolean { if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } try { - this.showUtterances = show - engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_UTTER_BOOL, show) + if (hotWordsId.isNotEmpty()) { + // 大模型ASR需要通过请求参数设置热词 + val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + } return true } catch (e: Exception) { - FileLogger.e(TAG, "设置语音信息显示失败: ${e.message}", e) + FileLogger.e(TAG, "设置热词失败: ${e.message}", e) return false } } /** - * 设置VAD切句参数 + * 一次性识别(异步) */ - fun setVadParams(forceToSpeechTime: Int, endWindowSize: Int): Boolean { + fun recognizeOnce(callback: RecognizeCallback): Boolean { + FileLogger.i(TAG, "开始一次性识别") if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } + if (isRecognizing) { + FileLogger.e(TAG, "当前已在识别中") + return false + } + try { - val reqParams = "{\"force_to_speech_time\":$forceToSpeechTime, \"end_window_size\":$endWindowSize}" - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + this.recognizeCallback = callback + 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 } catch (e: Exception) { - FileLogger.e(TAG, "设置VAD参数失败: ${e.message}", e) + FileLogger.e(TAG, "识别异常: ${e.message}", e) return false } } /** - * 设置纠错词表 + * 停止一次性识别 */ - fun setCorrectWords(correctWordsJson: String): Boolean { + fun stopRecognize(): Boolean { if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } + if (!isEngineStarted) { + FileLogger.e(TAG, "引擎未启动") + return false + } + try { - val reqParams = "{\"context\": \"{\\\"correct_words\\\": $correctWordsJson}\"}" - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + if (recorderType == SpeechEngineDefines.RECORDER_TYPE_RECORDER) { + // 麦克风输入-告知引擎音频输入完成 + 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 } catch (e: Exception) { - FileLogger.e(TAG, "设置纠错词表失败: ${e.message}", e) + FileLogger.e(TAG, "停止识别异常: ${e.message}", e) return false } } - + /** - * 一次性识别 + * 开始连续识别 */ - fun recognizeOnce(callback: ASRCallback): Boolean { + fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { + FileLogger.i(TAG, "开始连续识别") if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } - if (isContinuousRecognitionActive) { - FileLogger.e(TAG, "当前正在连续识别中") + if (isRecognizing) { + FileLogger.e(TAG, "当前已在识别中") return false } try { - this.currentAsrCallback = callback + this.continuousCallback = callback + this.recognizeCallback = null + this.isContinuousMode = true // 停止当前引擎 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, "") if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - FileLogger.e(TAG, "启动识别失败: $ret") + FileLogger.e(TAG, "启动连续识别失败: $ret") return false } + isRecognizing = true + isEngineStarted = true + state = AsrState.RECOGNIZING + FileLogger.i(TAG, "开始连续识别") return true } catch (e: Exception) { - FileLogger.e(TAG, "识别异常: ${e.message}", e) + FileLogger.e(TAG, "连续识别异常: ${e.message}", e) return false } } /** - * 停止一次性识别 + * 停止连续识别 */ - fun stopRecognize(): Boolean { - if (!isInitialized) { - FileLogger.e(TAG, "引擎未初始化") + fun stopContinuousRecognition(): Boolean { + if (!isInitialized || !isRecognizing) { + FileLogger.e(TAG, "引擎未初始化或未在识别中") return false } 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) { - FileLogger.e(TAG, "停止识别失败: $ret") + FileLogger.e(TAG, "停止连续识别失败: $ret") return false } + isRecognizing = false + isEngineStarted = false + state = AsrState.READY return true } catch (e: Exception) { - FileLogger.e(TAG, "停止识别异常: ${e.message}", e) + FileLogger.e(TAG, "停止连续识别异常: ${e.message}", e) return false } } /** - * 开始连续识别 + * 发送PCM音频数据到引擎 + * 注意:必须在setAudioInputType("stream")后,才能调用此方法 */ - fun startContinuousRecognition(callback: ASRContinuousCallback): Boolean { + fun feedAudio(data: ByteArray): Boolean { if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } - if (isContinuousRecognitionActive) { - FileLogger.e(TAG, "当前已经在连续识别中") + if (!isEngineStarted) { + FileLogger.e(TAG, "引擎未启动") + return false + } + + if (recorderType != SpeechEngineDefines.RECORDER_TYPE_STREAM) { + FileLogger.e(TAG, "当前录音类型不是stream模式,无法使用此方法") return false } try { - this.currentContinuousCallback = callback - - // 停止当前引擎 - engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") - - // 启动引擎开始识别 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + // 调用引擎feedAudio方法 + val ret = engine?.feedAudio(engineHandler, data, data.size) if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - FileLogger.e(TAG, "启动连续识别失败: $ret") + FileLogger.e(TAG, "发送音频数据失败: $ret") return false } - isContinuousRecognitionActive = true return true } catch (e: Exception) { - FileLogger.e(TAG, "连续识别异常: ${e.message}", e) + FileLogger.e(TAG, "发送音频数据异常: ${e.message}", e) return false } } /** - * 停止连续识别 + * 发送任意格式音频的最后一块数据 + * 告知引擎音频输入已结束 */ - fun stopContinuousRecognition(): Boolean { - if (!isInitialized || !isContinuousRecognitionActive) { - FileLogger.e(TAG, "引擎未初始化或未在连续识别中") + fun feedAudioEnd(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (!isEngineStarted) { + FileLogger.e(TAG, "引擎未启动") + return false + } + + if (recorderType != SpeechEngineDefines.RECORDER_TYPE_STREAM) { + FileLogger.e(TAG, "当前录音类型不是stream模式,无法使用此方法") return false } try { - // 停止引擎 - val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + // 空Buffer告知引擎输入结束 + val ret = engine?.feedAudio(engineHandler, ByteArray(0), 0) if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - FileLogger.e(TAG, "停止连续识别失败: $ret") + FileLogger.e(TAG, "发送音频结束标记失败: $ret") return false } - isContinuousRecognitionActive = false return true } catch (e: Exception) { - FileLogger.e(TAG, "停止连续识别异常: ${e.message}", e) + FileLogger.e(TAG, "发送音频结束标记异常: ${e.message}", e) 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 { - return isContinuousRecognitionActive + fun isRecognizing(): Boolean { + return isRecognizing } /** @@ -414,10 +591,10 @@ class VolcanoAsrHelper(private val context: Context) { try { // 停止引擎 - if (isContinuousRecognitionActive) { - stopContinuousRecognition() - } else { + if (isEngineStarted) { engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + isRecognizing = false + isEngineStarted = false } // 销毁引擎 @@ -425,6 +602,7 @@ class VolcanoAsrHelper(private val context: Context) { engineHandler = -1 engine = null isInitialized = false + state = AsrState.IDLE FileLogger.i(TAG, "引擎已释放") } catch (e: Exception) { @@ -439,101 +617,115 @@ class VolcanoAsrHelper(private val context: Context) { when (type) { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { // 引擎启动成功 + FileLogger.i(TAG, "引擎启动成功") mainHandler.post { - currentContinuousCallback?.onSessionStarted() + continuousCallback?.onSessionStarted() } } SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { // 引擎停止 - isContinuousRecognitionActive = false + FileLogger.i(TAG, "引擎停止") + isRecognizing = false + isEngineStarted = false + state = AsrState.READY + mainHandler.post { - currentContinuousCallback?.onSessionStopped() + continuousCallback?.onSessionStopped() } } SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> { // 中间识别结果 + FileLogger.i(TAG, "中间识别结果: $data") try { val json = JSONObject(data) if (!json.has("result")) { + FileLogger.w(TAG, "中间结果没有 result 字段: $data") 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 result = resultArray.getJSONObject(0) - val text = result.optString("text", "") - - if (text.isNotEmpty()) { - mainHandler.post { - currentContinuousCallback?.onRecognizing(text) - } + // 获取语言信息,如果没有则使用默认语言 + val detectedLanguage = json.optString("lang", language) + + if (text.isNotEmpty()) { + mainHandler.post { + FileLogger.i(TAG, "提取到中间文本: $text") + // 中间结果 + continuousCallback?.onRecognizing(text, detectedLanguage) } } - } catch (e: JSONException) { - FileLogger.e(TAG, "解析中间识别结果异常: ${e.message}", e) + } catch (e: Exception) { + FileLogger.e(TAG, "解析中间识别结果异常: ${e}") } } SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> { + FileLogger.i(TAG, "最终识别结果: $data") // 最终识别结果 try { val json = JSONObject(data) if (!json.has("result")) { + FileLogger.w(TAG, "最终结果没有 result 字段: $data") return } - val resultArray = json.getJSONArray("result") - if (resultArray.length() > 0) { - val result = resultArray.getJSONObject(0) - val text = result.optString("text", "") - - if (text.isNotEmpty()) { - mainHandler.post { - if (isContinuousRecognitionActive) { - currentContinuousCallback?.onResult(text) - } else { - currentAsrCallback?.onSuccess(text) - } + // 从多种可能的格式中获取文本 + val text = extractTextFromResult(json) + + + // 获取语言信息,如果没有则使用默认语言 + val detectedLanguage = json.optString("lang", language) + + if (text.isNotEmpty()) { + mainHandler.post { + if (isContinuousMode) { + FileLogger.i(TAG, "发送最终识别结果: $text (连续模式)") + continuousCallback?.onResult(text, detectedLanguage) + + // 在连续模式下,不需要手动触发下一轮识别,引擎会自动继续监听 + // 但如果需要明确标记新一轮开始,可以在此处添加代码 + } else { + FileLogger.i(TAG, "发送最终识别结果: $text (单次模式)") + recognizeCallback?.onResult(text, detectedLanguage) } } } - } catch (e: JSONException) { - FileLogger.e(TAG, "解析最终识别结果异常: ${e.message}", e) + } catch (e: Exception) { + val errMsg = "解析最终识别结果异常: ${e.message}" + FileLogger.e(TAG, errMsg) + FileLogger.e(TAG, "原始数据: $data") + FileLogger.e(TAG, "详细异常信息", e) } } 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 -> { // 错误信息 try { val json = JSONObject(data) - val errCode = json.optInt("err_code", -1) 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 { - if (isContinuousRecognitionActive) { - currentContinuousCallback?.onError(errMsg) - isContinuousRecognitionActive = false + if (isContinuousMode) { + continuousCallback?.onError(errMsg) } else { - currentAsrCallback?.onError(errMsg) + recognizeCallback?.onError(errMsg) } + isRecognizing = false + isEngineStarted = false } } catch (e: JSONException) { FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) @@ -541,22 +733,51 @@ class VolcanoAsrHelper(private val context: Context) { } } } - + /** - * 获取错误描述 + * 从多种可能的JSON格式中提取文本 */ - 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" + private fun extractTextFromResult(json: JSONObject): String { + try { + if (!json.has("result")) { + return "" + } + + // 获取result字段(可能是JSONObject或JSONArray) + val resultObj = json.get("result") + + // 根据result的实际类型处理 + val text = when (resultObj) { + // 情况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 "" } } } diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt index bb6f1e452..303f92072 100644 --- a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt +++ b/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 var ttsEventSink: EventChannel.EventSink? = null + // TTS音频数据相关 + private lateinit var ttsAudioDataChannel: EventChannel + private var ttsAudioDataSink: EventChannel.EventSink? = null + // TTS帮助类 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帮助类 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帮助类 volcanoAsrHelper = VolcanoAsrHelper(context) @@ -79,7 +125,6 @@ class VolcanoSpeechPlugin: FlutterPlugin { // 发送ASR事件 private fun sendAsrEvent(event: Map) { - FileLogger.d(TAG, "发送ASR事件: $event") if (asrEventSink == null) { FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") return @@ -88,7 +133,6 @@ class VolcanoSpeechPlugin: FlutterPlugin { mainHandler.post { try { asrEventSink?.success(event) - FileLogger.d(TAG, "ASR事件发送成功") } catch (e: Exception) { 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方法处理器 inner class AsrMethodHandler : MethodCallHandler { @@ -122,7 +181,7 @@ class VolcanoSpeechPlugin: FlutterPlugin { } "initialize" -> { val appId = call.argument("appId") ?: "" - val apiKey = call.argument("apiKey") ?: "" + val apiKey = call.argument("appToken") ?: "" val resourceId = call.argument("resourceId") ?: "" if (appId.isEmpty() || apiKey.isEmpty() || resourceId.isEmpty()) { @@ -141,35 +200,20 @@ class VolcanoSpeechPlugin: FlutterPlugin { val hotWordsId = call.argument("hotWordsId") ?: "" result.success(volcanoAsrHelper.setHotWords(hotWordsId)) } - "setVadParams" -> { - val forceToSpeechTime = call.argument("forceToSpeechTime") ?: 0 - val endWindowSize = call.argument("endWindowSize") ?: 800 - result.success(volcanoAsrHelper.setVadParams(forceToSpeechTime, endWindowSize)) - } - "setCorrectWords" -> { - val correctWordsJson = call.argument("correctWordsJson") ?: "{}" - result.success(volcanoAsrHelper.setCorrectWords(correctWordsJson)) - } - "setEnableVolume" -> { - val enable = call.argument("enable") ?: false - result.success(volcanoAsrHelper.setEnableVolume(enable)) - } - "setShowUtterances" -> { - val enable = call.argument("enable") ?: false - result.success(volcanoAsrHelper.setShowUtterances(enable)) - } "recognizeOnce" -> { + // 旧方式,不再推荐使用 // 确保当前不在连续识别中 - if (volcanoAsrHelper.isContinuousRecognitionActive()) { - result.error("ASR_BUSY", "当前正在连续识别中", null) + if (volcanoAsrHelper.isRecognizing()) { + result.error("ASR_BUSY", "当前正在识别中", null) return } - volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.ASRCallback { - override fun onSuccess(text: String) { + volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.RecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) { mainHandler.post { 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" -> { // 确保事件通道已准备好 if (asrEventSink == null) { @@ -188,18 +267,20 @@ class VolcanoSpeechPlugin: FlutterPlugin { return } - val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ASRContinuousCallback { - override fun onResult(text: String) { + val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ContinuousRecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) { sendAsrEvent(mapOf( "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( "type" to "recognizing", - "text" to text + "text" to text, + "language" to detectedLanguage )) } @@ -210,13 +291,7 @@ class VolcanoSpeechPlugin: FlutterPlugin { override fun onSessionStopped() { sendAsrEvent(mapOf("type" to "sessionStopped")) } - - override fun onVolumeChanged(volume: Int) { - sendAsrEvent(mapOf( - "type" to "volumeChanged", - "volume" to volume - )) - } + override fun onError(error: String) { sendAsrEvent(mapOf( @@ -237,7 +312,14 @@ class VolcanoSpeechPlugin: FlutterPlugin { result.success(success) } "isContinuousRecognitionActive" -> { - result.success(volcanoAsrHelper.isContinuousRecognitionActive()) + result.success(volcanoAsrHelper.isRecognizing()) + } + "feedAudioData" -> { + val data = call.argument("data") ?: ByteArray(0) + result.success(volcanoAsrHelper.feedAudio(data)) + } + "feedAudioEnd" -> { + result.success(volcanoAsrHelper.feedAudioEnd()) } "release" -> { volcanoAsrHelper.release() @@ -259,15 +341,14 @@ class VolcanoSpeechPlugin: FlutterPlugin { } "initialize" -> { val appId = call.argument("appId") ?: "" - val token = call.argument("token") ?: "" - val resourceId = call.argument("resourceId") ?: "" + val token = call.argument("appToken") ?: "" - if (appId.isEmpty() || token.isEmpty() || resourceId.isEmpty()) { - result.error("INVALID_ARGUMENTS", "appId、token和resourceId不能为空", null) + if (appId.isEmpty() || token.isEmpty()) { + result.error("INVALID_ARGUMENTS", "appId和token不能为空", null) return } - val success = volcanoTtsHelper.initialize(appId, token, resourceId) + val success = volcanoTtsHelper.initialize(appId, token) result.success(success) } "setVoice" -> { @@ -280,11 +361,7 @@ class VolcanoSpeechPlugin: FlutterPlugin { result.success(volcanoTtsHelper.setVoice(voice)) } - "setContinuousMode" -> { - val isContinuous = call.argument("isContinuous") ?: false - result.success(volcanoTtsHelper.setContinuousMode(isContinuous)) - } - "speak" -> { + "speakOnce" -> { val text = call.argument("text") ?: "" if (text.isEmpty()) { @@ -292,48 +369,10 @@ class VolcanoSpeechPlugin: FlutterPlugin { 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) - } - } - }) + val success = volcanoTtsHelper.speakOnce(text) + result.success(success) } - "synthesisNext" -> { + "speakStream" -> { val text = call.argument("text") ?: "" if (text.isEmpty()) { @@ -341,90 +380,30 @@ class VolcanoSpeechPlugin: FlutterPlugin { return } - val success = volcanoTtsHelper.synthesisNext(text) + val success = volcanoTtsHelper.speakStream(text) result.success(success) } - "pause" -> { - result.success(volcanoTtsHelper.pause()) - } - "resume" -> { - result.success(volcanoTtsHelper.resume()) + "flushStream" -> { + val success = volcanoTtsHelper.flushStream() + result.success(success) } "stop" -> { result.success(volcanoTtsHelper.stop()) } - "release" -> { - volcanoTtsHelper.release() - result.success(true) + "isSpeaking" -> { + result.success(volcanoTtsHelper.isSpeaking()) } - // 以下方法用于与Azure Speech版本兼容 - "setSpeechSynthesisVoice" -> { - val voiceName = call.argument("voiceName") ?: "" - - if (voiceName.isEmpty()) { - result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) - return - } - - result.success(volcanoTtsHelper.setVoice(voiceName)) + "getCurrentVoice" -> { + result.success(volcanoTtsHelper.getCurrentVoice()) } - "speakText" -> { - val text = call.argument("text") ?: "" - - if (text.isEmpty()) { - 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()) + "enableAudioCallback" -> { + val enable = call.argument("enable") ?: false + volcanoTtsHelper.setEnableAudioCallback(enable) + result.success(true) } - "resumeSpeaking" -> { - result.success(volcanoTtsHelper.resume()) + "release" -> { + volcanoTtsHelper.dispose() + result.success(true) } else -> { result.notImplemented() @@ -438,8 +417,9 @@ class VolcanoSpeechPlugin: FlutterPlugin { ttsChannel.setMethodCallHandler(null) asrEventChannel.setStreamHandler(null) ttsEventChannel.setStreamHandler(null) + ttsAudioDataChannel.setStreamHandler(null) - volcanoTtsHelper.release() + volcanoTtsHelper.dispose() volcanoAsrHelper.release() } } \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt index 4642272d4..46fd10269 100644 --- a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt +++ b/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.yunqiinnovation.volcano_speech.utils.FileLogger import org.json.JSONObject +import java.util.concurrent.ConcurrentLinkedQueue /** - * 火山语音合成帮助类 (大模型版本) + * 火山语音合成服务 - 符合TtsService接口要求 */ class VolcanoTtsHelper(private val context: Context) { private val TAG = "VolcanoTtsHelper" @@ -21,44 +22,157 @@ class VolcanoTtsHelper(private val context: Context) { private var engineHandler: Long = -1 private var isInitialized = false - // 当前回调 - private var currentTtsCallback: TTSCallback? = null - - // 合成状态 + // 语音合成状态 + private var isEngineStarted = false private var isPlaying = false private var currentReqId = "" // 配置参数 private var voice = "zh_male_M392_conversation_wvae_bigtts" - private var ttsText = "" - private var isContinuous = false - + + // 文本队列管理 + private val pendingTexts = ConcurrentLinkedQueue() + 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() + + // 事件监听 + private val listeners = mutableListOf() + /** - * TTS合成回调接口 + * TTS事件类型 */ - interface TTSCallback { - fun onStart(reqId: String) - fun onProgress(reqId: String, progress: Double) - fun onComplete(reqId: String) - fun onError(reqId: String, errorCode: Int, errorMsg: String) + enum class TtsEventType { + STARTED, + COMPLETED, + CANCELED, + 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) { - FileLogger.i(TAG, "引擎已经初始化") return true } - FileLogger.d(TAG, "初始化语音合成引擎: $appId, $token, $resourceId") + try { // 准备环境 try { SpeechEngineGenerator.PrepareEnvironment(context, null) } catch (e: Exception) { - FileLogger.w(TAG, "准备环境时发生异常: ${e.message},尝试继续初始化") - // 即使PrepareEnvironment失败仍继续执行,因为某些环境下可能找不到特定类 + FileLogger.w(TAG, "准备环境时发生异常: ${e.message}") } // 创建引擎 @@ -66,235 +180,318 @@ class VolcanoTtsHelper(private val context: Context) { engineHandler = engine?.createEngine() ?: -1 if (engineHandler == -1L) { - FileLogger.e(TAG, "创建引擎失败") + handleError(-1, "创建引擎失败") return false } - // 设置上下文 + // 设置基本参数 engine?.setContext(context) - - // 设置引擎类型为TTS 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) - // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_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_TOKEN_STRING, "Bearer;$token") - - // 设置资源ID - 大模型必需 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, "Bearer;${token}") - // 设置协议类型为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?.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_CLUSTER_STRING, "volcano_tts") - // 设置发音人 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, voice) - // 设置播放进度回调 + // 设置播放进度回调和播放器 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) isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR if (isInitialized) { - FileLogger.i(TAG, "引擎初始化成功") - // 设置回调监听 engine?.setListener(object : SpeechEngine.SpeechListener { override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { - val stdData = String(data) - handleEngineEvent(type, stdData) + handleEngineEvent(type, String(data), data) } }) return true } else { - // 错误码说明: - // -1:通用错误 - // -2:无效参数 - // -3:无效状态 - // -4:无效引擎 - // -5:操作失败 - // -6:缺少配置 - // -7:参数无效 - // -8:超时 - // -9:资源不足 - FileLogger.e(TAG, "引擎初始化失败: $result, 可能原因: ${getErrorDescription(result)}") + handleError(result ?: -1, "引擎初始化失败") return false } } catch (e: Exception) { - FileLogger.e(TAG, "初始化异常: ${e.message}", e) + handleError(-1, "初始化异常: ${e.message}") return false } } /** - * 设置发音人 + * 统一处理错误 */ - fun setVoice(voice: String): Boolean { - if (!isInitialized) { - FileLogger.e(TAG, "引擎未初始化") - return false - } - - 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 + private fun handleError(code: Int, message: String, e: Exception? = null) { + if (e != null) { + FileLogger.e(TAG, message, e) + } else { + FileLogger.e(TAG, message) } + dispatchEvent(TtsEvent(TtsEventType.ERROR, code, message)) } - + /** - * 设置合成场景(单次或连续) + * 设置语音 */ - fun setContinuousMode(isContinuous: Boolean): Boolean { + fun setVoice(voiceName: String): Boolean { if (!isInitialized) { FileLogger.e(TAG, "引擎未初始化") return false } try { - this.isContinuous = isContinuous - val scenarioType = if (isContinuous) { - SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL - } else { - SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL - } - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, scenarioType) + this.voice = voiceName + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, voiceName) return true } catch (e: Exception) { - FileLogger.e(TAG, "设置合成场景失败: ${e.message}", e) + handleError(-1, "设置发音人失败: ${e.message}", e) return false } } /** - * 开始合成并播放 + * 单次播放文本 */ - fun speak(text: String, callback: TTSCallback? = null): Boolean { - FileLogger.d(TAG, "开始合成并播放: $text") - if (!isInitialized) { - FileLogger.e(TAG, "引擎未初始化") + fun speakOnce(text: String): Boolean { + if (text.isEmpty()) { 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()) { - FileLogger.e(TAG, "合成文本为空") return false } - try { - this.ttsText = text - this.currentTtsCallback = callback - - // 设置要合成的文本 - engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) + // 设置连续播放模式 + if (!isContinuousMode) { + isContinuousMode = true + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL) + // 启用音频数据回调(如果需要) + 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, "") - // 启动引擎开始合成 + // 启动引擎 val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - FileLogger.e(TAG, "启动合成失败: $ret") + handleError(ret ?: -1, "启动引擎失败") + isProcessingQueue = false return false } + isEngineStarted = true + retryCount = MAX_RETRY_COUNT + return true } catch (e: Exception) { - FileLogger.e(TAG, "合成异常: ${e.message}", e) + handleError(-1, "启动引擎异常: ${e.message}", e) + isProcessingQueue = false return false } } /** - * 仅用于连续合成场景:在引擎启动后添加新的文本进行合成 + * 启动引擎并设置文本(适用于单次模式) */ - fun synthesisNext(text: String): Boolean { - if (!isInitialized || !isContinuous) { - FileLogger.e(TAG, "引擎未初始化或非连续合成模式") - return false - } - - if (text.isEmpty()) { - FileLogger.e(TAG, "合成文本为空") + private fun startEngineWithText(text: String, isStream: Boolean): Boolean { + if (!isInitialized) { return false } try { + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + // 设置要合成的文本 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) { - FileLogger.e(TAG, "添加合成文本失败: $ret") + handleError(ret ?: -1, "启动合成失败") + isProcessingQueue = false return false } + isEngineStarted = true + retryCount = MAX_RETRY_COUNT + return true } catch (e: Exception) { - FileLogger.e(TAG, "添加合成文本异常: ${e.message}", e) + handleError(-1, "合成异常: ${e.message}", e) + isProcessingQueue = false return false } } - + /** - * 暂停播放 + * 播放队列中剩余文本并标记流式输入结束 */ - fun pause(): Boolean { - if (!isInitialized || !isPlaying) { - FileLogger.e(TAG, "引擎未初始化或未在播放") - return false + fun flushStream(): Boolean { + if (pendingTexts.isEmpty() && !isEngineStarted) { + isProcessingQueue = 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 { - 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 } catch (e: Exception) { - FileLogger.e(TAG, "暂停播放异常: ${e.message}", e) + FileLogger.e(TAG, "关闭引擎异常", e) return false } } - + /** - * 恢复播放 + * 在回调中触发下一句合成 */ - fun resume(): Boolean { - if (!isInitialized) { - FileLogger.e(TAG, "引擎未初始化") + private fun triggerNextSynthesis(): Boolean { + if (!isEngineStarted || pendingTexts.isEmpty() || isSynthesisInProgress) { return false } + val nextText = pendingTexts.poll() + currentText = nextText + 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) { - FileLogger.e(TAG, "恢复播放异常: ${e.message}", e) + isSynthesisInProgress = false + handleError(-1, "触发合成异常: ${e.message}", e) return false } } @@ -304,16 +501,32 @@ class VolcanoTtsHelper(private val context: Context) { */ fun stop(): Boolean { if (!isInitialized) { - FileLogger.e(TAG, "引擎未初始化") return false } + // 已经不在播放,无需操作 + if (!isPlaying && !isEngineStarted) { + return true + } + try { val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") isPlaying = false + isEngineStarted = false + isSynthesisInProgress = false + + // 清空队列 + pendingTexts.clear() + isProcessingQueue = false + isContinuousMode = false + synthesisFromPlayer = false + + // 发送取消事件 + dispatchEvent(TtsEvent(TtsEventType.CANCELED)) + return ret == SpeechEngineDefines.ERR_NO_ERROR } catch (e: Exception) { - FileLogger.e(TAG, "停止播放异常: ${e.message}", e) + FileLogger.e(TAG, "停止播放异常", e) return false } } @@ -321,7 +534,7 @@ class VolcanoTtsHelper(private val context: Context) { /** * 释放资源 */ - fun release() { + fun dispose() { if (!isInitialized) { return } @@ -335,104 +548,164 @@ class VolcanoTtsHelper(private val context: Context) { engineHandler = -1 engine = null isInitialized = false - - FileLogger.i(TAG, "引擎已释放") + pendingTexts.clear() + listeners.clear() + audioDataListeners.clear() } 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) { SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { - // 引擎启动成功,获取请求ID - currentReqId = data - isPlaying = true - - mainHandler.post { - currentTtsCallback?.onStart(currentReqId) - } + // 引擎启动成功 + currentReqId = stringData + isEngineStarted = true - if (isContinuous) { - // 在连续合成模式下,需要单独发送合成指令 - synthesisNext(ttsText) + // 如果是连续模式且队列不为空,在引擎启动后立即触发第一句合成 + if (isContinuousMode && !pendingTexts.isEmpty() && !isSynthesisInProgress) { + triggerNextSynthesis() } } SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { // 引擎停止 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 -> { // 开始播放 - FileLogger.d(TAG, "开始播放: $data") + isPlaying = true + dispatchEvent(TtsEvent(TtsEventType.STARTED)) } SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { // 播放结束 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 -> { - // 播放进度 - try { - val json = JSONObject(data) - 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_TTS_AUDIO_DATA -> { + // 音频数据回调 + if (rawData != null && rawData.isNotEmpty()) { + dispatchAudioData(rawData) } } SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { // 错误信息 try { - val json = JSONObject(data) - val reqId = json.optString("reqid", "") + val json = JSONObject(stringData) val errCode = json.optInt("err_code", -1) 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) { - 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" - } - } } diff --git a/local_plugins/volcano_speech/lib/volcano_speech.dart b/local_plugins/volcano_speech/lib/volcano_speech.dart deleted file mode 100644 index 99da7d4b7..000000000 --- a/local_plugins/volcano_speech/lib/volcano_speech.dart +++ /dev/null @@ -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 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 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 _eventStreamController = StreamController.broadcast(); - - /// ASR事件流 - Stream 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 getPlatformVersion() async { - return await _channel.invokeMethod('getPlatformVersion'); - } - - /// 初始化语音识别引擎 - /// - /// [appId] 火山引擎AppID - /// [apiKey] 火山引擎ApiKey - /// [supportedLanguages] 支持的语言列表 - /// [useBigModel] 是否使用大模型识别 - /// [resourceId] 大模型资源ID(仅当useBigModel为true时有效) - Future initialize({ - required String appId, - required String apiKey, - List 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 setLanguage(String language) async { - return await _channel.invokeMethod('setLanguage', { - 'language': language, - }) ?? false; - } - - /// 设置热词 - /// - /// [hotWords] 热词JSON字符串,例如 {"hotwords":[{"word":"快速入门","scale":2.0}]} - Future setHotWords(String hotWords) async { - return await _channel.invokeMethod('setHotWords', { - 'hotWords': hotWords, - }) ?? false; - } - - /// 设置ASR请求参数 - /// - /// [params] 请求参数JSON字符串 - Future setRequestParams(String params) async { - return await _channel.invokeMethod('setRequestParams', { - 'params': params, - }) ?? false; - } - - /// 启用语音停顿、分句、分词信息输出 - /// - /// [enable] 是否启用 - Future setShowUtterances(bool enable) async { - return await _channel.invokeMethod('setShowUtterances', { - 'enable': enable, - }) ?? false; - } - - /// 一次性识别(直到说话结束) - /// - /// 返回识别结果文本和语言 - Future> recognizeOnce() async { - final result = await _channel.invokeMethod('recognizeOnce'); - return { - 'text': result['text'] ?? '', - 'language': result['language'] ?? 'zh-CN', - }; - } - - /// 开始连续识别 - /// - /// 通过[events]流监听识别结果 - Future startContinuousRecognition() async { - return await _channel.invokeMethod('startContinuousRecognition') ?? false; - } - - /// 停止连续识别 - Future stopContinuousRecognition() async { - return await _channel.invokeMethod('stopContinuousRecognition') ?? false; - } - - /// 检查是否正在连续识别 - Future isContinuousRecognitionActive() async { - return await _channel.invokeMethod('isContinuousRecognitionActive') ?? false; - } - - /// 开始长按识别(按下开始,抬起结束) - /// - /// 通过[events]流监听识别结果 - Future startListening() async { - return await _channel.invokeMethod('startListening') ?? false; - } - - /// 停止长按识别 - Future stopListening() async { - return await _channel.invokeMethod('stopListening') ?? false; - } - - /// 释放ASR资源 - Future 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 _progressStreamController = StreamController.broadcast(); - - /// TTS进度事件流 - Stream 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 getPlatformVersion() async { - return await _channel.invokeMethod('getPlatformVersion'); - } - - /// 启用大模型TTS - /// - /// [enable] 是否启用大模型TTS - /// [resourceId] 资源ID - Future enableBigModelTts({ - required bool enable, - String resourceId = '', - }) async { - return await _channel.invokeMethod('enableBigModelTts', { - 'enable': enable, - 'resourceId': resourceId, - }) ?? false; - } - - /// 初始化语音合成引擎 - /// - /// [appId] 火山引擎AppID - /// [apiKey] 火山引擎ApiKey - Future initialize({ - required String appId, - required String apiKey, - }) async { - return await _channel.invokeMethod('initialize', { - 'appId': appId, - 'apiKey': apiKey, - }) ?? false; - } - - /// 设置音色 - /// - /// [voiceName] 音色名称 - /// [voiceType] 音色类型,默认 qingxin - Future setVoice({ - required String voiceName, - String voiceType = 'qingxin', - }) async { - return await _channel.invokeMethod('setVoice', { - 'voiceName': voiceName, - 'voiceType': voiceType, - }) ?? false; - } - - /// 设置离线音色 - /// - /// [voiceName] 音色名称 - /// [voiceType] 音色类型,默认 qingxin - Future setOfflineVoice({ - required String voiceName, - String voiceType = 'qingxin', - }) async { - return await _channel.invokeMethod('setOfflineVoice', { - 'voiceName': voiceName, - 'voiceType': voiceType, - }) ?? false; - } - - /// 设置大模型声音ID - /// - /// [voiceId] 声音ID - Future setBigModelVoiceId(String voiceId) async { - return await _channel.invokeMethod('setBigModelVoiceId', { - 'voiceId': voiceId, - }) ?? false; - } - - /// 设置大模型请求参数 - /// - /// [params] 请求参数,JSON字符串 - Future setBigModelRequestParams(String params) async { - return await _channel.invokeMethod('setBigModelRequestParams', { - 'params': params, - }) ?? false; - } - - /// 设置工作模式 - /// - /// [mode] 工作模式 - Future 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 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 setEnableCache(bool enable) async { - return await _channel.invokeMethod('setEnableCache', { - 'enable': enable, - }) ?? false; - } - - /// 设置情感 - /// - /// [emotion] 情感,例如 neutral、happy、angry、sad等 - Future setEmotion(String emotion) async { - return await _channel.invokeMethod('setEmotion', { - 'emotion': emotion, - }) ?? false; - } - - /// 设置是否启用情感预测 - /// - /// [enable] 是否启用 - Future setEnableEmotionPredict(bool enable) async { - return await _channel.invokeMethod('setEnableEmotionPredict', { - 'enable': enable, - }) ?? false; - } - - /// 设置是否启用声音克隆 - /// - /// [enable] 是否启用 - /// [backendCluster] 后端集群 - Future setEnableVoiceClone({ - required bool enable, - String backendCluster = '', - }) async { - return await _channel.invokeMethod('setEnableVoiceClone', { - 'enable': enable, - 'backendCluster': backendCluster, - }) ?? false; - } - - /// 设置是否启用回声消除 - /// - /// [enable] 是否启用 - Future setEnableAEC(bool enable) async { - return await _channel.invokeMethod('setEnableAEC', { - 'enable': enable, - }) ?? false; - } - - /// 下载离线资源 - /// - /// [voiceTypes] 音色类型列表 - /// [languages] 语言列表 - Future downloadOfflineResource({ - List voiceTypes = const ['qingxin'], - List 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 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 setAudioOutputType(String outputType) async { - return await _channel.invokeMethod('setAudioOutputType', { - 'outputType': outputType, - }) ?? false; - } - - /// 合成并播放文本 - /// - /// [text] 待合成的文本 - Future speakText(String text) async { - return await _channel.invokeMethod('speakText', { - 'text': text, - }) ?? false; - } - - /// 使用大模型合成并播放文本 - /// - /// [text] 待合成的文本 - /// [voiceId] 声音ID - /// [params] 额外参数,JSON字符串 - Future speakWithBigModel({ - required String text, - required String voiceId, - String params = '', - }) async { - return await _channel.invokeMethod('speakWithBigModel', { - 'text': text, - 'voiceId': voiceId, - 'params': params, - }) ?? false; - } - - /// 暂停播放 - Future pausePlayback() async { - return await _channel.invokeMethod('pausePlayback') ?? false; - } - - /// 恢复播放 - Future resumePlayback() async { - return await _channel.invokeMethod('resumePlayback') ?? false; - } - - /// 停止播放 - Future stopSpeaking() async { - return await _channel.invokeMethod('stopSpeaking') ?? false; - } - - /// 释放TTS资源 - Future dispose() async { - return await _channel.invokeMethod('dispose') ?? false; - } -} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 303d4b581..e208e7a73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,8 +58,10 @@ dependencies: firebase_auth: ^5.5.1 google_sign_in: ^6.2.2 flutter_facebook_auth: ^7.1.1 - common_utils: ^2.1.0 flutter_image_compress: ^2.4.0 + common_utils: ^2.1.0 + deepvoice_utils: + path: local_plugins/deepvoice_utils # image_cropper: ^9.0.0 dio: ^5.8.0+1 sqflite: ^2.4.2 @@ -68,8 +70,6 @@ dependencies: path: local_plugins/azure_speech open_ai_service: path: local_plugins/open_ai_service - volcano_speech: - path: local_plugins/volcano_speech jieli: path: local_plugins/jieli chat_storage: @@ -80,6 +80,10 @@ dependencies: path: amap_flutter jl_opus: path: local_plugins/jl_opus + ble_service: + path: local_plugins/ble_service + classic_bluetooth: + path: local_plugins/classic_bluetooth dev_dependencies: diff --git a/test.json b/test.json index 3a3b7be33..19f06549a 100644 --- a/test.json +++ b/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 'Content-Type: application/json' \ -d '{ - "model": "bot-20250405211523-l7c9r", - "stream": true, - "stream_options": {"include_usage": true}, - "messages": [ - { - "role": "system", - "content": "You are a helpful assistant." - }, - { - "role": "user", - "content": "Hello!" - } - ] + "model": "bot-20250405211523-l7c9r", + "stream": true, + "stream_options": { + "include_usage": true + }, + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant." + }, + { + "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 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' \ -d '{ @@ -178,7 +180,19 @@ curl -X POST "https://www.deapsound.com/messages?sessionId=f156e609-6875-420b-b0 -H "Accept: */*" \ -H "User-Agent: ktor-client" \ -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 "User-Agent: ktor-client" \ -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" \ -H "Accept: text/event-stream,text/event-stream" \ -H "Cache-Control: no-cache,no-store" \ -H "Accept-Charset: UTF-8" \ - -H "User-Agent: ktor-client" \ No newline at end of file + -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,