90 changed files with 11745 additions and 5750 deletions
@ -1,7 +0,0 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<!-- The INTERNET permission is required for development. Specifically, |
|||
the Flutter tool needs it to communicate with the running application |
|||
to allow setting breakpoints, to provide hot reload, etc. |
|||
--> |
|||
<uses-permission android:name="android.permission.INTERNET"/> |
|||
</manifest> |
|||
@ -1,45 +0,0 @@ |
|||
package com.yunqiinnovation.deepsound |
|||
|
|||
import android.content.BroadcastReceiver |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.os.Build |
|||
import com.yunqiinnovation.deepsound.core.utils.FileLogger |
|||
|
|||
/** |
|||
* 蓝牙BLE广播接收器 |
|||
* 用于接收系统通过PendingIntent触发的蓝牙扫描结果 |
|||
* 即使应用进程已被杀死,系统也能通过此接收器唤醒应用 |
|||
*/ |
|||
class BleBroadcastReceiver : BroadcastReceiver() { |
|||
|
|||
companion object { |
|||
private const val TAG = "BleBroadcastReceiver" |
|||
|
|||
// 蓝牙扫描结果Action |
|||
const val ACTION_BLE_SCAN_RESULT = "com.yunqiinnovation.deepsound.ACTION_BLE_SCAN_RESULT" |
|||
} |
|||
|
|||
override fun onReceive(context: Context, intent: Intent) { |
|||
FileLogger.init(context) |
|||
//FileLogger.d(TAG, "接收到广播: ${intent.action}") |
|||
|
|||
if (intent.action == ACTION_BLE_SCAN_RESULT) { |
|||
//FileLogger.d(TAG, "收到系统BLE扫描唤醒事件") |
|||
|
|||
// 打印日志,表明唤醒成功 |
|||
//FileLogger.d(TAG, "应用被蓝牙BLE广播唤醒成功") |
|||
|
|||
// 启动BLE唤醒服务,该服务将执行后续操作 |
|||
val serviceIntent = Intent(context, BleWakeService::class.java) |
|||
serviceIntent.action = "android.bluetooth.adapter.action.REQUEST_ENABLE" |
|||
|
|||
// 对于Android 8.0及以上版本,必须使用startForegroundService启动前台服务 |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
context.startForegroundService(serviceIntent) |
|||
} else { |
|||
context.startService(serviceIntent) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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}") |
|||
} |
|||
} |
|||
} |
|||
@ -1,242 +0,0 @@ |
|||
package com.yunqiinnovation.deepsound |
|||
|
|||
import android.bluetooth.BluetoothAdapter |
|||
import android.bluetooth.BluetoothProfile |
|||
import android.content.Context |
|||
import android.content.pm.PackageManager |
|||
import android.os.Build |
|||
import android.util.Log |
|||
import android.Manifest |
|||
import androidx.core.content.ContextCompat |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
import java.util.concurrent.CountDownLatch |
|||
import java.util.concurrent.TimeUnit |
|||
|
|||
/** |
|||
* 经典蓝牙辅助类,用于获取已连接的蓝牙设备信息 |
|||
*/ |
|||
class ClassicBluetoothHelper(private val context: Context) { |
|||
|
|||
private val TAG = "ClassicBluetoothHelper" |
|||
|
|||
// 缓存蓝牙代理对象 |
|||
private var a2dpProxy: BluetoothProfile? = null |
|||
private var headsetProxy: BluetoothProfile? = null |
|||
|
|||
/** |
|||
* 获取已连接的A2DP设备(音频输出设备) |
|||
* @param callback 回调接口,返回设备列表或错误信息 |
|||
*/ |
|||
fun getConnectedA2dpDevices(callback: BluetoothDevicesCallback) { |
|||
getConnectedBluetoothDevices(BluetoothProfile.A2DP, callback) |
|||
} |
|||
|
|||
/** |
|||
* 获取已连接的蓝牙耳机设备 |
|||
* @param callback 回调接口,返回设备列表或错误信息 |
|||
*/ |
|||
fun getConnectedHeadsetDevices(callback: BluetoothDevicesCallback) { |
|||
getConnectedBluetoothDevices(BluetoothProfile.HEADSET, callback) |
|||
} |
|||
|
|||
/** |
|||
* 检查是否有蓝牙连接权限 |
|||
* @return 是否有权限 |
|||
*/ |
|||
private fun hasBluetoothConnectPermission(): Boolean { |
|||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
|||
ContextCompat.checkSelfPermission( |
|||
context, |
|||
Manifest.permission.BLUETOOTH_CONNECT |
|||
) == PackageManager.PERMISSION_GRANTED |
|||
} else { |
|||
true |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查蓝牙是否启用 |
|||
* @return 蓝牙是否启用 |
|||
*/ |
|||
fun isBluetoothEnabled(): Boolean { |
|||
try { |
|||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() |
|||
if (bluetoothAdapter == null) { |
|||
Log.w(TAG, "设备不支持蓝牙") |
|||
return false |
|||
} |
|||
|
|||
return bluetoothAdapter.isEnabled |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "检查蓝牙状态失败: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定配置文件的已连接蓝牙设备 |
|||
* @param profile 蓝牙配置文件类型,如 BluetoothProfile.A2DP 或 BluetoothProfile.HEADSET |
|||
* @param callback 回调接口,返回设备列表或错误信息 |
|||
*/ |
|||
private fun getConnectedBluetoothDevices(profile: Int, callback: BluetoothDevicesCallback) { |
|||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() |
|||
if (bluetoothAdapter == null) { |
|||
Log.e(TAG, "没有找到蓝牙适配器") |
|||
callback.onError("NO_ADAPTER", "没有找到蓝牙适配器") |
|||
return |
|||
} |
|||
|
|||
// 检查蓝牙权限 |
|||
if (!hasBluetoothConnectPermission()) { |
|||
Log.e(TAG, "缺少BLUETOOTH_CONNECT权限") |
|||
callback.onError("PERMISSION_DENIED", "缺少蓝牙连接权限,请在应用设置中授予该权限") |
|||
return |
|||
} |
|||
|
|||
try { |
|||
// 检查是否已经有缓存的代理对象 |
|||
val cachedProxy = when (profile) { |
|||
BluetoothProfile.A2DP -> a2dpProxy |
|||
BluetoothProfile.HEADSET -> headsetProxy |
|||
else -> null |
|||
} |
|||
|
|||
if (cachedProxy != null) { |
|||
// 使用已缓存的代理对象 |
|||
processDeviceList(cachedProxy, profile, callback) |
|||
return |
|||
} |
|||
|
|||
// 使用原子布尔值确保回调只被调用一次 |
|||
val callbackCalled = AtomicBoolean(false) |
|||
|
|||
// 使用CountDownLatch等待服务连接 |
|||
val latch = CountDownLatch(1) |
|||
|
|||
// 通过 getProfileProxy 获取指定 Profile (A2DP/HEADSET) 的连接状态 |
|||
bluetoothAdapter.getProfileProxy(context, |
|||
object : BluetoothProfile.ServiceListener { |
|||
override fun onServiceConnected(profileId: Int, proxy: BluetoothProfile) { |
|||
Log.d(TAG, "蓝牙配置文件服务已连接: $profileId") |
|||
if (profileId == profile) { |
|||
// 缓存代理对象 |
|||
when (profileId) { |
|||
BluetoothProfile.A2DP -> a2dpProxy = proxy |
|||
BluetoothProfile.HEADSET -> headsetProxy = proxy |
|||
} |
|||
|
|||
try { |
|||
processDeviceList(proxy, profileId, callback) |
|||
latch.countDown() |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "处理设备信息时出错: ${e.message}") |
|||
|
|||
// 确保回调只被调用一次 |
|||
if (callbackCalled.compareAndSet(false, true)) { |
|||
callback.onError("DEVICE_ERROR", "处理设备信息时出错: ${e.message}") |
|||
} |
|||
latch.countDown() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onServiceDisconnected(profileId: Int) { |
|||
// Profile 断开连接 |
|||
Log.e(TAG, "蓝牙配置文件服务断开连接: $profileId") |
|||
|
|||
// 清除对应的缓存代理 |
|||
when (profileId) { |
|||
BluetoothProfile.A2DP -> a2dpProxy = null |
|||
BluetoothProfile.HEADSET -> headsetProxy = null |
|||
} |
|||
|
|||
// 确保回调只被调用一次,但不在这里调用错误回调 |
|||
// 避免服务断开时也返回错误导致重复回调 |
|||
if (callbackCalled.compareAndSet(false, true) && !latch.await(0, TimeUnit.MILLISECONDS)) { |
|||
callback.onError("SERVICE_DISCONNECTED", "蓝牙配置文件服务断开连接") |
|||
latch.countDown() |
|||
} |
|||
} |
|||
}, profile |
|||
) |
|||
|
|||
// 设置超时处理 |
|||
Thread { |
|||
try { |
|||
// 最多等待3秒 |
|||
if (!latch.await(3, TimeUnit.SECONDS)) { |
|||
Log.e(TAG, "获取蓝牙设备超时") |
|||
if (callbackCalled.compareAndSet(false, true)) { |
|||
callback.onError("TIMEOUT", "获取蓝牙设备超时") |
|||
} |
|||
} |
|||
} catch (e: InterruptedException) { |
|||
Log.e(TAG, "等待蓝牙服务被中断: ${e.message}") |
|||
if (callbackCalled.compareAndSet(false, true)) { |
|||
callback.onError("INTERRUPTED", "等待蓝牙服务被中断: ${e.message}") |
|||
} |
|||
} |
|||
}.start() |
|||
|
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "获取蓝牙设备失败: ${e.message}") |
|||
callback.onError("BLUETOOTH_ERROR", "获取蓝牙设备失败: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理设备列表 |
|||
*/ |
|||
private fun processDeviceList(proxy: BluetoothProfile, profileId: Int, callback: BluetoothDevicesCallback) { |
|||
// 获取已连接设备 |
|||
val connectedDevices = proxy.connectedDevices |
|||
|
|||
// 转换为设备信息列表 |
|||
val devicesList = connectedDevices.map { device -> |
|||
mapOf( |
|||
"name" to (device.name ?: "未知设备"), |
|||
"address" to device.address |
|||
) |
|||
} |
|||
|
|||
Log.d(TAG, "获取到的设备列表:${devicesList.size}个设备") |
|||
|
|||
// 返回设备列表(不关闭代理) |
|||
callback.onSuccess(devicesList) |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun dispose() { |
|||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: return |
|||
|
|||
// 释放A2DP代理 |
|||
a2dpProxy?.let { |
|||
try { |
|||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, it) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "关闭A2DP代理失败: ${e.message}") |
|||
} |
|||
a2dpProxy = null |
|||
} |
|||
|
|||
// 释放HEADSET代理 |
|||
headsetProxy?.let { |
|||
try { |
|||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, it) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "关闭HEADSET代理失败: ${e.message}") |
|||
} |
|||
headsetProxy = null |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 蓝牙设备回调接口 |
|||
*/ |
|||
interface BluetoothDevicesCallback { |
|||
fun onSuccess(devices: List<Map<String, String>>) |
|||
fun onError(code: String, message: String) |
|||
} |
|||
} |
|||
@ -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<String, String?> = 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() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从 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 |
|||
// 获取配置的便捷方法 |
|||
fun getConfig(key: String, defaultValue: String = ""): String { |
|||
return configMap[key] ?: defaultValue |
|||
} |
|||
} |
|||
|
|||
// 请求码 |
|||
private const val REQUEST_BLUETOOTH_PERMISSIONS = 1003 |
|||
} |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
|
|||
// 初始化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) |
|||
} |
|||
/** |
|||
* 从安全存储中加载配置 |
|||
*/ |
|||
private fun loadConfigFromSecureStorage() { |
|||
val data = Utils.loadAllSecureData(applicationContext) |
|||
|
|||
// 打印已注册的广播 |
|||
FileLogger.d(TAG, "已注册语音交互广播:${VoiceInteractionService.ACTION_RECOGNITION_STARTED}, " + |
|||
"${VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED}, " + |
|||
"${VoiceInteractionService.ACTION_ENTER_TRANSLATION_MODE}") |
|||
// 将所有数据加载到全局配置字典中 |
|||
configMap.clear() |
|||
configMap.putAll(data) |
|||
|
|||
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) |
|||
} |
|||
|
|||
// 注册蓝牙状态变化广播接收器 |
|||
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) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置方法通道 |
|||
*/ |
|||
private fun setupMethodChannels(flutterEngine: FlutterEngine) { |
|||
Log.d(TAG, "开始设置方法通道") |
|||
|
|||
// 设置语音交互方法通道 |
|||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_CHANNEL).setMethodCallHandler { call, result -> |
|||
when (call.method) { |
|||
"startService" -> { |
|||
startVoiceInteractionService(call, result) |
|||
} |
|||
"stopService" -> { |
|||
stopVoiceInteractionService() |
|||
result.success(true) |
|||
} |
|||
"isServiceRunning" -> { |
|||
result.success(VoiceInteractionService.isServiceRunning()) |
|||
} |
|||
"pauseVoiceInteraction" -> { |
|||
pauseVoiceInteraction() |
|||
result.success(true) |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 设置经典蓝牙方法通道 |
|||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CLASSIC_BLUETOOTH_CHANNEL).setMethodCallHandler { call, result -> |
|||
when (call.method) { |
|||
"getConnectedA2dpDevices" -> { |
|||
try { |
|||
classicBluetoothHelper.getConnectedA2dpDevices(object : ClassicBluetoothHelper.BluetoothDevicesCallback { |
|||
override fun onSuccess(devices: List<Map<String, String>>) { |
|||
try { |
|||
result.success(devices) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "返回A2DP设备时发生异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
override fun onError(code: String, message: String) { |
|||
try { |
|||
result.error(code, message, null) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "返回A2DP设备错误时发生异常: ${e.message}") |
|||
} |
|||
} |
|||
}) |
|||
} catch (e: Exception) { |
|||
try { |
|||
Log.e(TAG, "获取A2DP设备时发生异常: ${e.message}") |
|||
result.error("UNEXPECTED_ERROR", "获取A2DP设备时发生异常: ${e.message}", e.toString()) |
|||
} catch (e2: Exception) { |
|||
Log.e(TAG, "处理A2DP设备获取异常时发生二次异常: ${e2.message}") |
|||
} |
|||
} |
|||
} |
|||
"getConnectedHeadsetDevices" -> { |
|||
try { |
|||
classicBluetoothHelper.getConnectedHeadsetDevices(object : ClassicBluetoothHelper.BluetoothDevicesCallback { |
|||
override fun onSuccess(devices: List<Map<String, String>>) { |
|||
try { |
|||
result.success(devices) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "返回耳机设备时发生异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
override fun onError(code: String, message: String) { |
|||
try { |
|||
result.error(code, message, null) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "返回耳机设备错误时发生异常: ${e.message}") |
|||
} |
|||
} |
|||
}) |
|||
} catch (e: Exception) { |
|||
try { |
|||
Log.e(TAG, "获取耳机设备时发生异常: ${e.message}") |
|||
result.error("UNEXPECTED_ERROR", "获取耳机设备时发生异常: ${e.message}", e.toString()) |
|||
} catch (e2: Exception) { |
|||
Log.e(TAG, "处理耳机设备获取异常时发生二次异常: ${e2.message}") |
|||
} |
|||
} |
|||
} |
|||
"isBluetoothEnabled" -> { |
|||
try { |
|||
val isEnabled = classicBluetoothHelper.isBluetoothEnabled() |
|||
result.success(isEnabled) |
|||
Log.d(TAG, "检查蓝牙状态: ${if (isEnabled) "已启用" else "未启用"}") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "检查蓝牙状态时发生异常: ${e.message}") |
|||
result.error("BLUETOOTH_ERROR", "检查蓝牙状态失败: ${e.message}", e.toString()) |
|||
} |
|||
} |
|||
|
|||
"initialize" -> { |
|||
result.success(true) |
|||
} |
|||
else -> result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 启动语音交互服务(带配置参数) |
|||
*/ |
|||
private fun startVoiceInteractionService(call: MethodCall, result: MethodChannel.Result) { |
|||
FileLogger.d(TAG, "启动语音交互服务") |
|||
|
|||
// 获取配置参数 |
|||
azureSpeechKey = call.argument<String>("azure_speech_key") ?: "" |
|||
azureSpeechRegion = call.argument<String>("azure_speech_region") ?: "" |
|||
openaiApiKey = call.argument<String>("openai_api_key") ?: "" |
|||
openaiBaseUrl = call.argument<String>("openai_base_url") |
|||
openaiModel = call.argument<String>("openai_model") |
|||
volcanoSpeechAppId = call.argument<String>("volcano_speech_app_id") ?: "" |
|||
volcanoSpeechAppToken = call.argument<String>("volcano_speech_app_token") ?: "" |
|||
mcpServer = call.argument<String>("mcp_config") ?: "" |
|||
|
|||
// 保存密钥到安全存储 |
|||
saveKeysToSecureStorage(applicationContext) |
|||
|
|||
// 创建 Intent 并启动服务 |
|||
val serviceIntent = Intent(this, VoiceInteractionService::class.java) |
|||
|
|||
// 在 Android O 及以上版本,需要使用 startForegroundService |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
startForegroundService(serviceIntent) |
|||
} else { |
|||
startService(serviceIntent) |
|||
} |
|||
|
|||
// 返回成功结果 |
|||
result.success(true) |
|||
} |
|||
|
|||
/** |
|||
* 停止语音交互服务 |
|||
*/ |
|||
private fun stopVoiceInteractionService() { |
|||
Log.d(TAG, "停止语音交互服务") |
|||
val serviceIntent = Intent(this, VoiceInteractionService::class.java) |
|||
stopService(serviceIntent) |
|||
} |
|||
|
|||
/** |
|||
* 暂停语音交互(停止TTS和语音识别,但保持服务运行) |
|||
*/ |
|||
private fun pauseVoiceInteraction() { |
|||
Log.d(TAG, "发送暂停语音交互请求") |
|||
val intent = Intent(this, VoiceInteractionService::class.java).apply { |
|||
action = VoiceInteractionService.ACTION_PAUSE_VOICE_INTERACTION |
|||
} |
|||
|
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
startForegroundService(intent) |
|||
} else { |
|||
startService(intent) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送聊天记录事件到 Flutter |
|||
*/ |
|||
private fun sendChatHistoryEvent(agentId: String, userMessage: String, assistantMessage: String, timestamp: Long) { |
|||
if (voiceInteractionEventSink == null) { |
|||
Log.e(TAG, "无法发送聊天记录事件:事件通道未准备好") |
|||
return |
|||
} |
|||
|
|||
runOnUiThread { |
|||
try { |
|||
val eventData = mapOf( |
|||
"type" to "chat_history_updated", |
|||
"agentId" to agentId, |
|||
"userMessage" to userMessage, |
|||
"assistantMessage" to assistantMessage, |
|||
"timestamp" to timestamp |
|||
) |
|||
voiceInteractionEventSink?.success(eventData) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送聊天记录事件到Flutter失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送语音交互事件到 Flutter |
|||
*/ |
|||
private fun sendVoiceInteractionEvent(event: Map<String, Any>) { |
|||
if (voiceInteractionEventSink == null) { |
|||
Log.e(TAG, "无法发送语音交互事件:事件通道未准备好") |
|||
FileLogger.e(TAG, "无法发送语音交互事件:事件通道未准备好") |
|||
return |
|||
} |
|||
|
|||
FileLogger.d(TAG, "准备发送事件到Flutter: ${event["type"]}") |
|||
|
|||
runOnUiThread { |
|||
try { |
|||
voiceInteractionEventSink?.success(event) |
|||
FileLogger.d(TAG, "成功发送事件到Flutter: ${event["type"]}") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}") |
|||
FileLogger.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 发送蓝牙事件到Flutter |
|||
private fun sendBluetoothEvent(event: Map<String, Any>) { |
|||
if (bluetoothEventSink == null) { |
|||
Log.w(TAG, "无法发送蓝牙事件:事件通道未准备好") |
|||
return |
|||
} |
|||
|
|||
try { |
|||
runOnUiThread { |
|||
try { |
|||
bluetoothEventSink?.success(event) |
|||
Log.d(TAG, "已发送蓝牙事件: ${event["type"]}") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送蓝牙事件失败: ${e.message}") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "转到UI线程发送蓝牙事件失败: ${e.message}") |
|||
} |
|||
FileLogger.d(TAG, "Flutter 引擎配置完成") |
|||
} |
|||
|
|||
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<String>() |
|||
|
|||
for (permission in REQUIRED_PERMISSIONS) { |
|||
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { |
|||
permissionsToRequest.add(permission) |
|||
} |
|||
} |
|||
|
|||
if (permissionsToRequest.isNotEmpty()) { |
|||
ActivityCompat.requestPermissions( |
|||
this, |
|||
permissionsToRequest.toTypedArray(), |
|||
PERMISSION_REQUEST_CODE |
|||
) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理权限请求结果 |
|||
* 初始化BLE后台扫描和唤醒功能 |
|||
*/ |
|||
override fun onRequestPermissionsResult( |
|||
requestCode: Int, |
|||
permissions: Array<out String>, |
|||
grantResults: IntArray |
|||
) { |
|||
super.onRequestPermissionsResult(requestCode, permissions, grantResults) |
|||
|
|||
if (requestCode == PERMISSION_REQUEST_CODE) { |
|||
val deniedPermissions = ArrayList<String>() |
|||
|
|||
for (i in permissions.indices) { |
|||
if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { |
|||
deniedPermissions.add(permissions[i]) |
|||
} |
|||
} |
|||
|
|||
if (deniedPermissions.isNotEmpty()) { |
|||
// 记录未授权的权限 |
|||
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唤醒服务") |
|||
private fun initBleBackgroundScan() { |
|||
try { |
|||
// 初始化BleManagerWrapper |
|||
val bleManager = BleManagerWrapper.instance |
|||
bleManager.init(applicationContext) |
|||
|
|||
// 检查蓝牙权限 |
|||
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 |
|||
} |
|||
} |
|||
|
|||
|
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
package com.yunqiinnovation.deepsound |
|||
|
|||
import android.content.Context |
|||
import androidx.security.crypto.EncryptedSharedPreferences |
|||
import androidx.security.crypto.MasterKey |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
|
|||
/** |
|||
* 通用工具类,包含安全存储等功能 |
|||
*/ |
|||
object Utils { |
|||
private const val TAG = "Utils" |
|||
private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" |
|||
private const val KEY_SESSION_ID = "session_id" |
|||
|
|||
/** |
|||
* 安全存储数据 |
|||
* @param context 上下文 |
|||
* @param dataMap 要存储的键值对字典 |
|||
* @return 是否成功存储 |
|||
*/ |
|||
fun saveSecureData(context: Context, dataMap: Map<String, String?>): Boolean { |
|||
return try { |
|||
// 创建或获取主密钥 |
|||
val masterKey = MasterKey.Builder(context) |
|||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|||
.build() |
|||
|
|||
// 创建加密SharedPreferences |
|||
val sharedPreferences = EncryptedSharedPreferences.create( |
|||
context, |
|||
SECURE_PREFS_FILENAME, |
|||
masterKey, |
|||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|||
) |
|||
|
|||
// 获取或生成会话ID |
|||
var sessionId = sharedPreferences.getString(KEY_SESSION_ID, "") ?: "" |
|||
if (sessionId.isEmpty()) { |
|||
sessionId = java.util.UUID.randomUUID().toString() |
|||
} |
|||
|
|||
// 开始编辑会话 |
|||
val editor = sharedPreferences.edit() |
|||
|
|||
// 保存会话ID |
|||
editor.putString(KEY_SESSION_ID, sessionId) |
|||
|
|||
// 保存所有键值对 |
|||
for ((key, value) in dataMap) { |
|||
if (value != null) { |
|||
editor.putString(key, value) |
|||
} else { |
|||
editor.remove(key) |
|||
} |
|||
} |
|||
|
|||
// 提交更改 |
|||
editor.apply() |
|||
|
|||
FileLogger.d(TAG, "数据已安全保存到加密存储中") |
|||
true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "保存数据到加密存储失败", e) |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从安全存储中读取所有数据 |
|||
* @param context 上下文 |
|||
* @return 包含所有数据的键值对字典 |
|||
*/ |
|||
fun loadAllSecureData(context: Context): Map<String, String> { |
|||
return try { |
|||
val resultMap = mutableMapOf<String, String>() |
|||
|
|||
// 创建或获取主密钥 |
|||
val masterKey = MasterKey.Builder(context) |
|||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|||
.build() |
|||
|
|||
// 创建加密SharedPreferences |
|||
val sharedPreferences = EncryptedSharedPreferences.create( |
|||
context, |
|||
SECURE_PREFS_FILENAME, |
|||
masterKey, |
|||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|||
) |
|||
|
|||
// 读取所有数据 |
|||
for ((key, value) in sharedPreferences.all) { |
|||
if (value is String) { |
|||
resultMap[key] = value |
|||
} |
|||
} |
|||
|
|||
FileLogger.d(TAG, "已从加密存储加载所有数据") |
|||
resultMap |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "从加密存储加载所有数据失败", e) |
|||
emptyMap() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清除所有安全存储的数据 |
|||
* @param context 上下文 |
|||
* @return 是否成功清除 |
|||
*/ |
|||
fun clearAllSecureData(context: Context): Boolean { |
|||
return try { |
|||
// 创建或获取主密钥 |
|||
val masterKey = MasterKey.Builder(context) |
|||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|||
.build() |
|||
|
|||
// 创建加密SharedPreferences |
|||
val sharedPreferences = EncryptedSharedPreferences.create( |
|||
context, |
|||
SECURE_PREFS_FILENAME, |
|||
masterKey, |
|||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|||
) |
|||
|
|||
// 生成新的会话ID |
|||
val sessionId = java.util.UUID.randomUUID().toString() |
|||
|
|||
// 清除所有数据 |
|||
sharedPreferences.edit().clear() |
|||
.putString(KEY_SESSION_ID, sessionId) |
|||
.apply() |
|||
|
|||
FileLogger.d(TAG, "已清除所有安全存储的数据") |
|||
true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "清除安全存储数据失败", e) |
|||
false |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,350 @@ |
|||
import 'dart:async'; |
|||
|
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:flutter/services.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
|
|||
import '../asr_service.dart'; |
|||
|
|||
/// 火山语音识别服务实现 |
|||
class VolcanoAsrService extends GetxService implements AsrService { |
|||
/// 方法通道 |
|||
static const MethodChannel _channel = MethodChannel('volcano_speech/asr'); |
|||
|
|||
/// 事件通道 |
|||
static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events'); |
|||
|
|||
/// 事件流控制器 |
|||
final StreamController<RecognitionEvent> _eventStreamController = |
|||
StreamController<RecognitionEvent>.broadcast(); |
|||
|
|||
/// 事件订阅 |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
/// 是否初始化完成 |
|||
bool _isInitialized = false; |
|||
|
|||
/// 是否正在连续识别 |
|||
bool _isContinuousRecognitionActive = false; |
|||
|
|||
/// 当前语言 |
|||
String _currentLanguage = 'zh-CN'; |
|||
|
|||
/// 支持的语言列表 |
|||
final List<String> _supportedLanguages = ['zh-CN', 'en-US']; |
|||
|
|||
@override |
|||
List<String> get supportedLanguages => _supportedLanguages; |
|||
|
|||
/// 构造函数 |
|||
VolcanoAsrService() { |
|||
_setupEventListener(); |
|||
} |
|||
|
|||
/// 初始化语音识别服务 |
|||
@override |
|||
Future<bool> initialize({ |
|||
List<String>? supportedLanguages, |
|||
}) async { |
|||
if (_isInitialized) return true; |
|||
|
|||
try { |
|||
// 如果提供了支持的语言列表,则替换默认列表 |
|||
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
|||
_supportedLanguages.clear(); |
|||
_supportedLanguages.addAll(supportedLanguages); |
|||
} |
|||
|
|||
// 从.env文件读取配置 |
|||
final appId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? ''; |
|||
final appToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? ''; |
|||
final resourceId = dotenv.env['VOLCANO_ASR_RESOURCE_ID'] ?? 'volc.bigasr.sauc.duration'; |
|||
|
|||
if (appId.isEmpty || appToken.isEmpty) { |
|||
debugPrint('火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量'); |
|||
return false; |
|||
} |
|||
|
|||
// 初始化火山语音ASR |
|||
final initResult = await _channel.invokeMethod<bool>('initialize', { |
|||
'appId': appId, |
|||
'appToken': appToken, |
|||
'resourceId': resourceId, |
|||
}) ?? false; |
|||
|
|||
if (!initResult) { |
|||
debugPrint('火山语音ASR初始化失败'); |
|||
return false; |
|||
} |
|||
|
|||
// 设置默认语言、VAD参数和启用音量返回 |
|||
await _configureAsr(); |
|||
|
|||
_isInitialized = true; |
|||
return true; |
|||
} catch (e) { |
|||
debugPrint('火山语音ASR初始化异常:$e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 配置ASR参数 |
|||
Future<void> _configureAsr() async { |
|||
// 设置默认语言 |
|||
await _channel.invokeMethod<bool>('setLanguage', { |
|||
'language': _currentLanguage, |
|||
}); |
|||
|
|||
} |
|||
|
|||
/// 设置事件监听 |
|||
void _setupEventListener() { |
|||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen( |
|||
(dynamic event) { |
|||
if (event is! Map) return; |
|||
_handleAsrEvent(Map<String, dynamic>.from(event as Map)); |
|||
}, |
|||
onError: (Object error) { |
|||
debugPrint('ASR事件流错误: $error'); |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: '事件流错误: $error', |
|||
)); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
/// 处理ASR事件 |
|||
void _handleAsrEvent(Map<String, dynamic> event) { |
|||
final eventType = event['type'] as String?; |
|||
|
|||
switch (eventType) { |
|||
case 'sessionStarted': |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.sessionStarted, |
|||
)); |
|||
break; |
|||
|
|||
case 'sessionStopped': |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.sessionStopped, |
|||
)); |
|||
_isContinuousRecognitionActive = false; |
|||
break; |
|||
|
|||
case 'recognizing': |
|||
final text = event['text'] as String?; |
|||
final language = event['language'] as String?; |
|||
|
|||
if (text != null) { |
|||
debugPrint('接收到中间识别结果: $text'); |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.intermediateResult, |
|||
text: text, |
|||
detectedLanguage: language ?? _currentLanguage, |
|||
)); |
|||
} |
|||
break; |
|||
|
|||
case 'result': |
|||
final text = event['text'] as String?; |
|||
final language = event['language'] as String?; |
|||
|
|||
if (text != null) { |
|||
debugPrint('接收到最终识别结果: $text'); |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.finalResult, |
|||
text: text, |
|||
detectedLanguage: language ?? _currentLanguage, |
|||
)); |
|||
} |
|||
break; |
|||
|
|||
case 'error': |
|||
final errorMessage = event['message'] as String?; |
|||
|
|||
debugPrint('接收到识别错误: ${errorMessage ?? "未知错误"}'); |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: errorMessage ?? '未知错误', |
|||
)); |
|||
_isContinuousRecognitionActive = false; |
|||
break; |
|||
|
|||
case 'volumeChanged': |
|||
// 音量变化事件仅在日志中输出,不发送到事件流 |
|||
final volume = event['volume'] as int?; |
|||
if (volume != null && volume > 5) { // 仅记录有意义的音量变化 |
|||
debugPrint('音量变化: $volume'); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 检查初始化状态 |
|||
Future<bool> _ensureInitialized() async { |
|||
if (!_isInitialized) { |
|||
return await initialize(); |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
/// 执行一次性语音识别 |
|||
@override |
|||
Future<RecognitionEvent> recognizeOnce() async { |
|||
if (!await _ensureInitialized()) { |
|||
return RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: '语音识别服务未初始化', |
|||
); |
|||
} |
|||
|
|||
try { |
|||
// 创建一个Completer用于等待最终结果 |
|||
final completer = Completer<RecognitionEvent>(); |
|||
|
|||
// 创建一次性事件监听 |
|||
StreamSubscription? subscription; |
|||
subscription = _eventStreamController.stream.listen((event) { |
|||
// 仅处理finalResult和error事件 |
|||
if (event.type == RecognitionEventType.finalResult) { |
|||
// 识别成功,返回结果 |
|||
completer.complete(event); |
|||
subscription?.cancel(); |
|||
} else if (event.type == RecognitionEventType.error) { |
|||
// 识别失败,返回错误 |
|||
completer.complete(event); |
|||
subscription?.cancel(); |
|||
} |
|||
}); |
|||
|
|||
// 启动一次性识别 |
|||
final started = await _channel.invokeMethod<bool>('recognizeOnce') ?? false; |
|||
|
|||
if (!started) { |
|||
subscription.cancel(); |
|||
return RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: '启动一次性识别失败', |
|||
); |
|||
} |
|||
|
|||
// 设置超时,防止永久等待 |
|||
Future.delayed(const Duration(seconds: 30), () { |
|||
if (!completer.isCompleted) { |
|||
completer.complete(RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: '识别超时', |
|||
)); |
|||
subscription?.cancel(); |
|||
// 停止识别 |
|||
_channel.invokeMethod('stopRecognize'); |
|||
} |
|||
}); |
|||
|
|||
// 等待结果 |
|||
return await completer.future; |
|||
} catch (e) { |
|||
// 确保停止识别 |
|||
try { |
|||
await _channel.invokeMethod('stopRecognize'); |
|||
} catch (_) {} |
|||
|
|||
return RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: e.toString(), |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 开始连续语音识别 |
|||
@override |
|||
Future<Stream<RecognitionEvent>> startContinuousRecognition() async { |
|||
if (!await _ensureInitialized()) { |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: '语音识别服务未初始化', |
|||
)); |
|||
return _eventStreamController.stream; |
|||
} |
|||
|
|||
try { |
|||
final success = await _channel.invokeMethod<bool>('startContinuousRecognition') ?? false; |
|||
|
|||
if (success) { |
|||
_isContinuousRecognitionActive = true; |
|||
} else { |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: '启动连续识别失败', |
|||
)); |
|||
} |
|||
} catch (e) { |
|||
_eventStreamController.add(RecognitionEvent( |
|||
type: RecognitionEventType.error, |
|||
error: '启动连续识别异常:$e', |
|||
)); |
|||
} |
|||
|
|||
return _eventStreamController.stream; |
|||
} |
|||
|
|||
/// 停止连续语音识别 |
|||
@override |
|||
Future<bool> stopContinuousRecognition() async { |
|||
if (!_isInitialized || !_isContinuousRecognitionActive) { |
|||
return false; |
|||
} |
|||
|
|||
try { |
|||
final success = await _channel.invokeMethod<bool>('stopContinuousRecognition') ?? false; |
|||
|
|||
if (success) { |
|||
_isContinuousRecognitionActive = false; |
|||
} |
|||
return success; |
|||
} catch (e) { |
|||
debugPrint('停止连续识别异常:$e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 推送音频数据到连续识别中 |
|||
@override |
|||
Future<void> pushAudioData(Uint8List data) async { |
|||
if (!_isInitialized || !_isContinuousRecognitionActive) { |
|||
return; |
|||
} |
|||
|
|||
// 注意:此功能需要在引擎初始化时设置音频输入类型为stream |
|||
// 由于当前实现默认使用麦克风输入,此方法暂时不会实际处理数据 |
|||
debugPrint('警告:当前火山语音ASR使用麦克风输入,pushAudioData方法不会起作用'); |
|||
} |
|||
|
|||
/// 检查连续识别是否活跃 |
|||
@override |
|||
bool isContinuousRecognitionActive() { |
|||
return _isContinuousRecognitionActive; |
|||
} |
|||
|
|||
/// 释放资源 |
|||
@override |
|||
Future<void> dispose() async { |
|||
if (_isContinuousRecognitionActive) { |
|||
await stopContinuousRecognition(); |
|||
} |
|||
|
|||
await _eventSubscription?.cancel(); |
|||
_eventSubscription = null; |
|||
|
|||
if (!_eventStreamController.isClosed) { |
|||
await _eventStreamController.close(); |
|||
} |
|||
|
|||
if (_isInitialized) { |
|||
await _channel.invokeMethod<bool>('release'); |
|||
_isInitialized = false; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,285 @@ |
|||
import 'dart:async'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:flutter/services.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../tts_service.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
|
|||
/// 火山语音合成服务实现 |
|||
class VolcanoTtsService extends GetxService implements TtsService { |
|||
/// 方法通道 |
|||
static const MethodChannel _channel = MethodChannel('volcano_speech/tts'); |
|||
|
|||
/// 事件通道 |
|||
static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events'); |
|||
|
|||
/// 音频数据通道 |
|||
static const EventChannel _audioDataChannel = EventChannel('volcano_speech/tts_audio_data'); |
|||
|
|||
/// 事件流控制器 |
|||
final StreamController<TtsEvent> _eventStreamController = |
|||
StreamController<TtsEvent>.broadcast(); |
|||
|
|||
/// 音频数据流控制器 |
|||
final StreamController<Uint8List> _audioDataStreamController = |
|||
StreamController<Uint8List>.broadcast(); |
|||
|
|||
/// 事件处理器 |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
/// 音频数据处理器 |
|||
StreamSubscription? _audioDataSubscription; |
|||
|
|||
/// 是否初始化完成 |
|||
bool _isInitialized = false; |
|||
|
|||
/// 当前语音名称 |
|||
String _currentVoice = "zh_female_qingxin"; |
|||
|
|||
/// 是否启用音频回调 |
|||
bool _isAudioCallbackEnabled = false; |
|||
|
|||
@override |
|||
String get currentVoice => _currentVoice; |
|||
|
|||
@override |
|||
bool get isSpeaking => _isSpeaking; |
|||
|
|||
/// 是否正在播放 |
|||
bool _isSpeaking = false; |
|||
|
|||
@override |
|||
Stream<TtsEvent> get onEvent => _eventStreamController.stream; |
|||
|
|||
/// 音频数据流 |
|||
Stream<Uint8List> get onAudioData => _audioDataStreamController.stream; |
|||
|
|||
/// 构造函数 |
|||
VolcanoTtsService() { |
|||
_setupEventListeners(); |
|||
_setupAudioDataListeners(); |
|||
} |
|||
|
|||
/// 设置事件监听 |
|||
void _setupEventListeners() { |
|||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen( |
|||
(dynamic event) { |
|||
if (event is! Map) return; |
|||
|
|||
final Map<dynamic, dynamic> eventMap = event; |
|||
final String? type = eventMap['type'] as String?; |
|||
|
|||
switch (type) { |
|||
case 'started': |
|||
_isSpeaking = true; |
|||
_eventStreamController.add(TtsEvent(type: TtsEventType.started)); |
|||
break; |
|||
|
|||
case 'completed': |
|||
_isSpeaking = false; |
|||
_eventStreamController.add(TtsEvent(type: TtsEventType.completed)); |
|||
break; |
|||
|
|||
case 'canceled': |
|||
_isSpeaking = false; |
|||
_eventStreamController.add(TtsEvent(type: TtsEventType.canceled)); |
|||
break; |
|||
|
|||
case 'error': |
|||
_isSpeaking = false; |
|||
final errorCode = eventMap['errorCode'] as int? ?? 0; |
|||
final errorMessage = eventMap['errorMessage'] as String? ?? '未知错误'; |
|||
_eventStreamController.add(TtsEvent( |
|||
type: TtsEventType.error, |
|||
error: errorMessage, |
|||
)); |
|||
break; |
|||
} |
|||
}, |
|||
onError: (Object error) { |
|||
debugPrint('TTS事件流错误: $error'); |
|||
_eventStreamController.add(TtsEvent( |
|||
type: TtsEventType.error, |
|||
error: '事件流错误: $error', |
|||
)); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
/// 设置音频数据监听 |
|||
void _setupAudioDataListeners() { |
|||
_audioDataSubscription = _audioDataChannel.receiveBroadcastStream().listen( |
|||
(dynamic data) { |
|||
if (data is Uint8List) { |
|||
_audioDataStreamController.add(data); |
|||
} |
|||
}, |
|||
onError: (Object error) { |
|||
debugPrint('TTS音频数据流错误: $error'); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
@override |
|||
Future<bool> initialize({List<String>? supportedLanguages}) async { |
|||
if (_isInitialized) return true; |
|||
|
|||
try { |
|||
// 从.env文件读取配置 |
|||
final appId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? ''; |
|||
final appToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? ''; |
|||
|
|||
if (appId.isEmpty || appToken.isEmpty) { |
|||
debugPrint('火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量'); |
|||
return false; |
|||
} |
|||
|
|||
// 初始化火山语音TTS |
|||
final result = await _channel.invokeMethod<bool>('initialize', { |
|||
'appId': appId, |
|||
'appToken': appToken, |
|||
}) ?? false; |
|||
|
|||
if (!result) { |
|||
debugPrint('火山语音TTS初始化失败'); |
|||
return false; |
|||
} |
|||
|
|||
_isInitialized = true; |
|||
return true; |
|||
} catch (e) { |
|||
debugPrint('火山语音TTS初始化异常:$e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 检查初始化状态 |
|||
Future<bool> _ensureInitialized() async { |
|||
if (!_isInitialized) { |
|||
return await initialize(); |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
@override |
|||
Future<bool> setVoice(String voiceName) async { |
|||
if (!await _ensureInitialized()) return false; |
|||
|
|||
try { |
|||
final result = await _channel.invokeMethod<bool>('setVoice', { |
|||
'voice': voiceName, |
|||
}) ?? false; |
|||
|
|||
if (result) { |
|||
_currentVoice = voiceName; |
|||
} |
|||
return result; |
|||
} catch (e) { |
|||
debugPrint('设置语音失败:$e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置是否启用音频数据回调 |
|||
Future<bool> setEnableAudioCallback(bool enable) async { |
|||
if (!await _ensureInitialized()) return false; |
|||
|
|||
try { |
|||
final result = await _channel.invokeMethod<bool>('enableAudioCallback', { |
|||
'enable': enable, |
|||
}) ?? false; |
|||
|
|||
if (result) { |
|||
_isAudioCallbackEnabled = enable; |
|||
} |
|||
return result; |
|||
} catch (e) { |
|||
debugPrint('设置音频回调失败:$e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> speakOnce(String text) async { |
|||
if (!await _ensureInitialized()) return false; |
|||
|
|||
try { |
|||
// 单次播放 |
|||
final result = await _channel.invokeMethod<bool>('speakOnce', { |
|||
'text': text, |
|||
}) ?? false; |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
debugPrint('单次播放失败:$e'); |
|||
_eventStreamController.add(TtsEvent.error('单次播放失败:$e')); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> speakStream(String text) async { |
|||
if (!await _ensureInitialized()) return false; |
|||
|
|||
try { |
|||
// 流式添加文本 |
|||
final result = await _channel.invokeMethod<bool>('speakStream', { |
|||
'text': text, |
|||
}) ?? false; |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
debugPrint('流式播放失败:$e'); |
|||
_eventStreamController.add(TtsEvent.error('流式播放失败:$e')); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> flushStream() async { |
|||
if (!await _ensureInitialized()) return false; |
|||
|
|||
try { |
|||
// 刷新流式文本 |
|||
return await _channel.invokeMethod<bool>('flushStream') ?? false; |
|||
} catch (e) { |
|||
debugPrint('刷新流式文本失败:$e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> stop() async { |
|||
if (!_isInitialized) return false; |
|||
|
|||
try { |
|||
return await _channel.invokeMethod<bool>('stop') ?? false; |
|||
} catch (e) { |
|||
debugPrint('停止播放失败:$e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<void> dispose() async { |
|||
if (_isSpeaking) { |
|||
await stop(); |
|||
} |
|||
|
|||
await _eventSubscription?.cancel(); |
|||
await _audioDataSubscription?.cancel(); |
|||
|
|||
if (!_eventStreamController.isClosed) { |
|||
await _eventStreamController.close(); |
|||
} |
|||
|
|||
if (!_audioDataStreamController.isClosed) { |
|||
await _audioDataStreamController.close(); |
|||
} |
|||
|
|||
if (_isInitialized) { |
|||
await _channel.invokeMethod<bool>('release'); |
|||
_isInitialized = false; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/ble_test_controller.dart'; |
|||
|
|||
class BleTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<BleTestController>( |
|||
() => BleTestController(), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,433 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:ble_service/ble_service.dart'; |
|||
|
|||
/// BLE测试控制器 |
|||
/// 用于测试BLE服务插件的功能 |
|||
class BleTestController extends GetxController { |
|||
// 设备列表 |
|||
final deviceList = <BleDevice>[].obs; |
|||
|
|||
// 扫描状态 |
|||
final isScanning = false.obs; |
|||
|
|||
// 连接状态 |
|||
final isConnected = false.obs; |
|||
final connectionState = 0.obs; |
|||
|
|||
// 设备信息 |
|||
final connectedDeviceAddress = ''.obs; |
|||
final batteryLevel = 0.obs; |
|||
final firmwareVersion = ''.obs; |
|||
|
|||
// 状态消息 |
|||
final statusMessage = ''.obs; |
|||
|
|||
// BLE服务 |
|||
final _bleService = BleService(); |
|||
|
|||
// 订阅 |
|||
late StreamSubscription _scanSubscription; |
|||
late StreamSubscription _connectionSubscription; |
|||
late StreamSubscription _dataSubscription; |
|||
late StreamSubscription _statusSubscription; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initBleService(); |
|||
} |
|||
|
|||
/// 初始化BLE服务 |
|||
Future<void> _initBleService() async { |
|||
try { |
|||
// 初始化BLE服务 |
|||
final result = await _bleService.initialize(); |
|||
if (result) { |
|||
statusMessage.value = 'BLE服务初始化成功'; |
|||
|
|||
// 注册回调 |
|||
await _bleService.registerCallback(); |
|||
|
|||
// 订阅扫描结果 |
|||
_scanSubscription = _bleService.onScanResults.listen((devices) { |
|||
if (devices.isEmpty) { |
|||
deviceList.clear(); |
|||
return; |
|||
} |
|||
|
|||
bool hasChanges = false; |
|||
|
|||
for (final newDevice in devices) { |
|||
int existingIndex = deviceList.indexWhere((d) => d.id == newDevice.id); |
|||
if (existingIndex >= 0) { |
|||
BleDevice existing = deviceList[existingIndex]; |
|||
if (existing.rssi != newDevice.rssi || existing.name != newDevice.name) { |
|||
deviceList[existingIndex] = newDevice; |
|||
hasChanges = true; |
|||
} |
|||
} else { |
|||
deviceList.add(newDevice); |
|||
hasChanges = true; |
|||
} |
|||
} |
|||
|
|||
if (hasChanges) { |
|||
deviceList.refresh(); |
|||
} |
|||
}); |
|||
|
|||
// 订阅连接状态 |
|||
_connectionSubscription = _bleService.onConnectionChanged.listen((state) { |
|||
if (state is int) { |
|||
connectionState.value = state; |
|||
isConnected.value = state == BleConnectionState.connected; |
|||
statusMessage.value = '连接状态变更: ${_getStateString(state)}'; |
|||
} |
|||
}); |
|||
|
|||
// 订阅数据 |
|||
_dataSubscription = _bleService.onDataReceived.listen((data) { |
|||
if (data is Map) { |
|||
final type = data['type']; |
|||
if (type == 'wakeup') { |
|||
statusMessage.value = '收到唤醒信号'; |
|||
} else if (type == 'audioData') { |
|||
statusMessage.value = '收到音频数据: ${(data['data'] as Uint8List).length} 字节'; |
|||
} else if (type == 'rawData') { |
|||
statusMessage.value = '收到原始数据: ${(data['data'] as Uint8List).length} 字节'; |
|||
} |
|||
} |
|||
}); |
|||
|
|||
// 订阅状态更新 |
|||
_statusSubscription = _bleService.onStatusUpdated.listen((status) { |
|||
if (status is Map) { |
|||
final type = status['type']; |
|||
if (type == 'statusUpdate') { |
|||
// 更新状态 |
|||
connectionState.value = status['connectionState'] as int? ?? 0; |
|||
isConnected.value = connectionState.value == BleConnectionState.connected; |
|||
batteryLevel.value = status['batteryLevel'] as int? ?? 0; |
|||
firmwareVersion.value = status['firmwareVersion'] as String? ?? ''; |
|||
connectedDeviceAddress.value = status['connectedDevice'] as String? ?? ''; |
|||
|
|||
statusMessage.value = '状态更新: ${_getStateString(connectionState.value)}'; |
|||
} else if (type == 'deviceInfo') { |
|||
// 设备信息 |
|||
final infoType = status['infoType'] as int; |
|||
final infoData = status['infoData'] as String; |
|||
|
|||
statusMessage.value = '设备信息: $infoData'; |
|||
|
|||
// 处理不同类型的设备信息 |
|||
if (infoType == 0x80) { // 版本信息 |
|||
firmwareVersion.value = infoData; |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
// 获取连接状态 |
|||
_updateConnectionState(); |
|||
} else { |
|||
statusMessage.value = 'BLE服务初始化失败'; |
|||
} |
|||
} catch (e) { |
|||
statusMessage.value = '初始化异常: $e'; |
|||
Get.snackbar('初始化失败', '无法初始化BLE服务: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 获取状态字符串 |
|||
String _getStateString(int state) { |
|||
switch (state) { |
|||
case BleConnectionState.disconnected: |
|||
return '已断开'; |
|||
case BleConnectionState.connecting: |
|||
return '连接中'; |
|||
case BleConnectionState.connected: |
|||
return '已连接'; |
|||
case BleConnectionState.disconnecting: |
|||
return '断开中'; |
|||
case BleConnectionState.error: |
|||
return '连接错误'; |
|||
default: |
|||
return '未知状态($state)'; |
|||
} |
|||
} |
|||
|
|||
/// 更新连接状态 |
|||
Future<void> _updateConnectionState() async { |
|||
connectionState.value = await _bleService.getConnectionState(); |
|||
isConnected.value = connectionState.value == BleConnectionState.connected; |
|||
|
|||
if (isConnected.value) { |
|||
connectedDeviceAddress.value = await _bleService.getConnectedDeviceAddress() ?? ''; |
|||
batteryLevel.value = await _bleService.getBatteryLevel(); |
|||
firmwareVersion.value = await _bleService.getFirmwareVersion() ?? ''; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描设备 |
|||
Future<void> startScan() async { |
|||
try { |
|||
// 清空设备列表 |
|||
deviceList.clear(); |
|||
|
|||
// 设置扫描状态 |
|||
isScanning.value = true; |
|||
statusMessage.value = '开始扫描...'; |
|||
|
|||
// 开始扫描 |
|||
final result = await _bleService.startScan(timeout: 15000); |
|||
|
|||
if (!result) { |
|||
statusMessage.value = '扫描失败'; |
|||
Get.snackbar('扫描失败', '无法启动BLE扫描', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} catch (e) { |
|||
statusMessage.value = '扫描异常: $e'; |
|||
Get.snackbar('扫描错误', '扫描过程中出错: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} finally { |
|||
// 5秒后自动停止扫描 |
|||
Future.delayed(const Duration(seconds: 15), () { |
|||
if (isScanning.value) { |
|||
stopScan(); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
/// 停止扫描设备 |
|||
Future<void> stopScan() async { |
|||
try { |
|||
await _bleService.stopScan(); |
|||
isScanning.value = false; |
|||
statusMessage.value = '扫描已停止'; |
|||
} catch (e) { |
|||
statusMessage.value = '停止扫描异常: $e'; |
|||
Get.snackbar('停止扫描失败', '无法停止BLE扫描: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 连接到设备 |
|||
Future<void> connectToDevice(String address) async { |
|||
try { |
|||
statusMessage.value = '正在连接到 $address...'; |
|||
|
|||
final result = await _bleService.connect(address); |
|||
|
|||
if (!result) { |
|||
statusMessage.value = '连接失败'; |
|||
Get.snackbar('连接失败', '无法连接到设备', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} else { |
|||
connectedDeviceAddress.value = address; |
|||
statusMessage.value = '连接成功,正在获取设备信息...'; |
|||
|
|||
// 获取设备信息 |
|||
await _bleService.getDeviceVersion(); |
|||
await _bleService.getBatteryInfo(); |
|||
} |
|||
} catch (e) { |
|||
statusMessage.value = '连接异常: $e'; |
|||
Get.snackbar('连接错误', '连接过程中出错: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 断开设备连接 |
|||
Future<void> disconnect() async { |
|||
try { |
|||
statusMessage.value = '正在断开连接...'; |
|||
|
|||
await _bleService.disconnect(); |
|||
|
|||
connectedDeviceAddress.value = ''; |
|||
isConnected.value = false; |
|||
|
|||
statusMessage.value = '已断开连接'; |
|||
} catch (e) { |
|||
statusMessage.value = '断开连接异常: $e'; |
|||
Get.snackbar('断开连接失败', '无法断开设备连接: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 获取设备版本信息 |
|||
Future<void> getDeviceVersion() async { |
|||
try { |
|||
statusMessage.value = '正在获取设备版本...'; |
|||
|
|||
await _bleService.getDeviceVersion(); |
|||
|
|||
statusMessage.value = '版本获取命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '获取版本异常: $e'; |
|||
Get.snackbar('获取版本失败', '无法获取设备版本: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 获取电池信息 |
|||
Future<void> getBatteryInfo() async { |
|||
try { |
|||
statusMessage.value = '正在获取电池信息...'; |
|||
|
|||
await _bleService.getBatteryInfo(); |
|||
|
|||
statusMessage.value = '电池信息获取命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '获取电池信息异常: $e'; |
|||
Get.snackbar('获取电池信息失败', '无法获取电池信息: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 打开编码器 |
|||
Future<void> openEncoder() async { |
|||
try { |
|||
statusMessage.value = '正在打开编码器...'; |
|||
|
|||
await _bleService.openEncoder(); |
|||
|
|||
statusMessage.value = '编码器打开命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '打开编码器异常: $e'; |
|||
Get.snackbar('操作失败', '无法打开编码器: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 打开解码器 |
|||
Future<void> openDecoder() async { |
|||
try { |
|||
statusMessage.value = '正在打开解码器...'; |
|||
|
|||
await _bleService.openDecoder(); |
|||
|
|||
statusMessage.value = '解码器打开命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '打开解码器异常: $e'; |
|||
Get.snackbar('操作失败', '无法打开解码器: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 关闭编解码器 |
|||
Future<void> closeCodec() async { |
|||
try { |
|||
statusMessage.value = '正在关闭编解码器...'; |
|||
|
|||
await _bleService.closeCodec(); |
|||
|
|||
statusMessage.value = '编解码器关闭命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '关闭编解码器异常: $e'; |
|||
Get.snackbar('操作失败', '无法关闭编解码器: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 播放A2DP |
|||
Future<void> playA2DP() async { |
|||
try { |
|||
statusMessage.value = '正在播放A2DP...'; |
|||
|
|||
await _bleService.playA2DP(); |
|||
|
|||
statusMessage.value = 'A2DP播放命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '播放A2DP异常: $e'; |
|||
Get.snackbar('操作失败', '无法播放A2DP: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 音量增加 |
|||
Future<void> volumeUp() async { |
|||
try { |
|||
statusMessage.value = '正在增加音量...'; |
|||
|
|||
await _bleService.volumeUp(); |
|||
|
|||
statusMessage.value = '音量增加命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '增加音量异常: $e'; |
|||
Get.snackbar('操作失败', '无法增加音量: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 音量减小 |
|||
Future<void> volumeDown() async { |
|||
try { |
|||
statusMessage.value = '正在减小音量...'; |
|||
|
|||
await _bleService.volumeDown(); |
|||
|
|||
statusMessage.value = '音量减小命令已发送'; |
|||
} catch (e) { |
|||
statusMessage.value = '减小音量异常: $e'; |
|||
Get.snackbar('操作失败', '无法减小音量: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 发送音频数据 |
|||
Future<void> sendAudioData(Uint8List data) async { |
|||
try { |
|||
statusMessage.value = '正在发送音频数据...'; |
|||
|
|||
await _bleService.sendAudioData(data); |
|||
|
|||
statusMessage.value = '音频数据发送成功: ${data.length} 字节'; |
|||
} catch (e) { |
|||
statusMessage.value = '发送音频数据异常: $e'; |
|||
Get.snackbar('发送失败', '无法发送音频数据: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
Future<void> cleanup() async { |
|||
try { |
|||
await _bleService.cleanup(); |
|||
} catch (e) { |
|||
Get.snackbar('清理失败', '无法清理BLE资源: $e', |
|||
snackPosition: SnackPosition.BOTTOM); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
// 取消所有订阅 |
|||
_scanSubscription.cancel(); |
|||
_connectionSubscription.cancel(); |
|||
_dataSubscription.cancel(); |
|||
_statusSubscription.cancel(); |
|||
|
|||
// 停止扫描 |
|||
if (isScanning.value) { |
|||
stopScan(); |
|||
} |
|||
|
|||
// 断开连接 |
|||
if (isConnected.value) { |
|||
disconnect(); |
|||
} |
|||
|
|||
// 清理资源 |
|||
cleanup(); |
|||
|
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -0,0 +1,279 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/ble_test_controller.dart'; |
|||
|
|||
class BleTestView extends GetView<BleTestController> { |
|||
const BleTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Scaffold( |
|||
appBar: AppBar( |
|||
title: const Text('BLE测试'), |
|||
actions: [ |
|||
Obx(() => IconButton( |
|||
icon: const Icon(Icons.refresh), |
|||
onPressed: controller.isScanning.value ? null : controller.startScan, |
|||
)), |
|||
], |
|||
), |
|||
body: Column( |
|||
children: [ |
|||
// 状态消息 - 使用AnimatedContainer让过渡更平滑 |
|||
Obx(() => AnimatedContainer( |
|||
duration: const Duration(milliseconds: 300), |
|||
padding: const EdgeInsets.all(8), |
|||
width: double.infinity, |
|||
color: Colors.grey[200], |
|||
child: Text( |
|||
controller.statusMessage.value, |
|||
style: const TextStyle(fontSize: 14), |
|||
), |
|||
)), |
|||
|
|||
// 根据连接状态显示不同的内容 |
|||
Expanded( |
|||
child: Obx(() { |
|||
// 保存连接状态到局部变量,避免多次访问反应式变量 |
|||
final isConnected = controller.isConnected.value; |
|||
return isConnected ? _buildConnectedView() : _buildScanView(); |
|||
}), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 扫描视图 |
|||
Widget _buildScanView() { |
|||
return Column( |
|||
children: [ |
|||
// 扫描控制 |
|||
Padding( |
|||
padding: const EdgeInsets.all(16.0), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|||
children: [ |
|||
Obx(() { |
|||
final isScanning = controller.isScanning.value; |
|||
return ElevatedButton( |
|||
onPressed: isScanning ? null : controller.startScan, |
|||
child: Text(isScanning ? '扫描中...' : '开始扫描'), |
|||
); |
|||
}), |
|||
Obx(() => ElevatedButton( |
|||
onPressed: controller.isScanning.value |
|||
? controller.stopScan |
|||
: null, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red, |
|||
), |
|||
child: const Text('停止扫描'), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
|
|||
// 设备列表 - 使用RepaintBoundary提高性能 |
|||
Expanded( |
|||
child: RepaintBoundary( |
|||
child: Obx(() { |
|||
if (controller.deviceList.isEmpty) { |
|||
return const Center( |
|||
child: Text('没有找到设备,请开始扫描'), |
|||
); |
|||
} |
|||
|
|||
// 仅在需要时创建列表视图 |
|||
return ListView.builder( |
|||
// 使用常量key避免整体重建 |
|||
key: const ValueKey('device_list'), |
|||
itemCount: controller.deviceList.length, |
|||
itemBuilder: (context, index) { |
|||
final device = controller.deviceList[index]; |
|||
return Card( |
|||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), |
|||
child: ListTile( |
|||
title: Text(device.name), |
|||
subtitle: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text('ID: ${device.id}'), |
|||
if (device.uuids.isNotEmpty) |
|||
Text( |
|||
'UUID: ${device.uuids.length > 0 ? device.uuids.first : "无"}', |
|||
style: const TextStyle(fontSize: 12), |
|||
overflow: TextOverflow.ellipsis, |
|||
maxLines: 1, |
|||
), |
|||
if (device.uuids.length > 1) |
|||
Text( |
|||
'其他UUID: ${device.uuids.length - 1}个', |
|||
style: const TextStyle(fontSize: 12), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
], |
|||
), |
|||
trailing: Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Text('RSSI: ${device.rssi}'), |
|||
const SizedBox(height: 4), |
|||
const Icon(Icons.bluetooth, size: 16), |
|||
], |
|||
), |
|||
onTap: () => controller.connectToDevice(device.id), |
|||
), |
|||
); |
|||
}, |
|||
); |
|||
}), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 已连接设备视图 |
|||
Widget _buildConnectedView() { |
|||
return SingleChildScrollView( |
|||
padding: const EdgeInsets.all(16), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
// 设备信息卡片 |
|||
Card( |
|||
child: Padding( |
|||
padding: const EdgeInsets.all(16), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Obx(() => Text( |
|||
'设备地址: ${controller.connectedDeviceAddress.value}', |
|||
style: const TextStyle(fontWeight: FontWeight.bold), |
|||
)), |
|||
const SizedBox(height: 8), |
|||
Obx(() => Text('电池电量: ${controller.batteryLevel.value}%')), |
|||
const SizedBox(height: 4), |
|||
Obx(() => Text('固件版本: ${controller.firmwareVersion.value}')), |
|||
const SizedBox(height: 16), |
|||
Wrap( |
|||
spacing: 8, // 水平间距 |
|||
runSpacing: 8, // 垂直间距 |
|||
alignment: WrapAlignment.spaceBetween, |
|||
children: [ |
|||
SizedBox( |
|||
width: 110, |
|||
child: ElevatedButton( |
|||
onPressed: controller.getDeviceVersion, |
|||
child: const Text('获取版本'), |
|||
), |
|||
), |
|||
SizedBox( |
|||
width: 110, |
|||
child: ElevatedButton( |
|||
onPressed: controller.getBatteryInfo, |
|||
child: const Text('获取电量'), |
|||
), |
|||
), |
|||
SizedBox( |
|||
width: 110, |
|||
child: ElevatedButton( |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red, |
|||
), |
|||
onPressed: controller.disconnect, |
|||
child: const Text('断开连接'), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
|
|||
const SizedBox(height: 24), |
|||
|
|||
// 音频控制 |
|||
const Text('音频控制', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), |
|||
const SizedBox(height: 16), |
|||
|
|||
Wrap( |
|||
spacing: 8, |
|||
runSpacing: 8, |
|||
alignment: WrapAlignment.start, |
|||
children: [ |
|||
SizedBox( |
|||
width: 140, |
|||
child: ElevatedButton( |
|||
onPressed: controller.openEncoder, |
|||
child: const Text('打开编码器'), |
|||
), |
|||
), |
|||
SizedBox( |
|||
width: 140, |
|||
child: ElevatedButton( |
|||
onPressed: controller.openDecoder, |
|||
child: const Text('打开解码器'), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
|
|||
const SizedBox(height: 16), |
|||
|
|||
Wrap( |
|||
spacing: 8, |
|||
runSpacing: 8, |
|||
alignment: WrapAlignment.start, |
|||
children: [ |
|||
SizedBox( |
|||
width: 140, |
|||
child: ElevatedButton( |
|||
onPressed: controller.closeCodec, |
|||
child: const Text('关闭编解码器'), |
|||
), |
|||
), |
|||
SizedBox( |
|||
width: 140, |
|||
child: ElevatedButton( |
|||
onPressed: controller.playA2DP, |
|||
child: const Text('播放A2DP'), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
|
|||
const SizedBox(height: 24), |
|||
|
|||
// 音量控制 |
|||
const Text('音量控制', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), |
|||
const SizedBox(height: 16), |
|||
|
|||
Wrap( |
|||
spacing: 8, |
|||
runSpacing: 8, |
|||
alignment: WrapAlignment.start, |
|||
children: [ |
|||
SizedBox( |
|||
width: 140, |
|||
child: ElevatedButton( |
|||
onPressed: controller.volumeUp, |
|||
child: const Text('音量+'), |
|||
), |
|||
), |
|||
SizedBox( |
|||
width: 140, |
|||
child: ElevatedButton( |
|||
onPressed: controller.volumeDown, |
|||
child: const Text('音量-'), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../controllers/speech_test_controller.dart'; |
|||
|
|||
class SpeechTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<SpeechTestController>( |
|||
() => SpeechTestController(), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,416 @@ |
|||
import 'dart:async'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../../../data/services/asr_service.dart'; |
|||
import '../../../data/services/tts_service.dart'; |
|||
import '../../../data/services/speech_factory.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
|
|||
/// 语音测试控制器 |
|||
class SpeechTestController extends GetxController { |
|||
// 语音服务工厂 |
|||
final SpeechFactory _speechFactory = Get.find<SpeechFactory>(); |
|||
|
|||
// 当前使用的TTS服务 |
|||
late TtsService _ttsService; |
|||
|
|||
// 当前使用的ASR服务 |
|||
late AsrService _asrService; |
|||
|
|||
// 状态变量 |
|||
final RxBool isInitialized = false.obs; |
|||
final RxBool isProcessing = false.obs; |
|||
final RxBool isListening = false.obs; |
|||
final RxBool isSpeaking = false.obs; |
|||
|
|||
// 识别结果 |
|||
final RxString intermediateResult = ''.obs; // 中间结果 |
|||
final RxString finalResult = ''.obs; // 最终结果 |
|||
final RxString recognitionStatus = '准备就绪'.obs; |
|||
final RxString detectedLanguage = ''.obs; // 检测到的语言 |
|||
|
|||
// TTS事件 |
|||
final RxString ttsStatus = '准备就绪'.obs; |
|||
|
|||
// 当前选择的语音服务类型 |
|||
final Rx<SpeechServiceType> selectedServiceType = SpeechServiceType.azure.obs; |
|||
|
|||
// 连续识别结果流 |
|||
StreamSubscription<RecognitionEvent>? _recognitionSubscription; |
|||
|
|||
// TTS事件流 |
|||
StreamSubscription<TtsEvent>? _ttsEventSubscription; |
|||
|
|||
// 文本控制器 |
|||
final TextEditingController textController = TextEditingController(); |
|||
|
|||
// 连续播放的测试句子 |
|||
final List<String> testSentences = [ |
|||
'这是第一句测试语音。', |
|||
'这是第二句测试语音,语音合成支持连续播放。', |
|||
'这是第三句测试语音,将在连续模式下播放完毕。' |
|||
]; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
// 设置默认待播放文本 |
|||
textController.text = '欢迎使用语音测试,这是一段默认的测试文本。您可以修改此文本,然后点击播放按钮测试语音合成效果。'; |
|||
_initServices(); |
|||
} |
|||
|
|||
/// 初始化服务 |
|||
Future<void> _initServices() async { |
|||
try { |
|||
isProcessing.value = true; |
|||
|
|||
// 获取服务实例 |
|||
_ttsService = _speechFactory.getTtsService(); |
|||
_asrService = _speechFactory.getAsrService(); |
|||
|
|||
// 初始化服务 |
|||
await _ttsService.initialize(); |
|||
await _asrService.initialize(); |
|||
|
|||
// 监听TTS事件 |
|||
_setupTtsEventListener(); |
|||
|
|||
isInitialized.value = true; |
|||
ttsStatus.value = '准备就绪'; |
|||
recognitionStatus.value = '准备就绪'; |
|||
} catch (e) { |
|||
ttsStatus.value = '初始化失败'; |
|||
recognitionStatus.value = '初始化失败'; |
|||
Logger.error('语音测试初始化失败: $e'); |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 设置TTS事件监听 |
|||
void _setupTtsEventListener() { |
|||
// 取消旧的订阅 |
|||
_ttsEventSubscription?.cancel(); |
|||
|
|||
// 设置新的订阅 |
|||
_ttsEventSubscription = _ttsService.onEvent.listen((event) { |
|||
switch (event.type) { |
|||
case TtsEventType.started: |
|||
isSpeaking.value = true; |
|||
ttsStatus.value = '正在播放...'; |
|||
break; |
|||
case TtsEventType.completed: |
|||
isSpeaking.value = false; |
|||
ttsStatus.value = '播放完成'; |
|||
break; |
|||
case TtsEventType.canceled: |
|||
isSpeaking.value = false; |
|||
ttsStatus.value = '播放已取消'; |
|||
break; |
|||
case TtsEventType.error: |
|||
isSpeaking.value = false; |
|||
ttsStatus.value = '播放错误: ${event.error}'; |
|||
break; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 设置ASR事件监听 |
|||
void _setupAsrEventListener(Stream<RecognitionEvent> stream) { |
|||
// 取消旧的订阅 |
|||
_recognitionSubscription?.cancel(); |
|||
|
|||
// 重置识别结果 |
|||
intermediateResult.value = ''; |
|||
finalResult.value = ''; |
|||
detectedLanguage.value = ''; |
|||
recognitionStatus.value = '正在等待语音输入...'; |
|||
|
|||
// 设置新的订阅 |
|||
_recognitionSubscription = stream.listen((event) { |
|||
switch (event.type) { |
|||
case RecognitionEventType.sessionStarted: |
|||
isListening.value = true; |
|||
recognitionStatus.value = '开始聆听...'; |
|||
break; |
|||
|
|||
case RecognitionEventType.sessionStopped: |
|||
isListening.value = false; |
|||
recognitionStatus.value = '聆听已结束'; |
|||
// 清空中间结果,保留最终结果 |
|||
intermediateResult.value = ''; |
|||
break; |
|||
|
|||
case RecognitionEventType.intermediateResult: |
|||
// 更新中间结果,显示为斜体或不同颜色 |
|||
intermediateResult.value = event.text; |
|||
if (event.detectedLanguage.isNotEmpty) { |
|||
detectedLanguage.value = event.detectedLanguage; |
|||
} |
|||
recognitionStatus.value = '正在识别...'; |
|||
break; |
|||
|
|||
case RecognitionEventType.finalResult: |
|||
// 更新最终结果,显示为正常文本 |
|||
finalResult.value = event.text; |
|||
intermediateResult.value = ''; // 清空中间结果 |
|||
if (event.detectedLanguage.isNotEmpty) { |
|||
detectedLanguage.value = event.detectedLanguage; |
|||
} |
|||
recognitionStatus.value = '识别完成'; |
|||
break; |
|||
|
|||
case RecognitionEventType.error: |
|||
isListening.value = false; |
|||
recognitionStatus.value = '识别错误: ${event.error}'; |
|||
break; |
|||
|
|||
case RecognitionEventType.canceled: |
|||
isListening.value = false; |
|||
recognitionStatus.value = '识别已取消'; |
|||
// 清空中间结果 |
|||
intermediateResult.value = ''; |
|||
break; |
|||
} |
|||
}, onError: (error) { |
|||
isListening.value = false; |
|||
recognitionStatus.value = '识别错误: $error'; |
|||
intermediateResult.value = ''; |
|||
}); |
|||
} |
|||
|
|||
/// 切换语音服务类型 |
|||
void switchServiceType(SpeechServiceType type) async { |
|||
if (selectedServiceType.value == type) return; |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
|
|||
// 停止当前的所有服务状态 |
|||
await stopAll(); |
|||
|
|||
// 切换服务类型 |
|||
_speechFactory.setServiceType(type); |
|||
selectedServiceType.value = type; |
|||
|
|||
// 重新初始化服务 |
|||
await _initServices(); |
|||
} catch (e) { |
|||
Logger.error('切换语音服务类型失败: $e'); |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 停止所有活动 |
|||
Future<void> stopAll() async { |
|||
// 停止ASR |
|||
if (isListening.value) { |
|||
await stopContinuousRecognition(); |
|||
} |
|||
|
|||
// 停止TTS |
|||
if (isSpeaking.value) { |
|||
await stopSpeaking(); |
|||
} |
|||
|
|||
// 取消所有订阅 |
|||
await _recognitionSubscription?.cancel(); |
|||
_recognitionSubscription = null; |
|||
} |
|||
|
|||
/// 单次播放文本 |
|||
Future<void> speakOnce() async { |
|||
if (!isInitialized.value || isProcessing.value) return; |
|||
|
|||
final text = textController.text.trim(); |
|||
if (text.isEmpty) { |
|||
ttsStatus.value = '请输入要播放的文本'; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
ttsStatus.value = '准备播放...'; |
|||
|
|||
final result = await _ttsService.speakOnce(text); |
|||
|
|||
if (!result) { |
|||
ttsStatus.value = '播放失败'; |
|||
} |
|||
} catch (e) { |
|||
ttsStatus.value = '播放异常: $e'; |
|||
Logger.error('单次播放异常: $e'); |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 连续播放测试 |
|||
Future<void> speakContinuously() async { |
|||
if (!isInitialized.value || isProcessing.value) return; |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
ttsStatus.value = '准备连续播放...'; |
|||
|
|||
// 为避免阻塞,使用一个句子接一个句子的方式添加到队列 |
|||
for (final sentence in testSentences) { |
|||
if (!await _ttsService.speakStream(sentence)) { |
|||
ttsStatus.value = '添加句子到队列失败'; |
|||
break; |
|||
} |
|||
// 给前一个句子一点处理时间 |
|||
await Future.delayed(const Duration(milliseconds: 50)); |
|||
} |
|||
|
|||
// 刷新队列,确保播放完成 |
|||
await _ttsService.flushStream(); |
|||
|
|||
} catch (e) { |
|||
ttsStatus.value = '连续播放异常: $e'; |
|||
Logger.error('连续播放异常: $e'); |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 停止播放 |
|||
Future<void> stopSpeaking() async { |
|||
if (!isInitialized.value) return; |
|||
|
|||
try { |
|||
await _ttsService.stop(); |
|||
} catch (e) { |
|||
ttsStatus.value = '停止播放异常: $e'; |
|||
Logger.error('停止播放异常: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 一次性识别 |
|||
Future<void> recognizeOnce() async { |
|||
if (!isInitialized.value || isProcessing.value || isListening.value) return; |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
recognitionStatus.value = '开始识别...'; |
|||
|
|||
// 清空之前的结果 |
|||
intermediateResult.value = ''; |
|||
finalResult.value = ''; |
|||
detectedLanguage.value = ''; |
|||
|
|||
// 显示正在进行的状态 |
|||
recognitionStatus.value = '正在聆听...'; |
|||
|
|||
// 执行一次性识别 - 这是同步等待结果的操作 |
|||
final result = await _asrService.recognizeOnce(); |
|||
|
|||
if (result.isError) { |
|||
recognitionStatus.value = '识别错误: ${result.error}'; |
|||
Logger.error('一次性识别错误: ${result.error}'); |
|||
} else { |
|||
// 更新最终结果 |
|||
finalResult.value = result.text; |
|||
|
|||
// 更新语言信息 |
|||
if (result.detectedLanguage.isNotEmpty) { |
|||
detectedLanguage.value = result.detectedLanguage; |
|||
Logger.debug('检测到语言: ${result.detectedLanguage}'); |
|||
} |
|||
|
|||
recognitionStatus.value = '识别完成'; |
|||
} |
|||
} catch (e) { |
|||
recognitionStatus.value = '识别异常: $e'; |
|||
Logger.error('一次性识别异常: $e'); |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 开始连续识别 |
|||
Future<void> startContinuousRecognition() async { |
|||
if (!isInitialized.value || isProcessing.value || isListening.value) return; |
|||
|
|||
try { |
|||
isProcessing.value = true; |
|||
|
|||
// 清空之前的结果 |
|||
intermediateResult.value = ''; |
|||
finalResult.value = ''; |
|||
detectedLanguage.value = ''; |
|||
recognitionStatus.value = '准备连续识别...'; |
|||
|
|||
// 获取识别事件流 - 这启动了异步识别过程 |
|||
final stream = await _asrService.startContinuousRecognition(); |
|||
|
|||
// 设置事件监听 - 异步处理识别结果 |
|||
_setupAsrEventListener(stream); |
|||
|
|||
} catch (e) { |
|||
recognitionStatus.value = '启动连续识别异常: $e'; |
|||
Logger.error('启动连续识别异常: $e'); |
|||
isListening.value = false; |
|||
intermediateResult.value = ''; |
|||
finalResult.value = ''; |
|||
} finally { |
|||
isProcessing.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 停止连续识别 |
|||
Future<void> stopContinuousRecognition() async { |
|||
if (!isInitialized.value || !isListening.value) return; |
|||
|
|||
try { |
|||
// 首先更新状态,防止重复调用 |
|||
isListening.value = false; |
|||
recognitionStatus.value = '正在停止识别...'; |
|||
|
|||
// 停止识别过程 |
|||
await _asrService.stopContinuousRecognition(); |
|||
|
|||
// 更新UI状态 |
|||
recognitionStatus.value = '连续识别已停止'; |
|||
intermediateResult.value = ''; |
|||
} catch (e) { |
|||
recognitionStatus.value = '停止连续识别异常: $e'; |
|||
Logger.error('停止连续识别异常: $e'); |
|||
} |
|||
} |
|||
|
|||
// 获取识别结果(用于UI显示) |
|||
String getDisplayResult() { |
|||
// 如果有最终结果,优先显示 |
|||
String displayText = ''; |
|||
|
|||
if (finalResult.value.isNotEmpty) { |
|||
displayText = finalResult.value; |
|||
} |
|||
|
|||
// 如果有中间结果,添加到最终结果后(可在UI中使用不同样式显示) |
|||
if (intermediateResult.value.isNotEmpty) { |
|||
if (displayText.isNotEmpty) { |
|||
displayText = '$displayText\n\n正在识别: ${intermediateResult.value}'; |
|||
} else { |
|||
displayText = '正在识别: ${intermediateResult.value}'; |
|||
} |
|||
} |
|||
|
|||
return displayText; |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
// 取消所有订阅 |
|||
_recognitionSubscription?.cancel(); |
|||
_ttsEventSubscription?.cancel(); |
|||
|
|||
// 释放控制器 |
|||
textController.dispose(); |
|||
|
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -0,0 +1,349 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../controllers/speech_test_controller.dart'; |
|||
import '../../../data/services/speech_factory.dart'; |
|||
|
|||
class SpeechTestView extends GetView<SpeechTestController> { |
|||
const SpeechTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Scaffold( |
|||
appBar: AppBar( |
|||
title: const Text('语音测试'), |
|||
actions: [ |
|||
IconButton( |
|||
icon: const Icon(Icons.refresh), |
|||
onPressed: () => controller.switchServiceType(controller.selectedServiceType.value), |
|||
tooltip: '重新初始化', |
|||
), |
|||
], |
|||
), |
|||
body: Obx(() { |
|||
return controller.isInitialized.value |
|||
? _buildTestContent() |
|||
: _buildLoadingView(); |
|||
}), |
|||
); |
|||
} |
|||
|
|||
Widget _buildLoadingView() { |
|||
return const Center( |
|||
child: Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
CircularProgressIndicator(), |
|||
SizedBox(height: 16), |
|||
Text('初始化语音服务中...'), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildTestContent() { |
|||
return SingleChildScrollView( |
|||
padding: const EdgeInsets.all(16), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|||
children: [ |
|||
_buildServiceTypeSelector(), |
|||
const SizedBox(height: 24), |
|||
_buildTtsSection(), |
|||
const SizedBox(height: 24), |
|||
const Divider(), |
|||
const SizedBox(height: 24), |
|||
_buildAsrSection(), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildServiceTypeSelector() { |
|||
return Card( |
|||
child: Padding( |
|||
padding: const EdgeInsets.all(16), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
const Text( |
|||
'选择语音服务', |
|||
style: TextStyle( |
|||
fontSize: 18, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
const SizedBox(height: 16), |
|||
Obx(() { |
|||
return Wrap( |
|||
spacing: 8, |
|||
children: [ |
|||
for (final type in SpeechServiceType.values) |
|||
ChoiceChip( |
|||
label: Text(_getServiceTypeName(type)), |
|||
selected: controller.selectedServiceType.value == type, |
|||
onSelected: (selected) { |
|||
if (selected) { |
|||
controller.switchServiceType(type); |
|||
} |
|||
}, |
|||
), |
|||
], |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
String _getServiceTypeName(SpeechServiceType type) { |
|||
switch (type) { |
|||
case SpeechServiceType.azure: |
|||
return 'Azure语音'; |
|||
case SpeechServiceType.flutter: |
|||
return 'Flutter本地'; |
|||
} |
|||
} |
|||
|
|||
Widget _buildTtsSection() { |
|||
return Card( |
|||
child: Padding( |
|||
padding: const EdgeInsets.all(16), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
const Text( |
|||
'语音合成测试', |
|||
style: TextStyle( |
|||
fontSize: 18, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
const SizedBox(height: 16), |
|||
TextField( |
|||
controller: controller.textController, |
|||
decoration: const InputDecoration( |
|||
labelText: '输入要合成的文本', |
|||
border: OutlineInputBorder(), |
|||
), |
|||
maxLines: 3, |
|||
), |
|||
const SizedBox(height: 16), |
|||
Obx(() { |
|||
return Text( |
|||
'状态: ${controller.ttsStatus}', |
|||
style: TextStyle( |
|||
color: _getStatusColor(controller.ttsStatus.value), |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
); |
|||
}), |
|||
const SizedBox(height: 16), |
|||
Wrap( |
|||
spacing: 8, |
|||
runSpacing: 8, |
|||
alignment: WrapAlignment.center, |
|||
children: [ |
|||
Obx(() { |
|||
return ElevatedButton.icon( |
|||
icon: const Icon(Icons.play_arrow), |
|||
label: const Text('单次播放'), |
|||
onPressed: controller.isProcessing.value || |
|||
controller.isSpeaking.value |
|||
? null |
|||
: controller.speakOnce, |
|||
); |
|||
}), |
|||
Obx(() { |
|||
return ElevatedButton.icon( |
|||
icon: const Icon(Icons.playlist_play), |
|||
label: const Text('连续播放测试'), |
|||
onPressed: controller.isProcessing.value || |
|||
controller.isSpeaking.value |
|||
? null |
|||
: controller.speakContinuously, |
|||
); |
|||
}), |
|||
Obx(() { |
|||
return ElevatedButton.icon( |
|||
icon: const Icon(Icons.stop), |
|||
label: const Text('停止'), |
|||
onPressed: controller.isSpeaking.value |
|||
? controller.stopSpeaking |
|||
: null, |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
const SizedBox(height: 8), |
|||
const Divider(), |
|||
const SizedBox(height: 8), |
|||
const Text( |
|||
'连续播放测试会依次播放以下句子:', |
|||
style: TextStyle(fontWeight: FontWeight.bold), |
|||
), |
|||
const SizedBox(height: 8), |
|||
for (int i = 0; i < controller.testSentences.length; i++) |
|||
Padding( |
|||
padding: const EdgeInsets.symmetric(vertical: 4), |
|||
child: Text('${i + 1}. ${controller.testSentences[i]}'), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildAsrSection() { |
|||
return Card( |
|||
child: Padding( |
|||
padding: const EdgeInsets.all(16), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
const Text( |
|||
'语音识别测试', |
|||
style: TextStyle( |
|||
fontSize: 18, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
const SizedBox(height: 16), |
|||
Obx(() { |
|||
return Text( |
|||
'状态: ${controller.recognitionStatus}', |
|||
style: TextStyle( |
|||
color: _getStatusColor(controller.recognitionStatus.value), |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
); |
|||
}), |
|||
const SizedBox(height: 8), |
|||
Obx(() { |
|||
final hasLanguage = controller.detectedLanguage.value.isNotEmpty; |
|||
return Visibility( |
|||
visible: hasLanguage, |
|||
child: Padding( |
|||
padding: const EdgeInsets.only(bottom: 8), |
|||
child: Text( |
|||
'检测语言: ${controller.detectedLanguage.value}', |
|||
style: const TextStyle( |
|||
fontStyle: FontStyle.italic, |
|||
color: Colors.blue, |
|||
), |
|||
), |
|||
), |
|||
); |
|||
}), |
|||
const SizedBox(height: 8), |
|||
Container( |
|||
padding: const EdgeInsets.all(16), |
|||
decoration: BoxDecoration( |
|||
border: Border.all(color: Colors.grey), |
|||
borderRadius: BorderRadius.circular(8), |
|||
), |
|||
constraints: const BoxConstraints(minHeight: 100), |
|||
width: double.infinity, |
|||
child: Obx(() { |
|||
final displayText = controller.getDisplayResult(); |
|||
return RichText( |
|||
text: TextSpan( |
|||
style: const TextStyle(fontSize: 16, color: Colors.black), |
|||
children: [ |
|||
if (displayText.isEmpty) |
|||
const TextSpan( |
|||
text: '识别结果将显示在这里', |
|||
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic), |
|||
) |
|||
else if (displayText.contains('正在识别:')) |
|||
TextSpan( |
|||
text: displayText.split('正在识别:')[0], |
|||
children: [ |
|||
const TextSpan( |
|||
text: '\n\n正在识别: ', |
|||
style: TextStyle( |
|||
color: Colors.blue, |
|||
fontWeight: FontWeight.bold, |
|||
fontStyle: FontStyle.italic, |
|||
), |
|||
), |
|||
TextSpan( |
|||
text: displayText.split('正在识别:')[1].trim(), |
|||
style: const TextStyle( |
|||
color: Colors.blue, |
|||
fontStyle: FontStyle.italic, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
else |
|||
TextSpan(text: displayText), |
|||
], |
|||
), |
|||
); |
|||
}), |
|||
), |
|||
const SizedBox(height: 16), |
|||
Wrap( |
|||
spacing: 8, |
|||
runSpacing: 8, |
|||
alignment: WrapAlignment.center, |
|||
children: [ |
|||
Obx(() { |
|||
return ElevatedButton.icon( |
|||
icon: const Icon(Icons.mic), |
|||
label: const Text('一次性识别'), |
|||
onPressed: controller.isProcessing.value || |
|||
controller.isListening.value || |
|||
controller.isSpeaking.value |
|||
? null |
|||
: controller.recognizeOnce, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green, |
|||
), |
|||
); |
|||
}), |
|||
Obx(() { |
|||
return ElevatedButton.icon( |
|||
icon: Icon( |
|||
controller.isListening.value |
|||
? Icons.mic_off |
|||
: Icons.mic_none, |
|||
), |
|||
label: Text( |
|||
controller.isListening.value ? '停止连续识别' : '开始连续识别', |
|||
), |
|||
onPressed: controller.isProcessing.value || |
|||
controller.isSpeaking.value |
|||
? null |
|||
: controller.isListening.value |
|||
? controller.stopContinuousRecognition |
|||
: controller.startContinuousRecognition, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: controller.isListening.value |
|||
? Colors.red |
|||
: Colors.blue, |
|||
), |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Color _getStatusColor(String status) { |
|||
if (status.contains('错误') || status.contains('失败')) { |
|||
return Colors.red; |
|||
} else if (status.contains('完成') || status.contains('就绪')) { |
|||
return Colors.green; |
|||
} else if (status.contains('正在')) { |
|||
return Colors.blue; |
|||
} else { |
|||
return Colors.black; |
|||
} |
|||
} |
|||
} |
|||
@ -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")) |
|||
} |
|||
@ -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") |
|||
@ -0,0 +1,5 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.agent_service"> |
|||
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
|||
<uses-permission android:name="android.permission.INTERNET" /> |
|||
</manifest> |
|||
@ -0,0 +1,577 @@ |
|||
package com.yunqiinnovation.agent_service |
|||
|
|||
import android.content.Context |
|||
import org.json.JSONArray |
|||
import org.json.JSONObject |
|||
import com.yunqiinnovation.azure_speech.AzureAsrHelper |
|||
import com.yunqiinnovation.azure_speech.AzureTtsHelper |
|||
import com.yunqiinnovation.open_ai_service.OpenAIService |
|||
import com.yunqiinnovation.chat_storage.ChatStorageHelper |
|||
import com.yunqiinnovation.common_utils.FileLogger |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import kotlinx.coroutines.* |
|||
import kotlin.coroutines.CoroutineContext |
|||
|
|||
/** |
|||
* 代理服务事件监听接口 |
|||
*/ |
|||
interface AgentServiceListener { |
|||
/** |
|||
* 当有事件发生时调用此方法 |
|||
* |
|||
* @param eventName 事件名称 |
|||
* @param data 事件数据 |
|||
*/ |
|||
fun onEvent(eventName: String, data: Map<String, Any>) |
|||
} |
|||
|
|||
/** |
|||
* 代理服务 - 处理语音识别、TTS和AI对话相关逻辑 |
|||
*/ |
|||
class AgentService( |
|||
private val context: Context, |
|||
private var listener: AgentServiceListener? = null |
|||
) : CoroutineScope { |
|||
private val TAG = "AgentService" |
|||
|
|||
// 协程相关 |
|||
private val job = SupervisorJob() |
|||
override val coroutineContext: CoroutineContext |
|||
get() = Dispatchers.Main + job |
|||
|
|||
// 配置参数 |
|||
private var azureSpeechKey: String = "" |
|||
private var azureSpeechRegion: String = "" |
|||
private var openaiApiKey: String = "" |
|||
private var openaiBaseUrl: String = "" |
|||
private var openaiModel: String = "gpt-3.5-turbo" |
|||
|
|||
// Azure服务 |
|||
private var azureAsrHelper: AzureAsrHelper? = null |
|||
private var azureTtsHelper: AzureTtsHelper? = null |
|||
|
|||
// OpenAI服务 |
|||
private val openAIService = OpenAIService(context.applicationContext) |
|||
|
|||
// 聊天存储服务 |
|||
private val chatStorageHelper = ChatStorageHelper(context) |
|||
|
|||
// 会话ID,用于区分不同聊天上下文 |
|||
private val sessionId = "default_session" |
|||
|
|||
// 历史聊天消息缓存 |
|||
private val historyMessages = JSONArray() |
|||
|
|||
// 状态 |
|||
private var isInitialized = false |
|||
var isRecognitionActive = false |
|||
private set |
|||
var isTtsSpeaking = false |
|||
private set |
|||
var hasSpeechDetected = false |
|||
private set |
|||
|
|||
/** |
|||
* 设置事件监听器 |
|||
*/ |
|||
fun setListener(listener: AgentServiceListener?) { |
|||
this.listener = listener |
|||
} |
|||
|
|||
/** |
|||
* 初始化 |
|||
* @param config 配置参数,包含所需的所有API密钥和设置 |
|||
*/ |
|||
fun initialize(config: Map<String, Any>): Boolean { |
|||
if (isInitialized) return true |
|||
|
|||
try { |
|||
// 从配置中获取参数 |
|||
azureSpeechKey = config["azureSpeechKey"] as? String ?: "" |
|||
azureSpeechRegion = config["azureSpeechRegion"] as? String ?: "" |
|||
openaiApiKey = config["openaiApiKey"] as? String ?: "" |
|||
openaiBaseUrl = config["openaiBaseUrl"] as? String ?: "" |
|||
openaiModel = config["openaiModel"] as? String ?: "gpt-3.5-turbo" |
|||
|
|||
// 检查必要参数 |
|||
if (azureSpeechKey.isEmpty() || azureSpeechRegion.isEmpty() || openaiApiKey.isEmpty()) { |
|||
FileLogger.e(TAG, "初始化失败:关键配置参数缺失") |
|||
return false |
|||
} |
|||
|
|||
// 初始化Azure ASR |
|||
azureAsrHelper = AzureAsrHelper(context).apply { |
|||
initialize( |
|||
subscriptionKey = azureSpeechKey, |
|||
region = azureSpeechRegion, |
|||
audioSourceType = AzureAsrHelper.AudioSourceType.MICROPHONE |
|||
) |
|||
} |
|||
|
|||
// 初始化Azure TTS |
|||
azureTtsHelper = AzureTtsHelper(context).apply { |
|||
initialize( |
|||
subscriptionKey = azureSpeechKey, |
|||
region = azureSpeechRegion |
|||
) |
|||
|
|||
// 设置TTS回调,更新状态 |
|||
setTtsCallback(object : AzureTtsHelper.TtsCallback { |
|||
override fun onSynthesisStarted() { |
|||
sendEvent("tts_started", mapOf("status" to "started")) |
|||
} |
|||
|
|||
override fun onSynthesizing() {} |
|||
|
|||
override fun onAudioDataReceived(audioData: ByteArray) {} |
|||
|
|||
override fun onSynthesisCompleted() { |
|||
isTtsSpeaking = false |
|||
sendEvent("tts_completed", mapOf("status" to "completed")) |
|||
} |
|||
|
|||
override fun onSynthesisCanceled() { |
|||
isTtsSpeaking = false |
|||
sendEvent("tts_canceled", mapOf("status" to "canceled")) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 初始化OpenAI服务 |
|||
openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) |
|||
|
|||
// 加载最近的聊天记录 |
|||
loadChatHistory() |
|||
|
|||
isInitialized = true |
|||
FileLogger.d(TAG, "代理服务初始化成功") |
|||
return true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "初始化失败", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 开始语音识别 |
|||
*/ |
|||
fun startRecognition(): Boolean { |
|||
if (!isInitialized) { |
|||
FileLogger.e(TAG, "服务未初始化") |
|||
return false |
|||
} |
|||
|
|||
if (isRecognitionActive) return true |
|||
|
|||
// 检查录音权限 |
|||
if (!checkRecordAudioPermission()) { |
|||
FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") |
|||
sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) |
|||
return false |
|||
} |
|||
|
|||
isRecognitionActive = true |
|||
hasSpeechDetected = false |
|||
|
|||
try { |
|||
azureAsrHelper?.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
|||
override fun onRecognizing(recognizing: String, detectedLanguage: String) { |
|||
if (recognizing.isNotEmpty()) { |
|||
hasSpeechDetected = true |
|||
sendEvent("recognizing", mapOf( |
|||
"text" to recognizing, |
|||
"language" to detectedLanguage |
|||
)) |
|||
|
|||
if (isTtsSpeaking) { |
|||
stopTts() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onResult(result: String, detectedLanguage: String) { |
|||
if (result.isNotEmpty()) { |
|||
sendEvent("recognition_result", mapOf( |
|||
"text" to result, |
|||
"language" to detectedLanguage |
|||
)) |
|||
|
|||
processWithOpenAI(result) |
|||
} |
|||
|
|||
// 重置状态,继续识别 |
|||
hasSpeechDetected = false |
|||
} |
|||
|
|||
override fun onSessionStarted() { |
|||
sendEvent("recognition_started", mapOf("status" to "started")) |
|||
} |
|||
|
|||
override fun onSessionStopped() { |
|||
isRecognitionActive = false |
|||
sendEvent("recognition_stopped", mapOf("status" to "stopped")) |
|||
} |
|||
|
|||
override fun onCanceled(reason: String, errorDetails: String) { |
|||
isRecognitionActive = false |
|||
sendEvent("recognition_canceled", mapOf( |
|||
"reason" to reason, |
|||
"details" to errorDetails |
|||
)) |
|||
} |
|||
|
|||
override fun onError(error: String) { |
|||
isRecognitionActive = false |
|||
FileLogger.e(TAG, "语音识别出错: $error") |
|||
sendEvent("error", mapOf( |
|||
"code" to "RECOGNITION_ERROR", |
|||
"message" to error |
|||
)) |
|||
} |
|||
}) |
|||
return true |
|||
} catch (e: Exception) { |
|||
isRecognitionActive = false |
|||
FileLogger.e(TAG, "启动语音识别失败", e) |
|||
sendEvent("error", mapOf( |
|||
"code" to "RECOGNITION_START_ERROR", |
|||
"message" to e.message.toString() |
|||
)) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止语音识别 |
|||
*/ |
|||
fun stopRecognition() { |
|||
if (!isRecognitionActive) return |
|||
|
|||
FileLogger.d(TAG, "停止语音识别") |
|||
|
|||
try { |
|||
azureAsrHelper?.stopContinuousRecognition() |
|||
isRecognitionActive = false |
|||
FileLogger.d(TAG, "语音识别已停止") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "停止语音识别异常", e) |
|||
isRecognitionActive = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理文本输入 |
|||
* 作为语音输入的补充,直接处理文本并通过事件返回结果 |
|||
* |
|||
* @param text 用户输入文本 |
|||
* @param speakResponse 是否朗读回复,默认为false |
|||
* @return 是否成功开始处理 |
|||
*/ |
|||
fun processTextInput(text: String, speakResponse: Boolean = false): Boolean { |
|||
if (!isInitialized) { |
|||
FileLogger.e(TAG, "服务未初始化") |
|||
sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化")) |
|||
return false |
|||
} |
|||
|
|||
if (text.isEmpty()) { |
|||
FileLogger.e(TAG, "文本输入不能为空") |
|||
sendEvent("error", mapOf("code" to "EMPTY_TEXT", "message" to "文本输入不能为空")) |
|||
return false |
|||
} |
|||
|
|||
// 发送与语音识别结果相同格式的事件 |
|||
sendEvent("recognition_result", mapOf( |
|||
"text" to text, |
|||
"language" to "zh-CN", // 默认语言,未来可以从配置或检测中获取 |
|||
"inputType" to "text" |
|||
)) |
|||
|
|||
// 使用OpenAI处理文本 |
|||
processWithOpenAI(text, speakResponse) |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 使用OpenAI处理语音识别结果 |
|||
*/ |
|||
private fun processWithOpenAI(text: String) { |
|||
processWithOpenAI(text, true) |
|||
} |
|||
|
|||
/** |
|||
* 使用OpenAI处理文本消息 |
|||
* |
|||
* @param text 用户输入文本 |
|||
* @param speakResponse 是否使用TTS朗读回复 |
|||
*/ |
|||
private fun processWithOpenAI( |
|||
text: String, |
|||
speakResponse: Boolean = true |
|||
) { |
|||
FileLogger.d(TAG, "用户问题: $text") |
|||
|
|||
launch(Dispatchers.IO) { |
|||
try { |
|||
// 创建用户消息 |
|||
val userMessage = openAIService.createUserMessage(text) |
|||
|
|||
// 将用户消息添加到历史记录 |
|||
addToHistoryMessages(userMessage) |
|||
|
|||
// 使用历史记录作为上下文发送到OpenAI |
|||
val responseBuilder = StringBuilder() |
|||
|
|||
openAIService.sendMessageStream( |
|||
messages = historyMessages, |
|||
callback = object : OpenAIService.StreamCallback { |
|||
override fun onToken(token: String) { |
|||
responseBuilder.append(token) |
|||
|
|||
// 发送流式回复token |
|||
sendEvent("assistant_token", mapOf("token" to token)) |
|||
} |
|||
|
|||
override fun onComplete() { |
|||
val response = responseBuilder.toString() |
|||
if (response.isNotEmpty()) { |
|||
launch { |
|||
FileLogger.d(TAG, "AI回复: $response") |
|||
|
|||
// 发送完整回复 |
|||
sendEvent("assistant_response", mapOf( |
|||
"text" to response, |
|||
"userInput" to text |
|||
)) |
|||
|
|||
// 添加AI回复到历史记录 |
|||
addToHistoryMessages(openAIService.createAssistantMessage(response)) |
|||
|
|||
// 保存聊天记录 |
|||
saveChatMessage(text, response) |
|||
|
|||
// 视情况决定是否朗读回复 |
|||
if (speakResponse) { |
|||
withContext(Dispatchers.Main) { |
|||
speakText(response) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onError(e: Exception) { |
|||
launch { |
|||
FileLogger.e(TAG, "AI处理出错", e) |
|||
sendEvent("error", mapOf( |
|||
"code" to "AI_ERROR", |
|||
"message" to e.message.toString() |
|||
)) |
|||
} |
|||
} |
|||
|
|||
override fun onFunctionCall(call: JSONObject) { |
|||
FileLogger.d(TAG, "收到函数调用: ${call.getString("name")}") |
|||
sendEvent("function_call", mapOf( |
|||
"name" to call.getString("name"), |
|||
"arguments" to call.toString() |
|||
)) |
|||
} |
|||
} |
|||
) |
|||
|
|||
} catch (e: Exception) { |
|||
withContext(Dispatchers.Main) { |
|||
FileLogger.e(TAG, "AI处理出错", e) |
|||
sendEvent("error", mapOf( |
|||
"code" to "AI_PROCESS_ERROR", |
|||
"message" to e.message.toString() |
|||
)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 加载最近的聊天历史记录 |
|||
*/ |
|||
private fun loadChatHistory() { |
|||
// 清空当前历史记录 |
|||
historyMessages.put(JSONArray()) |
|||
|
|||
try { |
|||
// 使用新的ChatStorageHelper直接获取最近消息 |
|||
val recentMessages = chatStorageHelper.getRecentMessages(sessionId, 10) |
|||
if (recentMessages.isEmpty()) { |
|||
FileLogger.d(TAG, "没有找到历史记录") |
|||
return |
|||
} |
|||
|
|||
// 清空当前历史并转换为OpenAI消息格式 |
|||
historyMessages.put(JSONArray()) |
|||
|
|||
// 将消息添加到历史记录 |
|||
for (message in recentMessages) { |
|||
val sender = message["sender"] as String |
|||
val content = message["message"] as String |
|||
|
|||
if (sender == "user") { |
|||
addToHistoryMessages(openAIService.createUserMessage(content)) |
|||
} else if (sender == "assistant") { |
|||
addToHistoryMessages(openAIService.createAssistantMessage(content)) |
|||
} |
|||
} |
|||
|
|||
FileLogger.d(TAG, "已加载${recentMessages.size}条历史记录") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "加载聊天历史失败", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 添加消息到历史记录,保持最近10条 |
|||
*/ |
|||
private fun addToHistoryMessages(message: JSONObject) { |
|||
// 添加新消息 |
|||
historyMessages.put(message) |
|||
|
|||
// 如果超过10条,删除最早的消息 |
|||
if (historyMessages.length() > 10) { |
|||
// 创建新数组 |
|||
val newHistory = JSONArray() |
|||
|
|||
// 保留最近的10条消息 |
|||
for (i in historyMessages.length() - 10 until historyMessages.length()) { |
|||
newHistory.put(historyMessages.getJSONObject(i)) |
|||
} |
|||
|
|||
// 清空并重新添加 |
|||
historyMessages.put(JSONArray()) |
|||
for (i in 0 until newHistory.length()) { |
|||
historyMessages.put(newHistory.getJSONObject(i)) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* TTS播放函数 |
|||
* |
|||
* @param text 要播放的文本 |
|||
*/ |
|||
fun speakText(text: String) { |
|||
if (text.isEmpty()) return |
|||
|
|||
// 更新状态 |
|||
isTtsSpeaking = true |
|||
|
|||
// 播放文本 |
|||
azureTtsHelper?.speakText(text) |
|||
} |
|||
|
|||
/** |
|||
* 停止TTS播放 |
|||
*/ |
|||
fun stopTts() { |
|||
if (isTtsSpeaking) { |
|||
azureTtsHelper?.stopSpeaking() |
|||
isTtsSpeaking = false |
|||
sendEvent("tts_stopped", mapOf("status" to "stopped")) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 保存聊天消息 |
|||
*/ |
|||
private fun saveChatMessage(userMessage: String, assistantMessage: String) { |
|||
CoroutineScope(Dispatchers.IO).launch { |
|||
try { |
|||
// 保存用户消息 |
|||
val userMessageId = chatStorageHelper.saveMessage( |
|||
sessionId = sessionId, |
|||
message = userMessage, |
|||
sender = "user" |
|||
) |
|||
|
|||
if (userMessageId != -1L) { |
|||
// 保存AI回复 |
|||
val assistantMessageId = chatStorageHelper.saveMessage( |
|||
sessionId = sessionId, |
|||
message = assistantMessage, |
|||
sender = "assistant" |
|||
) |
|||
|
|||
if (assistantMessageId != -1L) { |
|||
FileLogger.d(TAG, "聊天记录已保存:用户消息ID=$userMessageId, 助手消息ID=$assistantMessageId") |
|||
} else { |
|||
FileLogger.e(TAG, "保存助手消息失败") |
|||
} |
|||
} else { |
|||
FileLogger.e(TAG, "保存用户消息失败") |
|||
} |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "保存聊天记录异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清除聊天历史 |
|||
*/ |
|||
fun clearChatHistory(callback: (Boolean) -> Unit) { |
|||
CoroutineScope(Dispatchers.IO).launch { |
|||
try { |
|||
// 清除指定会话的所有消息 |
|||
val success = chatStorageHelper.deleteMessages(sessionId) |
|||
|
|||
if (success) { |
|||
// 清空内存中的历史记录 |
|||
historyMessages.put(JSONArray()) |
|||
FileLogger.d(TAG, "聊天历史已清除") |
|||
} else { |
|||
FileLogger.e(TAG, "清除聊天历史失败") |
|||
} |
|||
|
|||
withContext(Dispatchers.Main) { |
|||
callback(success) |
|||
} |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "清除聊天历史异常", e) |
|||
withContext(Dispatchers.Main) { |
|||
callback(false) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送事件 |
|||
*/ |
|||
private fun sendEvent(eventName: String, data: Map<String, Any>) { |
|||
// 使用监听器回调发送事件 |
|||
listener?.onEvent(eventName, data) |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun dispose() { |
|||
stopRecognition() |
|||
stopTts() |
|||
|
|||
azureAsrHelper?.dispose() |
|||
azureTtsHelper?.dispose() |
|||
job.cancel() |
|||
|
|||
FileLogger.d(TAG, "代理服务资源已释放") |
|||
} |
|||
|
|||
/** |
|||
* 检查录音权限 |
|||
*/ |
|||
private fun checkRecordAudioPermission(): Boolean { |
|||
val permission = android.Manifest.permission.RECORD_AUDIO |
|||
val result = context.checkCallingOrSelfPermission(permission) |
|||
return result == android.content.pm.PackageManager.PERMISSION_GRANTED |
|||
} |
|||
} |
|||
@ -0,0 +1,212 @@ |
|||
package com.yunqiinnovation.agent_service |
|||
|
|||
import android.content.Context |
|||
import androidx.annotation.NonNull |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import io.flutter.plugin.common.EventChannel |
|||
import com.yunqiinnovation.common_utils.FileLogger |
|||
import org.json.JSONObject |
|||
import kotlinx.coroutines.MainScope |
|||
import kotlinx.coroutines.launch |
|||
|
|||
class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler, AgentServiceListener { |
|||
private val TAG = "AgentServicePlugin" |
|||
private lateinit var context: Context |
|||
private lateinit var methodChannel: MethodChannel |
|||
private lateinit var eventChannel: EventChannel |
|||
private var eventSink: EventChannel.EventSink? = null |
|||
private var agentService: AgentService? = null |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
context = flutterPluginBinding.applicationContext |
|||
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.agent_service") |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.agent_service/events") |
|||
eventChannel.setStreamHandler(this) |
|||
|
|||
FileLogger.d(TAG, "插件已加载") |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"initialize" -> { |
|||
try { |
|||
val config = call.argument<Map<String, Any>>("config") |
|||
if (config == null) { |
|||
result.error("INVALID_ARGUMENTS", "配置参数不能为空", null) |
|||
return |
|||
} |
|||
|
|||
// 创建代理服务实例 |
|||
if (agentService == null) { |
|||
agentService = AgentService(context, this) |
|||
} else { |
|||
// 更新监听器 |
|||
agentService?.setListener(this) |
|||
} |
|||
|
|||
// 调用初始化方法,传入配置参数 |
|||
val isInitialized = agentService?.initialize(config) ?: false |
|||
result.success(isInitialized) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "初始化失败", e) |
|||
result.error("INIT_ERROR", "初始化失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"startConversation" -> { |
|||
if (agentService == null) { |
|||
result.error("NOT_INITIALIZED", "服务未初始化", null) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
val isStarted = agentService?.startRecognition() ?: false |
|||
result.success(isStarted) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "启动对话失败", e) |
|||
result.error("START_ERROR", "启动对话失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"stopConversation" -> { |
|||
if (agentService == null) { |
|||
result.error("NOT_INITIALIZED", "服务未初始化", null) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
agentService?.stopRecognition() |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "停止对话失败", e) |
|||
result.error("STOP_ERROR", "停止对话失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"processTextInput" -> { |
|||
if (agentService == null) { |
|||
result.error("NOT_INITIALIZED", "服务未初始化", null) |
|||
return |
|||
} |
|||
|
|||
val text = call.argument<String>("text") |
|||
if (text.isNullOrEmpty()) { |
|||
result.error("INVALID_ARGUMENTS", "文本不能为空", null) |
|||
return |
|||
} |
|||
|
|||
val speakResponse = call.argument<Boolean>("speakResponse") ?: false |
|||
|
|||
try { |
|||
val isProcessing = agentService?.processTextInput( |
|||
text = text, |
|||
speakResponse = speakResponse |
|||
) ?: false |
|||
|
|||
result.success(isProcessing) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "处理文本输入失败", e) |
|||
result.error("TEXT_PROCESS_ERROR", "处理文本输入失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"speakText" -> { |
|||
if (agentService == null) { |
|||
result.error("NOT_INITIALIZED", "服务未初始化", null) |
|||
return |
|||
} |
|||
|
|||
val text = call.argument<String>("text") |
|||
if (text.isNullOrEmpty()) { |
|||
result.error("INVALID_ARGUMENTS", "文本不能为空", null) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
agentService?.speakText(text) |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "语音合成失败", e) |
|||
result.error("TTS_ERROR", "语音合成失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"stopTts" -> { |
|||
if (agentService == null) { |
|||
result.error("NOT_INITIALIZED", "服务未初始化", null) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
agentService?.stopTts() |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "停止语音合成失败", e) |
|||
result.error("STOP_TTS_ERROR", "停止语音合成失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"clearChatHistory" -> { |
|||
if (agentService == null) { |
|||
result.error("NOT_INITIALIZED", "服务未初始化", null) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
agentService?.clearChatHistory { success -> |
|||
result.success(success) |
|||
} |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "清除聊天历史失败", e) |
|||
result.error("CLEAR_HISTORY_ERROR", "清除聊天历史失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"dispose" -> { |
|||
try { |
|||
agentService?.dispose() |
|||
agentService = null |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "释放资源失败", e) |
|||
result.error("DISPOSE_ERROR", "释放资源失败: ${e.message}", null) |
|||
} |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
this.eventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
this.eventSink = null |
|||
} |
|||
|
|||
/** |
|||
* 实现AgentServiceListener接口 |
|||
* 接收来自AgentService的事件,并转发到Flutter |
|||
*/ |
|||
override fun onEvent(eventName: String, data: Map<String, Any>) { |
|||
// 将事件转换为JSON字符串 |
|||
val eventData = JSONObject().apply { |
|||
put("event", eventName) |
|||
put("data", JSONObject(data)) |
|||
} |
|||
|
|||
// 在主线程发送事件到Flutter |
|||
MainScope().launch { |
|||
eventSink?.success(eventData.toString()) |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
eventChannel.setStreamHandler(null) |
|||
agentService?.setListener(null) |
|||
agentService?.dispose() |
|||
agentService = null |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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")) |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.ble_service"> |
|||
|
|||
<!-- Android 12+ 新蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
|
|||
<!-- 旧版兼容 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> |
|||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
|
|||
<!-- 前台服务 & 通知 --> |
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
|||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> |
|||
|
|||
<application> |
|||
<!-- 前台服务:dataSync + connectedDevice 表示同时传数据与保持连接 --> |
|||
<service |
|||
android:name=".BleForegroundService" |
|||
android:exported="false" |
|||
android:foregroundServiceType="dataSync|connectedDevice" /> |
|||
|
|||
<!-- 被系统 PendingIntent 触发的 Receiver --> |
|||
<receiver |
|||
android:name=".BleWakeReceiver" |
|||
android:exported="false"> |
|||
<intent-filter> |
|||
<action android:name="com.yunqiinnovation.ble_service.ACTION_BLE_SCAN" /> |
|||
</intent-filter> |
|||
</receiver> |
|||
|
|||
|
|||
</application> |
|||
</manifest> |
|||
@ -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 |
|||
} |
|||
@ -0,0 +1,311 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.app.* |
|||
import android.bluetooth.BluetoothManager |
|||
import android.content.Intent |
|||
import android.content.pm.ServiceInfo |
|||
import android.os.Build |
|||
import android.os.IBinder |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import androidx.annotation.RequiresApi |
|||
import androidx.core.app.NotificationCompat |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
import android.content.Context |
|||
import android.content.SharedPreferences |
|||
|
|||
/** |
|||
* BLE前台服务 |
|||
* 1. 在onCreate中启动前台服务并注册后台扫描 |
|||
* 2. 在onStartCommand中处理被BleWakeReceiver拉起的重连请求 |
|||
* 3. 在onDestroy中清理资源 |
|||
* 4. 自动扫描连接上一次连接的设备 |
|||
* 5. 响应唤醒命令,自动开启录音功能 |
|||
*/ |
|||
@RequiresApi(26) |
|||
class BleForegroundService : Service(), BleService.Callback { |
|||
|
|||
private lateinit var bleManager: BleManagerWrapper |
|||
private val TAG = "BleForegroundService" |
|||
private val handler = Handler(Looper.getMainLooper()) |
|||
private lateinit var prefs: SharedPreferences |
|||
|
|||
// 扫描相关 |
|||
private var isAutoConnecting = false |
|||
private var scanRetryCount = 0 |
|||
private val MAX_SCAN_RETRY = 3 |
|||
private val SCAN_TIMEOUT = 10000L // 10秒 |
|||
private val AUTO_CONNECT_DELAY = 2000L // 2秒 |
|||
|
|||
override fun onCreate() { |
|||
super.onCreate() |
|||
FileLogger.init(this) |
|||
FileLogger.i(TAG, "BleForegroundService正在创建...") |
|||
|
|||
// 初始化偏好设置存储 |
|||
prefs = getSharedPreferences("ble_service_prefs", Context.MODE_PRIVATE) |
|||
|
|||
// 使用全局BleManagerWrapper单例,共享BleService实例 |
|||
bleManager = BleManagerWrapper.instance.apply { |
|||
init(this@BleForegroundService) |
|||
addCallback(this@BleForegroundService) // 注册回调 |
|||
} |
|||
|
|||
// 使用正确的前台服务类型启动 |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Android 14+ |
|||
startForeground( |
|||
BleConst.FOREGROUND_ID, |
|||
buildNotification("BLE服务运行中..."), |
|||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE |
|||
) |
|||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10-13 |
|||
startForeground( |
|||
BleConst.FOREGROUND_ID, |
|||
buildNotification("BLE服务运行中..."), |
|||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION |
|||
) |
|||
} else { // 较旧版本 |
|||
startForeground(BleConst.FOREGROUND_ID, buildNotification("BLE服务运行中...")) |
|||
} |
|||
|
|||
// 启动自动连接流程 |
|||
startAutoConnect() |
|||
} |
|||
|
|||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
|||
// 若由Receiver带MAC拉起,则立即重连 |
|||
val mac = intent?.getStringExtra("mac") |
|||
FileLogger.d(TAG, "onStartCommand: $mac") |
|||
|
|||
mac?.let { |
|||
FileLogger.i(TAG, "收到连接请求,正在连接设备: $it") |
|||
// 保存最后连接的设备地址 |
|||
saveLastConnectedDevice(it) |
|||
// 连接设备 |
|||
bleManager.connect(it) |
|||
} |
|||
|
|||
return START_STICKY |
|||
} |
|||
|
|||
override fun onBind(i: Intent?): IBinder? = null |
|||
|
|||
override fun onDestroy() { |
|||
FileLogger.i(TAG, "BleForegroundService正在销毁...") |
|||
// 移除回调 |
|||
bleManager.removeCallback(this) |
|||
// 取消所有待处理的操作 |
|||
handler.removeCallbacksAndMessages(null) |
|||
super.onDestroy() |
|||
} |
|||
|
|||
/** |
|||
* 启动自动连接流程 |
|||
*/ |
|||
private fun startAutoConnect() { |
|||
// 如果已经连接则不需要自动连接 |
|||
if (bleManager.isConnected()) { |
|||
FileLogger.i(TAG, "已连接到设备,无需自动连接") |
|||
return |
|||
} |
|||
|
|||
// 获取上次连接的设备 |
|||
val lastDevice = getLastConnectedDevice() |
|||
if (lastDevice.isNullOrEmpty()) { |
|||
FileLogger.i(TAG, "无上次连接设备记录,开始扫描新设备") |
|||
startScanForDevices() |
|||
return |
|||
} |
|||
|
|||
// 尝试连接上次的设备 |
|||
FileLogger.i(TAG, "尝试连接上次设备: $lastDevice") |
|||
bleManager.connect(lastDevice) |
|||
|
|||
// 设置连接超时,如果超时则开始扫描 |
|||
handler.postDelayed({ |
|||
if (!bleManager.isConnected()) { |
|||
FileLogger.i(TAG, "连接上次设备超时,开始扫描新设备") |
|||
startScanForDevices() |
|||
} |
|||
}, AUTO_CONNECT_DELAY) |
|||
} |
|||
|
|||
/** |
|||
* 开始扫描设备 |
|||
*/ |
|||
private fun startScanForDevices() { |
|||
if (isAutoConnecting) return |
|||
|
|||
isAutoConnecting = true |
|||
scanRetryCount = 0 |
|||
|
|||
FileLogger.i(TAG, "开始扫描BLE设备...") |
|||
bleManager.startScan(SCAN_TIMEOUT) |
|||
|
|||
// 设置扫描超时 |
|||
handler.postDelayed({ |
|||
if (isAutoConnecting && scanRetryCount < MAX_SCAN_RETRY) { |
|||
scanRetryCount++ |
|||
FileLogger.i(TAG, "扫描超时,重试 $scanRetryCount/$MAX_SCAN_RETRY") |
|||
bleManager.startScan(SCAN_TIMEOUT) |
|||
} else if (isAutoConnecting) { |
|||
FileLogger.i(TAG, "已达最大重试次数,停止自动连接") |
|||
isAutoConnecting = false |
|||
} |
|||
}, SCAN_TIMEOUT + 1000) // 比扫描超时多1秒 |
|||
} |
|||
|
|||
/** |
|||
* 保存最后连接的设备 |
|||
*/ |
|||
private fun saveLastConnectedDevice(address: String) { |
|||
prefs.edit().putString("last_connected_device", address).apply() |
|||
} |
|||
|
|||
/** |
|||
* 获取最后连接的设备 |
|||
*/ |
|||
private fun getLastConnectedDevice(): String? { |
|||
return prefs.getString("last_connected_device", null) |
|||
} |
|||
|
|||
/** 构建低优先级通知,满足5秒前台规则 */ |
|||
private fun buildNotification(msg: String): Notification { |
|||
val nm = getSystemService(NotificationManager::class.java) |
|||
if (nm.getNotificationChannel(BleConst.CHANNEL_ID) == null) { |
|||
nm.createNotificationChannel( |
|||
NotificationChannel( |
|||
BleConst.CHANNEL_ID, "BLE前台", NotificationManager.IMPORTANCE_LOW |
|||
) |
|||
) |
|||
} |
|||
return NotificationCompat.Builder(this, BleConst.CHANNEL_ID) |
|||
.setSmallIcon(android.R.drawable.ic_dialog_info) |
|||
.setContentTitle("DeepSound") |
|||
.setContentText(msg) |
|||
.build() |
|||
} |
|||
|
|||
/** |
|||
* 更新通知内容 |
|||
*/ |
|||
private fun updateNotification(msg: String) { |
|||
val nm = getSystemService(NotificationManager::class.java) |
|||
nm.notify(BleConst.FOREGROUND_ID, buildNotification(msg)) |
|||
} |
|||
|
|||
// ============================== BleService.Callback 接口实现 ============================== |
|||
|
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
if (!isAutoConnecting || devices.isEmpty()) return |
|||
|
|||
// 自动连接第一个找到的设备 |
|||
val device = devices.firstOrNull() |
|||
device?.let { |
|||
val address = it["id"] as String |
|||
val name = it["name"] as String |
|||
|
|||
FileLogger.i(TAG, "扫描到设备: $name ($address),正在自动连接...") |
|||
saveLastConnectedDevice(address) |
|||
bleManager.connect(address) |
|||
|
|||
// 停止扫描 |
|||
bleManager.stopScan() |
|||
|
|||
// 延迟一段时间检查连接状态 |
|||
handler.postDelayed({ |
|||
if (!bleManager.isConnected()) { |
|||
FileLogger.i(TAG, "自动连接失败,继续扫描...") |
|||
if (scanRetryCount < MAX_SCAN_RETRY) { |
|||
startScanForDevices() |
|||
} else { |
|||
FileLogger.i(TAG, "已达最大重试次数,停止自动连接") |
|||
isAutoConnecting = false |
|||
} |
|||
} |
|||
}, AUTO_CONNECT_DELAY) |
|||
} |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
when (state) { |
|||
BleConst.STATE_CONNECTED -> { |
|||
FileLogger.i(TAG, "设备已连接") |
|||
updateNotification("已连接到BLE设备") |
|||
isAutoConnecting = false |
|||
|
|||
// 获取设备信息 |
|||
// bleManager.getDeviceVersion() |
|||
// bleManager.getBatteryInfo() |
|||
} |
|||
BleConst.STATE_DISCONNECTED -> { |
|||
FileLogger.i(TAG, "设备已断开") |
|||
updateNotification("BLE设备已断开") |
|||
|
|||
// 如果不是主动断开,尝试重新连接 |
|||
// if (!isAutoConnecting) { |
|||
// handler.postDelayed({ |
|||
// startAutoConnect() |
|||
// }, 3000) // 延迟3秒重连 |
|||
// } |
|||
} |
|||
BleConst.STATE_ERROR -> { |
|||
FileLogger.e(TAG, "设备连接错误") |
|||
updateNotification("BLE设备连接错误") |
|||
|
|||
// 连接错误,尝试重新扫描连接 |
|||
// if (!isAutoConnecting) { |
|||
// handler.postDelayed({ |
|||
// startAutoConnect() |
|||
// }, 5000) // 延迟5秒重连 |
|||
// } |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
FileLogger.i(TAG, "收到唤醒信号,开启设备录音功能") |
|||
|
|||
|
|||
// 更新通知 |
|||
updateNotification("已唤醒,正在录音...") |
|||
} |
|||
|
|||
override fun onCommandResult(commandType: Int, success: Boolean) { |
|||
FileLogger.i(TAG, "命令结果:类型=$commandType, 成功=$success") |
|||
} |
|||
|
|||
override fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean) { |
|||
when (commandType) { |
|||
BleConst.CMD_CONTROL_CODEC -> { |
|||
if (success) { |
|||
FileLogger.i(TAG, "编解码控制命令执行成功") |
|||
} else { |
|||
FileLogger.e(TAG, "编解码控制命令执行失败") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDeviceInfoReceived(infoType: Int, infoData: String) { |
|||
when (infoType) { |
|||
BleConst.CMD_GET_VERSION -> { |
|||
FileLogger.i(TAG, "设备版本: $infoData") |
|||
updateNotification("已连接:版本 $infoData") |
|||
} |
|||
BleConst.CMD_GET_BATTERY_INFO -> { |
|||
val parts = infoData.split("|") |
|||
val batteryLevel = parts[0] |
|||
val isCharging = parts.getOrNull(1) == "1" |
|||
|
|||
FileLogger.i(TAG, "电池电量: $batteryLevel%, 充电中: $isCharging") |
|||
updateNotification("已连接:电量 $batteryLevel%${if (isCharging) "(充电中)" else ""}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 以下是必须实现但当前服务不需要处理的回调方法 |
|||
override fun onDataReceived(data: ByteArray) {} |
|||
override fun onAudioDataReceived(data: ByteArray) {} |
|||
override fun onStatusUpdate(status: Map<String, Any>) {} |
|||
} |
|||
@ -0,0 +1,234 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.content.Context |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
import android.util.Log |
|||
import java.util.concurrent.CopyOnWriteArrayList |
|||
|
|||
/** |
|||
* BLE管理器封装类 |
|||
* 单例模式,确保原生层和Flutter层共用同一个BleService实例 |
|||
*/ |
|||
class BleManagerWrapper private constructor() { |
|||
companion object { |
|||
private const val TAG = "BleManagerWrapper" |
|||
|
|||
@JvmStatic |
|||
val instance by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { BleManagerWrapper() } |
|||
} |
|||
|
|||
// BleService实例 |
|||
var bleService: BleService? = null |
|||
private set |
|||
|
|||
// 回调分发器 |
|||
private val callbackDispatcher = CallbackDispatcher() |
|||
|
|||
// 存储所有注册的回调 |
|||
private val registeredCallbacks = CopyOnWriteArrayList<BleService.Callback>() |
|||
|
|||
/** |
|||
* 回调分发器类 - 实现BleService.Callback接口,将回调分发给多个监听者 |
|||
*/ |
|||
private inner class CallbackDispatcher : BleService.Callback { |
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onScanResult(devices) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发扫描结果回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onConnectionStateChanged(state) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发连接状态回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDataReceived(data: ByteArray) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onDataReceived(data) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发数据回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onAudioDataReceived(data: ByteArray) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onAudioDataReceived(data) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发音频数据回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onWakeupSignalReceived() |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发唤醒信号回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCommandResult(commandType: Int, success: Boolean) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onCommandResult(commandType, success) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发命令结果回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onProtocolCommandResult(commandType, data, success) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发协议命令结果回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDeviceInfoReceived(infoType: Int, infoData: String) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onDeviceInfoReceived(infoType, infoData) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发设备信息回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onStatusUpdate(status: Map<String, Any>) { |
|||
for (callback in registeredCallbacks) { |
|||
try { |
|||
callback.onStatusUpdate(status) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "分发状态更新回调异常", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 初始化BLE服务 |
|||
*/ |
|||
fun init(context: Context) { |
|||
FileLogger.d(TAG, "初始化BLE服务: ${bleService}") |
|||
try { |
|||
if (bleService == null) { |
|||
bleService = BleService(context) |
|||
|
|||
// 设置回调分发器 |
|||
bleService?.callback = callbackDispatcher |
|||
FileLogger.d(TAG, "BLE服务初始化成功") |
|||
} |
|||
// 注册后台扫描,确保可被唤醒 |
|||
bleService?.registerBackgroundScan() |
|||
|
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "BLE服务初始化失败", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 添加回调接口 |
|||
* @param callback 回调接口实现 |
|||
* @return 是否添加成功 |
|||
*/ |
|||
fun addCallback(callback: BleService.Callback): Boolean { |
|||
if (!registeredCallbacks.contains(callback)) { |
|||
registeredCallbacks.add(callback) |
|||
FileLogger.d(TAG, "已添加BLE回调, 当前回调数: ${registeredCallbacks.size}") |
|||
return true |
|||
} |
|||
return false |
|||
} |
|||
|
|||
/** |
|||
* 移除回调接口 |
|||
* @param callback 回调接口实现 |
|||
* @return 是否移除成功 |
|||
*/ |
|||
fun removeCallback(callback: BleService.Callback): Boolean { |
|||
val removed = registeredCallbacks.remove(callback) |
|||
if (removed) { |
|||
FileLogger.d(TAG, "已移除BLE回调, 当前回调数: ${registeredCallbacks.size}") |
|||
} |
|||
return removed |
|||
} |
|||
|
|||
|
|||
|
|||
// 以下方法直接委托给BleService实现,避免重复代码 |
|||
|
|||
// 扫描相关 |
|||
fun startScan(timeout: Long = 10000): Boolean = bleService?.startScan(timeout) ?: false |
|||
fun stopScan(): Boolean = bleService?.stopScan() ?: false |
|||
|
|||
// 连接相关 |
|||
fun connect(address: String): Boolean { |
|||
bleService?.connect(address) |
|||
return true |
|||
} |
|||
fun disconnect(): Boolean { |
|||
bleService?.disconnect() |
|||
return true |
|||
} |
|||
fun isConnected(): Boolean = bleService?.isConnected() ?: false |
|||
fun getConnectionState(): Int = bleService?.connectionState?.value ?: BleConst.STATE_DISCONNECTED |
|||
|
|||
// 设备信息 |
|||
fun getConnectedDeviceAddress(): String? = bleService?.getConnectedDeviceAddress() |
|||
fun getBatteryLevel(): Int = bleService?.batteryLevel ?: 0 |
|||
fun getFirmwareVersion(): String? = bleService?.firmwareVersion |
|||
|
|||
// 查询命令 |
|||
fun getDeviceVersion(): Boolean = bleService?.getDeviceVersion() ?: false |
|||
fun getBatteryInfo(): Boolean = bleService?.getBatteryInfo() ?: false |
|||
|
|||
// 控制命令 |
|||
fun openEncoder(): Boolean = bleService?.openEncoder() ?: false |
|||
fun openDecoder(): Boolean = bleService?.openDecoder() ?: false |
|||
fun closeCodec(): Boolean = bleService?.closeCodec() ?: false |
|||
fun playA2DP(): Boolean = bleService?.playA2DP() ?: false |
|||
fun volumeUp(): Boolean = bleService?.volumeUp() ?: false |
|||
fun volumeDown(): Boolean = bleService?.volumeDown() ?: false |
|||
|
|||
// 数据传输 |
|||
fun sendAudioData(data: ByteArray?): Boolean { |
|||
if (data == null) return false |
|||
return bleService?.sendAudioData(data) ?: false |
|||
} |
|||
|
|||
// 清理资源 |
|||
fun cleanup() { |
|||
bleService?.cleanup() |
|||
} |
|||
|
|||
// 获取扫描结果的简化实现 |
|||
fun getScannedDevices(): List<Map<String, Any>> { |
|||
// 简单返回一个转换后的设备列表 |
|||
val devices = mutableListOf<Map<String, Any>>() |
|||
bleService?.getBluetoothAdapter()?.bondedDevices?.forEach { device -> |
|||
devices.add(mapOf( |
|||
"id" to device.address, |
|||
"name" to (device.name ?: "未知设备"), |
|||
"rssi" to 0 // 已配对设备没有RSSI信息 |
|||
)) |
|||
} |
|||
return devices |
|||
} |
|||
} |
|||
@ -0,0 +1,905 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.Manifest |
|||
import android.app.PendingIntent |
|||
import android.bluetooth.* |
|||
import android.bluetooth.le.* |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.os.* |
|||
import androidx.lifecycle.MutableLiveData |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
import java.io.File |
|||
import java.io.FileOutputStream |
|||
import java.text.SimpleDateFormat |
|||
import java.util.* |
|||
import android.os.ParcelUuid |
|||
import android.annotation.SuppressLint |
|||
import android.content.BroadcastReceiver |
|||
import android.content.IntentFilter |
|||
import android.os.SystemClock |
|||
import android.content.pm.PackageManager |
|||
import androidx.core.content.ContextCompat |
|||
import java.util.concurrent.ConcurrentHashMap |
|||
|
|||
/** |
|||
* BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能 |
|||
* 支持: |
|||
* 1. App前台存活时主动扫描+连接 |
|||
* 2. App被杀后依旧保持PendingIntent扫描并可被UUID广播唤醒 |
|||
* 3. 内置命令及协议解析 |
|||
*/ |
|||
class BleService(private val context: Context) { |
|||
|
|||
// ====================================================================================================== |
|||
// 常量定义 |
|||
// ====================================================================================================== |
|||
companion object { |
|||
private const val TAG = "BleService" |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 回调接口 |
|||
// ====================================================================================================== |
|||
interface Callback { |
|||
// 扫描相关回调 |
|||
fun onScanResult(devices: List<Map<String, Any>>) |
|||
|
|||
// 连接状态相关回调 |
|||
fun onConnectionStateChanged(state: Int) |
|||
|
|||
// 数据相关回调 |
|||
fun onDataReceived(data: ByteArray) |
|||
fun onAudioDataReceived(data: ByteArray) |
|||
fun onWakeupSignalReceived() |
|||
|
|||
// 命令响应相关回调 |
|||
fun onCommandResult(commandType: Int, success: Boolean) |
|||
fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean) |
|||
|
|||
// 设备信息相关回调 |
|||
fun onDeviceInfoReceived(infoType: Int, infoData: String) |
|||
|
|||
// 状态更新相关回调 |
|||
fun onStatusUpdate(status: Map<String, Any>) |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 成员变量 |
|||
// ====================================================================================================== |
|||
// 日志与数据存储 |
|||
private var outputFile: File? = null |
|||
private var fos: FileOutputStream? = null |
|||
|
|||
// 蓝牙相关组件 |
|||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter |
|||
private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner |
|||
|
|||
// GATT连接相关 |
|||
private var bluetoothGatt: BluetoothGatt? = null |
|||
private var notifyChar: BluetoothGattCharacteristic? = null |
|||
private var writeChar: BluetoothGattCharacteristic? = null |
|||
|
|||
// 扫描相关 |
|||
private val scanHandler = Handler(Looper.getMainLooper()) |
|||
private val scanResults = ArrayList<ScanResult>() |
|||
private var isScanning = false |
|||
|
|||
// 连接状态 |
|||
val connectionState = MutableLiveData(BleConst.STATE_DISCONNECTED) |
|||
|
|||
// 回调 |
|||
var callback: Callback? = null |
|||
|
|||
// 设备信息 |
|||
var firmwareVersion: String? = null |
|||
private set |
|||
var batteryLevel: Int = 0 |
|||
private set |
|||
|
|||
// ====================================================================================================== |
|||
// 扫描功能 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 开始主动扫描BLE设备 |
|||
* @param timeout 扫描超时时间(毫秒) |
|||
* @return 是否成功开始扫描 |
|||
*/ |
|||
fun startScan(timeout: Long = 15000): Boolean { |
|||
if (bluetoothLeScanner == null || isScanning) return false |
|||
|
|||
FileLogger.i(TAG, "开始主动扫描BLE设备...") |
|||
unregisterBackgroundScan() // 避免双重扫描 |
|||
scanResults.clear() |
|||
|
|||
val filter = ScanFilter.Builder() |
|||
.setServiceUuid(ParcelUuid(BleConst.PRIMARY_SERVICE_UUID)) |
|||
.build() |
|||
|
|||
val settings = ScanSettings.Builder() |
|||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) |
|||
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) // 使用ALL_MATCHES获取所有结果 |
|||
.setReportDelay(0) // 立即报告结果 |
|||
.build() |
|||
|
|||
try { |
|||
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) |
|||
isScanning = true |
|||
|
|||
// 设置扫描超时 |
|||
scanHandler.postDelayed({ |
|||
FileLogger.d(TAG, "扫描超时,停止扫描") |
|||
stopScan() |
|||
}, timeout) |
|||
|
|||
return true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "启动扫描失败: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止主动扫描 |
|||
* @return 是否成功停止扫描 |
|||
*/ |
|||
fun stopScan(): Boolean { |
|||
if (bluetoothLeScanner == null || !isScanning) return false |
|||
FileLogger.i(TAG, "停止主动扫描") |
|||
|
|||
try { |
|||
bluetoothLeScanner.stopScan(scanCallback) |
|||
scanHandler.removeCallbacksAndMessages(null) |
|||
isScanning = false |
|||
|
|||
// 重新注册后台扫描,保证唤醒能力 |
|||
registerBackgroundScan() |
|||
|
|||
return true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "停止扫描失败: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 扫描回调 |
|||
*/ |
|||
private val scanCallback = object : ScanCallback() { |
|||
override fun onScanResult(type: Int, result: ScanResult) { |
|||
val dev = result.device ?: return |
|||
|
|||
// 打印设备信息和服务UUID |
|||
printDeviceInfo(result) |
|||
|
|||
// 直接上报每个扫描结果 |
|||
try { |
|||
// 将单个扫描结果转换为需要的格式 |
|||
val uuids = result.scanRecord?.serviceUuids?.map { it.uuid.toString() } ?: emptyList() |
|||
val deviceMap = mapOf( |
|||
"id" to dev.address, |
|||
"name" to (dev.name ?: "未知"), |
|||
"rssi" to result.rssi, |
|||
"uuids" to uuids |
|||
) |
|||
|
|||
// 直接回调单个设备结果 |
|||
callback?.onScanResult(listOf(deviceMap)) |
|||
|
|||
// 更新缓存 |
|||
val idx = scanResults.indexOfFirst { it.device.address == dev.address } |
|||
if (idx >= 0) scanResults[idx] = result else scanResults.add(result) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "上报扫描结果异常: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
override fun onScanFailed(errorCode: Int) { |
|||
FileLogger.e(TAG, "扫描失败 error=$errorCode") |
|||
isScanning = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 打印设备信息和服务UUID |
|||
*/ |
|||
private fun printDeviceInfo(result: ScanResult) { |
|||
val device = result.device |
|||
val scanRecord = result.scanRecord |
|||
val serviceUuids = scanRecord?.serviceUuids?.joinToString(", ") { it.uuid.toString() } ?: "无" |
|||
|
|||
FileLogger.d(TAG, "扫描到设备: 名称=${device.name ?: "未知"}, 地址=${device.address}, " + |
|||
"RSSI=${result.rssi}, 服务UUID=[$serviceUuids]") |
|||
|
|||
// 打印广播数据的详细信息 |
|||
val scanData = scanRecord?.bytes |
|||
if (scanData != null) { |
|||
val hexString = scanData.joinToString("") { String.format("%02X", it) } |
|||
FileLogger.d(TAG, "广播数据: $hexString") |
|||
|
|||
// 解析服务数据 |
|||
scanRecord.serviceData?.forEach { (uuid, data) -> |
|||
val dataHex = data.joinToString("") { String.format("%02X", it) } |
|||
FileLogger.d(TAG, "服务数据: UUID=${uuid.uuid}, 数据=$dataHex") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 设备连接与通信 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 连接到指定地址的BLE设备 |
|||
* @param address 设备MAC地址 |
|||
*/ |
|||
fun connect(address: String) { |
|||
if (connectionState.value == BleConst.STATE_CONNECTED) { |
|||
FileLogger.w(TAG, "已经连接中,忽略连接请求") |
|||
return |
|||
} |
|||
|
|||
if (bluetoothAdapter == null) { |
|||
updateConnectionState(BleConst.STATE_ERROR) |
|||
return |
|||
} |
|||
|
|||
// 关闭现有连接 |
|||
disconnectGatt() |
|||
|
|||
try { |
|||
// 创建新连接 |
|||
val device = bluetoothAdapter.getRemoteDevice(address) |
|||
updateConnectionState(BleConst.STATE_CONNECTING) |
|||
|
|||
bluetoothGatt = device.connectGatt( |
|||
context, false, gattCallback, BluetoothDevice.TRANSPORT_LE |
|||
) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "连接设备失败: ${e.message}", e) |
|||
updateConnectionState(BleConst.STATE_ERROR) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 断开当前连接 |
|||
*/ |
|||
fun disconnect() { |
|||
bluetoothGatt?.disconnect() |
|||
updateConnectionState(BleConst.STATE_DISCONNECTING) |
|||
} |
|||
|
|||
/** |
|||
* 安全关闭GATT连接 |
|||
*/ |
|||
private fun disconnectGatt() { |
|||
bluetoothGatt?.let { gatt -> |
|||
try { |
|||
gatt.disconnect() |
|||
gatt.close() |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "关闭GATT连接异常: ${e.message}", e) |
|||
} |
|||
bluetoothGatt = null |
|||
notifyChar = null |
|||
writeChar = null |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新连接状态并通知回调 |
|||
*/ |
|||
private fun updateConnectionState(state: Int) { |
|||
connectionState.postValue(state) |
|||
callback?.onConnectionStateChanged(state) |
|||
} |
|||
|
|||
/** |
|||
* GATT回调 |
|||
*/ |
|||
private val gattCallback = object : BluetoothGattCallback() { |
|||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { |
|||
when { |
|||
status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED -> { |
|||
updateConnectionState(BleConst.STATE_CONNECTED) |
|||
g.discoverServices() |
|||
} |
|||
newState == BluetoothProfile.STATE_DISCONNECTED -> { |
|||
updateConnectionState(BleConst.STATE_DISCONNECTED) |
|||
disconnectGatt() |
|||
} |
|||
else -> { |
|||
updateConnectionState(BleConst.STATE_ERROR) |
|||
disconnectGatt() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { |
|||
if (status != BluetoothGatt.GATT_SUCCESS) { |
|||
updateConnectionState(BleConst.STATE_ERROR) |
|||
return |
|||
} |
|||
|
|||
val svc = g.getService(BleConst.PRIMARY_SERVICE_UUID) |
|||
notifyChar = svc?.getCharacteristic(BleConst.NOTIFY_CHAR_UUID) |
|||
writeChar = svc?.getCharacteristic(BleConst.WRITE_CHAR_UUID) |
|||
|
|||
if (notifyChar == null || writeChar == null) { |
|||
FileLogger.e(TAG, "未找到所需特征") |
|||
updateConnectionState(BleConst.STATE_ERROR) |
|||
return |
|||
} |
|||
|
|||
writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE |
|||
setupNotifications(g) |
|||
} |
|||
|
|||
override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) { |
|||
val data = c.value ?: return |
|||
|
|||
// 通用数据回调 |
|||
callback?.onDataReceived(data) |
|||
|
|||
// 判断数据类型 |
|||
when { |
|||
// OPUS音频帧 |
|||
data.isNotEmpty() && (data[0].toInt() and 0xFF == 0x4B || |
|||
data[0].toInt() and 0xFF == 0x48) -> { |
|||
processOpusData(data) |
|||
} |
|||
// 设备响应 (0xBB开头) |
|||
data.isNotEmpty() && data[0] == BleConst.DEVICE_RESPONSE_HEADER -> { |
|||
processDeviceResponse(data) |
|||
} |
|||
// 设备主动上报 (0xCC开头) |
|||
data.isNotEmpty() && data[0] == BleConst.DEVICE_NOTIFY_HEADER -> { |
|||
processDeviceNotification(data) |
|||
} |
|||
// 唤醒信号 (0x01开头) |
|||
data.isNotEmpty() && data[0].toInt() == 0x01 -> { |
|||
FileLogger.i(TAG, "收到唤醒信号") |
|||
callback?.onWakeupSignalReceived() |
|||
// 接收到唤醒信号,打开编码器 |
|||
openEncoder() |
|||
} |
|||
// 其他未知数据 |
|||
else -> { |
|||
FileLogger.d(TAG, "收到未知数据: ${data.joinToString(", ") { "0x%02X".format(it) }}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCharacteristicWrite( |
|||
g: BluetoothGatt, c: BluetoothGattCharacteristic, status: Int |
|||
) { |
|||
FileLogger.i(TAG, "写入 ${if (status == BluetoothGatt.GATT_SUCCESS) "成功" else "失败"}") |
|||
} |
|||
|
|||
override fun onDescriptorWrite( |
|||
g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int |
|||
) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) |
|||
FileLogger.i(TAG, "通知描述符写入成功") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置通知功能 |
|||
*/ |
|||
private fun setupNotifications(g: BluetoothGatt) { |
|||
if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0) return |
|||
g.setCharacteristicNotification(notifyChar, true) |
|||
val desc = notifyChar!!.getDescriptor(BleConst.CLIENT_CHAR_CONFIG_UUID) |
|||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE |
|||
g.writeDescriptor(desc) |
|||
} |
|||
|
|||
/** |
|||
* 处理OPUS音频数据 |
|||
*/ |
|||
private fun processOpusData(data: ByteArray) { |
|||
try { |
|||
// 保存原始数据到文件 |
|||
fos?.write(data) |
|||
|
|||
// 判断是否为OPUS格式数据 |
|||
val isOpus = data.size > 2 && |
|||
(data[0].toInt() and 0xFF == 0x4B || |
|||
data[0].toInt() and 0xFF == 0x48) |
|||
|
|||
if (isOpus) { |
|||
// 回调给上层 |
|||
callback?.onAudioDataReceived(data) |
|||
} else { |
|||
FileLogger.w(TAG, "收到非OPUS格式数据") |
|||
} |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "处理OPUS数据异常: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 后台扫描与唤醒 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 注册后台持久扫描,APP被杀死后也能唤醒 |
|||
*/ |
|||
fun registerBackgroundScan() { |
|||
val scanner = bluetoothLeScanner ?: return |
|||
try { |
|||
val filter = ScanFilter.Builder() |
|||
.setServiceUuid(ParcelUuid(BleConst.PRIMARY_SERVICE_UUID)) |
|||
.build() |
|||
|
|||
val settings = ScanSettings.Builder() |
|||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) |
|||
.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH) |
|||
.build() |
|||
|
|||
val pi = PendingIntent.getBroadcast( |
|||
context, 0, |
|||
Intent(BleConst.ACTION_SCAN_RESULT) |
|||
.setClass(context, BleWakeReceiver::class.java), |
|||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE |
|||
) |
|||
scanner.startScan(listOf(filter), settings, pi) |
|||
FileLogger.i(TAG, "后台持久扫描已注册") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "注册后台扫描失败: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 注销后台持久扫描 |
|||
*/ |
|||
fun unregisterBackgroundScan() { |
|||
val scanner = bluetoothLeScanner ?: return |
|||
try { |
|||
val pi = PendingIntent.getBroadcast( |
|||
context, 0, |
|||
Intent(BleConst.ACTION_SCAN_RESULT) |
|||
.setClass(context, BleWakeReceiver::class.java), |
|||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE |
|||
) |
|||
scanner.stopScan(pi) |
|||
FileLogger.i(TAG, "后台持久扫描已注销") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "注销后台扫描失败: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 协议命令实现 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 发送命令到设备 - 通用方法 |
|||
* @param cmdType 命令类型 |
|||
* @param cmdData 命令数据 |
|||
* @return 是否成功发送 |
|||
*/ |
|||
private fun sendCommand(cmdType: Byte, cmdData: ByteArray = byteArrayOf()): Boolean { |
|||
if (!checkConn()) { |
|||
FileLogger.e(TAG, "发送命令失败: 设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
// 计算命令长度 |
|||
val cmdLength = cmdData.size |
|||
|
|||
// 创建命令包 |
|||
val cmdPacket = ByteArray(4 + cmdLength) // 帧头 + 命令 + 长度 + 数据 + CRC |
|||
|
|||
// 帧头 |
|||
cmdPacket[0] = BleConst.APP_REQUEST_HEADER |
|||
// 命令类型 |
|||
cmdPacket[1] = cmdType |
|||
// 命令长度 |
|||
cmdPacket[2] = cmdLength.toByte() |
|||
|
|||
// 填充命令数据(如果有) |
|||
if (cmdLength > 0) { |
|||
System.arraycopy(cmdData, 0, cmdPacket, 3, cmdLength) |
|||
} |
|||
|
|||
// 计算CRC - 对命令类型、长度和数据进行校验 |
|||
val crcData = ByteArray(2 + cmdLength) |
|||
System.arraycopy(cmdPacket, 1, crcData, 0, crcData.size) |
|||
val crc = crc8Maxim(crcData, crcData.size) |
|||
|
|||
// 设置CRC |
|||
cmdPacket[3 + cmdLength] = crc |
|||
|
|||
// 记录命令详情 |
|||
FileLogger.d(TAG, "发送命令: CMD=0x${(cmdType.toInt() and 0xFF).toString(16)}, 长度=$cmdLength, " + |
|||
"数据=${cmdData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}, " + |
|||
"CRC=0x${(crc.toInt() and 0xFF).toString(16)}") |
|||
|
|||
// 发送数据 |
|||
writeChar!!.value = cmdPacket |
|||
return bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "发送命令异常: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 计算CRC8/MAXIM校验值 |
|||
*/ |
|||
private fun crc8Maxim(data: ByteArray, length: Int): Byte { |
|||
var crc = 0 |
|||
val polynomial = 0x31 |
|||
|
|||
for (i in 0 until length) { |
|||
crc = crc xor (data[i].toInt() and 0xFF) |
|||
for (j in 0 until 8) { |
|||
if (crc and 0x80 != 0) { |
|||
crc = ((crc shl 1) xor polynomial) and 0xFF |
|||
} else { |
|||
crc = (crc shl 1) and 0xFF |
|||
} |
|||
} |
|||
} |
|||
return crc.toByte() |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 协议解析 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 处理设备响应数据 - 帧头为0xBB |
|||
*/ |
|||
private fun processDeviceResponse(data: ByteArray) { |
|||
if (data.size < 3) { |
|||
FileLogger.e(TAG, "设备响应数据格式错误:数据长度过短") |
|||
return |
|||
} |
|||
|
|||
val header = data[0] // 0xBB |
|||
val cmdType = data[1].toInt() and 0xFF |
|||
|
|||
// 判断命令失败响应(长度为0) |
|||
if (data.size == 4 && data[2].toInt() and 0xFF == 0) { |
|||
FileLogger.w(TAG, "命令 0x${cmdType.toString(16)} 执行失败") |
|||
callback?.onProtocolCommandResult(cmdType, null, false) |
|||
return |
|||
} |
|||
|
|||
// 正常响应处理 |
|||
val length = data[2].toInt() and 0xFF |
|||
|
|||
// 检查数据长度 |
|||
if (data.size != length + 4) { // 帧头 + 命令 + 长度 + 数据 + CRC |
|||
FileLogger.e(TAG, "设备响应数据长度不匹配: 期望=${length+4}, 实际=${data.size}") |
|||
return |
|||
} |
|||
|
|||
// 计算CRC校验 |
|||
val crcData = ByteArray(2 + length) // 命令 + 长度 + 数据 |
|||
System.arraycopy(data, 1, crcData, 0, crcData.size) |
|||
val calculatedCrc = crc8Maxim(crcData, crcData.size) |
|||
val receivedCrc = data[data.size - 1] |
|||
|
|||
if (calculatedCrc != receivedCrc) { |
|||
FileLogger.e(TAG, "设备响应CRC校验失败: 计算=${calculatedCrc.toInt() and 0xFF}, 接收=${receivedCrc.toInt() and 0xFF}") |
|||
return |
|||
} |
|||
|
|||
// 提取响应数据 |
|||
val responseData = if (length > 0) ByteArray(length) else ByteArray(0) |
|||
if (length > 0) { |
|||
System.arraycopy(data, 3, responseData, 0, length) |
|||
} |
|||
|
|||
FileLogger.d(TAG, "收到设备响应: CMD=0x${cmdType.toString(16)}, 数据=${responseData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}") |
|||
|
|||
// 根据命令类型处理不同的响应 |
|||
when (cmdType) { |
|||
BleConst.CMD_GET_VERSION -> { |
|||
if (responseData.isNotEmpty()) { |
|||
// 转换为字符串前,确认是否是有效的ASCII字符 |
|||
val version = responseData.joinToString(".") { (it.toInt() and 0xFF).toString() } |
|||
firmwareVersion = version |
|||
callback?.onDeviceInfoReceived(BleConst.CMD_GET_VERSION, version) |
|||
FileLogger.i(TAG, "接收到目标版本号: $version") |
|||
} |
|||
callback?.onProtocolCommandResult(cmdType, responseData, true) |
|||
} |
|||
BleConst.CMD_GET_PRODUCT_ID -> { |
|||
if (responseData.isNotEmpty() && responseData.size >= 2) { |
|||
val productId = "0x${(responseData[0].toInt() and 0xFF).toString(16)}${(responseData[1].toInt() and 0xFF).toString(16)}" |
|||
FileLogger.i(TAG, "接收到产品ID: $productId") |
|||
callback?.onDeviceInfoReceived(BleConst.CMD_GET_PRODUCT_ID, productId) |
|||
} |
|||
callback?.onProtocolCommandResult(cmdType, responseData, true) |
|||
} |
|||
BleConst.CMD_GET_COLOR_ID -> { |
|||
if (responseData.isNotEmpty()) { |
|||
val colorId = (responseData[0].toInt() and 0xFF).toString() |
|||
FileLogger.i(TAG, "接收到耳机颜色ID: $colorId") |
|||
callback?.onDeviceInfoReceived(BleConst.CMD_GET_COLOR_ID, colorId) |
|||
} |
|||
callback?.onProtocolCommandResult(cmdType, responseData, true) |
|||
} |
|||
BleConst.CMD_GET_BATTERY_INFO -> { |
|||
if (responseData.isNotEmpty() && responseData.size >= 3) { |
|||
val batteryLevel = responseData[0].toInt() and 0x7F |
|||
val isCharging = (responseData[0].toInt() and 0x80) != 0 |
|||
FileLogger.i(TAG, "接收到电量信息: ${batteryLevel}%, ${if(isCharging) "充电中" else "未充电"}") |
|||
|
|||
// 更新电池电量 |
|||
this.batteryLevel = batteryLevel |
|||
|
|||
callback?.onDeviceInfoReceived(BleConst.CMD_GET_BATTERY_INFO, "$batteryLevel|${if(isCharging) "1" else "0"}") |
|||
} |
|||
callback?.onProtocolCommandResult(cmdType, responseData, true) |
|||
} |
|||
BleConst.CMD_CONTROL_CODEC -> { |
|||
if (responseData.isNotEmpty() && responseData.size >= 2) { |
|||
val codecStatus = responseData[0].toInt() and 0xFF |
|||
val channelMode = responseData[1].toInt() and 0xFF |
|||
|
|||
// 解码状态描述 |
|||
val statusDesc = when (codecStatus) { |
|||
BleConst.CODEC_CONTROL_CLOSE -> "已关闭编解码" |
|||
BleConst.CODEC_CONTROL_DECODE_ON -> "已打开解码" |
|||
BleConst.CODEC_CONTROL_A2DP_PLAY -> "A2DP播放模式" |
|||
BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码" |
|||
else -> "未知状态($codecStatus)" |
|||
} |
|||
|
|||
// 声道模式描述 |
|||
val channelDesc = when (channelMode) { |
|||
BleConst.AUDIO_CHANNEL_LEFT -> "左声道" |
|||
BleConst.AUDIO_CHANNEL_RIGHT -> "右声道" |
|||
BleConst.AUDIO_CHANNEL_STEREO -> "立体声" |
|||
else -> "未知声道($channelMode)" |
|||
} |
|||
|
|||
FileLogger.i(TAG, "编解码控制响应: $statusDesc, $channelDesc") |
|||
} |
|||
callback?.onProtocolCommandResult(cmdType, responseData, true) |
|||
} |
|||
else -> { |
|||
FileLogger.d(TAG, "收到未知命令响应: 0x${cmdType.toString(16)}") |
|||
callback?.onProtocolCommandResult(cmdType, responseData, true) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理设备主动上报数据 - 帧头为0xCC |
|||
*/ |
|||
private fun processDeviceNotification(data: ByteArray) { |
|||
if (data.size < 3) { |
|||
FileLogger.e(TAG, "设备主动上报数据格式错误:数据长度过短") |
|||
return |
|||
} |
|||
|
|||
val header = data[0] // 0xCC |
|||
val infoType = data[1].toInt() and 0xFF |
|||
val length = data[2].toInt() and 0xFF |
|||
|
|||
// 检查数据长度 |
|||
if (data.size != length + 4) { // 帧头 + 类型 + 长度 + 数据 + CRC |
|||
FileLogger.e(TAG, "设备主动上报数据长度不匹配: 期望=${length+4}, 实际=${data.size}") |
|||
return |
|||
} |
|||
|
|||
// 计算CRC校验 |
|||
val crcData = ByteArray(2 + length) // 类型 + 长度 + 数据 |
|||
System.arraycopy(data, 1, crcData, 0, crcData.size) |
|||
val calculatedCrc = crc8Maxim(crcData, crcData.size) |
|||
val receivedCrc = data[data.size - 1] |
|||
|
|||
if (calculatedCrc != receivedCrc) { |
|||
FileLogger.e(TAG, "设备主动上报CRC校验失败: 计算=${calculatedCrc.toInt() and 0xFF}, 接收=${receivedCrc.toInt() and 0xFF}") |
|||
return |
|||
} |
|||
|
|||
// 提取上报数据 |
|||
val notifyData = if (length > 0) ByteArray(length) else ByteArray(0) |
|||
if (length > 0) { |
|||
System.arraycopy(data, 3, notifyData, 0, length) |
|||
} |
|||
|
|||
FileLogger.d(TAG, "收到设备主动上报: 类型=0x${infoType.toString(16)}, 数据=${notifyData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}") |
|||
|
|||
// 根据上报类型处理 |
|||
when (infoType) { |
|||
BleConst.CMD_GET_BATTERY_INFO -> { // 电量信息 |
|||
if (notifyData.isNotEmpty() && notifyData.size >= 3) { |
|||
val batteryLevel = notifyData[0].toInt() and 0x7F |
|||
val isCharging = (notifyData[0].toInt() and 0x80) != 0 |
|||
FileLogger.i(TAG, "接收到电量信息: ${batteryLevel}%, ${if(isCharging) "充电中" else "未充电"}") |
|||
|
|||
// 更新电池电量 |
|||
this.batteryLevel = batteryLevel |
|||
|
|||
callback?.onDeviceInfoReceived(BleConst.CMD_GET_BATTERY_INFO, "$batteryLevel|${if(isCharging) "1" else "0"}") |
|||
} |
|||
} |
|||
// 可以在这里添加其他主动上报类型的处理 |
|||
else -> { |
|||
FileLogger.d(TAG, "收到未知主动上报类型: 0x${infoType.toString(16)}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 公开的命令接口 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 获取耳机版本号 |
|||
*/ |
|||
fun getDeviceVersion(): Boolean { |
|||
FileLogger.i(TAG, "获取耳机版本号...") |
|||
return sendCommand(BleConst.CMD_GET_VERSION.toByte(), byteArrayOf(0xFF.toByte())) |
|||
} |
|||
|
|||
/** |
|||
* 获取产品ID |
|||
*/ |
|||
fun getProductId(): Boolean { |
|||
FileLogger.i(TAG, "获取耳机产品ID...") |
|||
return sendCommand(BleConst.CMD_GET_PRODUCT_ID.toByte(), byteArrayOf(0xFF.toByte())) |
|||
} |
|||
|
|||
/** |
|||
* 获取耳机颜色ID |
|||
*/ |
|||
fun getColorId(): Boolean { |
|||
FileLogger.i(TAG, "获取耳机颜色ID...") |
|||
return sendCommand(BleConst.CMD_GET_COLOR_ID.toByte(), byteArrayOf(0xFF.toByte())) |
|||
} |
|||
|
|||
/** |
|||
* 获取耳机电量信息 |
|||
*/ |
|||
fun getBatteryInfo(): Boolean { |
|||
FileLogger.i(TAG, "获取耳机电量信息...") |
|||
return sendCommand(BleConst.CMD_GET_BATTERY_INFO.toByte(), byteArrayOf(0xFF.toByte())) |
|||
} |
|||
|
|||
/** |
|||
* 控制编解码 - 关闭编解码 |
|||
*/ |
|||
fun closeCodec(): Boolean { |
|||
FileLogger.i(TAG, "关闭编解码...") |
|||
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_CLOSE.toByte())) |
|||
} |
|||
|
|||
/** |
|||
* 控制编解码 - 打开解码 |
|||
*/ |
|||
fun openDecoder(): Boolean { |
|||
FileLogger.i(TAG, "打开解码...") |
|||
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_DECODE_ON.toByte())) |
|||
} |
|||
|
|||
/** |
|||
* 控制编解码 - A2DP播放 |
|||
*/ |
|||
fun playA2DP(): Boolean { |
|||
FileLogger.i(TAG, "切换至A2DP播放模式...") |
|||
return sendCommand(BleConst.CMD_PLAY_A2DP.toByte()) |
|||
} |
|||
|
|||
/** |
|||
* 控制编解码 - 打开编码 |
|||
*/ |
|||
fun openEncoder(): Boolean { |
|||
FileLogger.i(TAG, "打开编码...") |
|||
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_ENCODE_ON.toByte())) |
|||
} |
|||
|
|||
/** |
|||
* 设置声道模式 |
|||
* @param channelMode 声道模式:AUDIO_CHANNEL_LEFT/AUDIO_CHANNEL_RIGHT/AUDIO_CHANNEL_STEREO |
|||
*/ |
|||
fun setChannelMode(channelMode: Int): Boolean { |
|||
when (channelMode) { |
|||
BleConst.AUDIO_CHANNEL_LEFT -> FileLogger.i(TAG, "设置左声道模式...") |
|||
BleConst.AUDIO_CHANNEL_RIGHT -> FileLogger.i(TAG, "设置右声道模式...") |
|||
BleConst.AUDIO_CHANNEL_STEREO -> FileLogger.i(TAG, "设置立体声模式...") |
|||
else -> { |
|||
FileLogger.e(TAG, "无效的声道模式: $channelMode") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// 高级控制:设置命令类型、长度和两个参数 |
|||
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( |
|||
0xA1.toByte(), // 状态码 - 使用解码状态 |
|||
channelMode.toByte() // 声道模式 |
|||
)) |
|||
} |
|||
|
|||
/** |
|||
* 音量增大 |
|||
*/ |
|||
fun volumeUp(): Boolean { |
|||
FileLogger.i(TAG, "设备音量增加...") |
|||
return sendCommand(BleConst.CMD_VOLUME_UP.toByte()) |
|||
} |
|||
|
|||
/** |
|||
* 音量减小 |
|||
*/ |
|||
fun volumeDown(): Boolean { |
|||
FileLogger.i(TAG, "设备音量减小...") |
|||
return sendCommand(BleConst.CMD_VOLUME_DOWN.toByte()) |
|||
} |
|||
|
|||
/** |
|||
* 发送音频数据 |
|||
* @param data 音频数据 |
|||
*/ |
|||
fun sendAudioData(data: ByteArray): Boolean { |
|||
if (!checkConn()) { |
|||
FileLogger.e(TAG, "发送音频数据失败: 设备未连接") |
|||
return false |
|||
} |
|||
|
|||
if (writeChar == null) { |
|||
FileLogger.e(TAG, "发送音频数据失败: 写特征为空") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
writeChar?.value = data |
|||
return bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "发送音频数据异常: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 工具方法 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 检查是否已连接 |
|||
*/ |
|||
fun isConnected(): Boolean { |
|||
return connectionState.value == BleConst.STATE_CONNECTED |
|||
} |
|||
|
|||
/** |
|||
* 连接状态检查 |
|||
*/ |
|||
private fun checkConn(): Boolean = |
|||
bluetoothGatt != null && writeChar != null && |
|||
connectionState.value == BleConst.STATE_CONNECTED |
|||
|
|||
/** |
|||
* 获取已连接设备地址 |
|||
*/ |
|||
fun getConnectedDeviceAddress(): String? { |
|||
return bluetoothGatt?.device?.address |
|||
} |
|||
|
|||
/** |
|||
* 获取蓝牙适配器 |
|||
*/ |
|||
fun getBluetoothAdapter(): BluetoothAdapter? { |
|||
return bluetoothAdapter |
|||
} |
|||
|
|||
// ====================================================================================================== |
|||
// 资源清理 |
|||
// ====================================================================================================== |
|||
/** |
|||
* 清理所有资源 |
|||
*/ |
|||
fun cleanup() { |
|||
stopScan() |
|||
scanHandler.removeCallbacksAndMessages(null) |
|||
disconnectGatt() |
|||
fos?.close() |
|||
fos = null |
|||
outputFile = null |
|||
} |
|||
} |
|||
@ -0,0 +1,425 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.content.Context |
|||
import android.os.Build |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.util.Log |
|||
import androidx.annotation.NonNull |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityAware |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import io.flutter.plugin.common.EventChannel |
|||
import io.flutter.plugin.common.PluginRegistry |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
|
|||
/** |
|||
* BleServicePlugin |
|||
* 作为Flutter与原生BLE通信的插件接口 |
|||
*/ |
|||
class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, PluginRegistry.RequestPermissionsResultListener, BleService.Callback { |
|||
private val TAG = "BleServicePlugin" |
|||
|
|||
// 添加主线程Handler |
|||
private val mainHandler = Handler(Looper.getMainLooper()) |
|||
|
|||
// 方法通道 |
|||
private lateinit var methodChannel: MethodChannel |
|||
|
|||
// 事件通道 |
|||
private var scanEventChannel: EventChannel? = null |
|||
private var connectionEventChannel: EventChannel? = null |
|||
private var dataEventChannel: EventChannel? = null |
|||
private var statusEventChannel: EventChannel? = null |
|||
|
|||
// 应用上下文 |
|||
private lateinit var context: Context |
|||
|
|||
// BleManager包装器 |
|||
private val bleManager = BleManagerWrapper.instance |
|||
|
|||
// 事件接收器 |
|||
private var scanEventSink: EventChannel.EventSink? = null |
|||
private var connectionEventSink: EventChannel.EventSink? = null |
|||
private var dataEventSink: EventChannel.EventSink? = null |
|||
private var statusEventSink: EventChannel.EventSink? = null |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
FileLogger.init(flutterPluginBinding.applicationContext) |
|||
FileLogger.d(TAG, "BleServicePlugin onAttachedToEngine") |
|||
|
|||
// 设置方法通道 |
|||
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.ble_service") |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
// 设置事件通道 |
|||
setupEventChannels(flutterPluginBinding) |
|||
|
|||
context = flutterPluginBinding.applicationContext |
|||
|
|||
// 初始化BleManagerWrapper |
|||
try { |
|||
bleManager.init(context) |
|||
bleManager.addCallback(this) |
|||
|
|||
// 开始定期状态更新 |
|||
startPeriodicStatusUpdates() |
|||
|
|||
FileLogger.d(TAG, "BleManagerWrapper initialized successfully") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "Failed to initialize BleManagerWrapper: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
// 设置事件通道 |
|||
private fun setupEventChannels(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
// 扫描结果事件通道 |
|||
scanEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/scan_events").apply { |
|||
setStreamHandler(object : EventChannel.StreamHandler { |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
scanEventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
scanEventSink = null |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 连接状态事件通道 |
|||
connectionEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/connection_events").apply { |
|||
setStreamHandler(object : EventChannel.StreamHandler { |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
connectionEventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
connectionEventSink = null |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 数据接收事件通道 |
|||
dataEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/data_events").apply { |
|||
setStreamHandler(object : EventChannel.StreamHandler { |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
dataEventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
dataEventSink = null |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 状态更新事件通道 |
|||
statusEventChannel = EventChannel(binding.binaryMessenger, "com.yunqiinnovation.ble_service/status_events").apply { |
|||
setStreamHandler(object : EventChannel.StreamHandler { |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
statusEventSink = events |
|||
// 立即发送一次状态更新 |
|||
sendStatusUpdate() |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
statusEventSink = null |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// 开始定期状态更新 |
|||
private fun startPeriodicStatusUpdates() { |
|||
// 添加节流标志 |
|||
var lastUpdateTime = 0L |
|||
|
|||
Thread { |
|||
try { |
|||
while (!Thread.interrupted()) { |
|||
val currentTime = System.currentTimeMillis() |
|||
// 确保至少间隔5秒才发送一次状态更新 |
|||
if (currentTime - lastUpdateTime >= 5000) { |
|||
sendStatusUpdate() |
|||
lastUpdateTime = currentTime |
|||
} |
|||
Thread.sleep(3000) // 保持同样的休眠时间 |
|||
} |
|||
} catch (e: InterruptedException) { |
|||
FileLogger.d(TAG, "状态更新线程已停止") |
|||
} |
|||
}.apply { |
|||
isDaemon = true |
|||
start() |
|||
} |
|||
} |
|||
|
|||
// 添加sendEvent函数 |
|||
private fun <T> sendEvent(eventSink: EventChannel.EventSink?, data: T, errorPrefix: String) { |
|||
if (eventSink == null) return |
|||
|
|||
// 确保在主线程执行 |
|||
if (Looper.myLooper() == Looper.getMainLooper()) { |
|||
try { |
|||
eventSink.success(data) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "$errorPrefix: ${e.message}", e) |
|||
} |
|||
} else { |
|||
mainHandler.post { |
|||
try { |
|||
eventSink.success(data) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "$errorPrefix: ${e.message}", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 修改sendStatusUpdate方法,使用新的sendEvent函数 |
|||
private fun sendStatusUpdate() { |
|||
try { |
|||
val connectionState = bleManager.getConnectionState() |
|||
|
|||
val stateStr = when(connectionState) { |
|||
BleConst.STATE_DISCONNECTED -> "已断开" |
|||
BleConst.STATE_CONNECTING -> "连接中" |
|||
BleConst.STATE_CONNECTED -> "已连接" |
|||
BleConst.STATE_DISCONNECTING -> "断开中" |
|||
BleConst.STATE_ERROR -> "连接错误" |
|||
else -> "未知状态($connectionState)" |
|||
} |
|||
|
|||
val stateMap = mapOf( |
|||
"type" to "statusUpdate", |
|||
"connectionState" to connectionState, |
|||
"connectionStateStr" to stateStr, |
|||
"batteryLevel" to bleManager.getBatteryLevel(), |
|||
"firmwareVersion" to (bleManager.getFirmwareVersion() ?: "未知"), |
|||
"connectedDevice" to (bleManager.getConnectedDeviceAddress() ?: "") |
|||
) |
|||
|
|||
// 使用sendEvent函数发送事件 |
|||
sendEvent(statusEventSink, stateMap, "发送状态更新失败") |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "准备状态更新数据失败: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
|
|||
// 移除回调 |
|||
bleManager.removeCallback(this) |
|||
|
|||
// 解除事件通道 |
|||
scanEventChannel?.setStreamHandler(null) |
|||
connectionEventChannel?.setStreamHandler(null) |
|||
dataEventChannel?.setStreamHandler(null) |
|||
statusEventChannel?.setStreamHandler(null) |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
FileLogger.d(TAG, "Method call: ${call.method}") |
|||
|
|||
when (call.method) { |
|||
"initialize" -> { |
|||
try { |
|||
bleManager.init(context) |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
result.error("INIT_ERROR", "初始化BLE服务失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"registerCallback" -> { |
|||
try { |
|||
bleManager.addCallback(this) |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
result.error("CALLBACK_ERROR", "设置回调失败: ${e.message}", null) |
|||
} |
|||
} |
|||
"getPlatformVersion" -> { |
|||
result.success("Android ${Build.VERSION.RELEASE}") |
|||
} |
|||
"startScan" -> { |
|||
val timeout = call.argument<Number>("timeout")?.toLong() ?: 10000L |
|||
val success = bleManager.startScan(timeout) |
|||
result.success(success) |
|||
} |
|||
"stopScan" -> { |
|||
val success = bleManager.stopScan() |
|||
result.success(success) |
|||
} |
|||
"connect" -> { |
|||
val address = call.argument<String>("address") |
|||
if (address == null) { |
|||
result.error("INVALID_ARGUMENT", "Address cannot be null", null) |
|||
return |
|||
} |
|||
bleManager.connect(address) |
|||
result.success(true) |
|||
} |
|||
"disconnect" -> { |
|||
bleManager.disconnect() |
|||
result.success(true) |
|||
} |
|||
"isConnected" -> { |
|||
val connected = bleManager.isConnected() |
|||
result.success(connected) |
|||
} |
|||
"getConnectionState" -> { |
|||
val state = bleManager.getConnectionState() |
|||
result.success(state) |
|||
} |
|||
"getConnectedDeviceAddress" -> { |
|||
val address = bleManager.getConnectedDeviceAddress() |
|||
result.success(address) |
|||
} |
|||
"getBatteryLevel" -> { |
|||
val level = bleManager.getBatteryLevel() |
|||
result.success(level) |
|||
} |
|||
"getFirmwareVersion" -> { |
|||
val version = bleManager.getFirmwareVersion() |
|||
result.success(version) |
|||
} |
|||
"getDeviceVersion" -> { |
|||
val success = bleManager.getDeviceVersion() |
|||
result.success(success) |
|||
} |
|||
"getBatteryInfo" -> { |
|||
val success = bleManager.getBatteryInfo() |
|||
result.success(success) |
|||
} |
|||
"openEncoder" -> { |
|||
val success = bleManager.openEncoder() |
|||
result.success(success) |
|||
} |
|||
"openDecoder" -> { |
|||
val success = bleManager.openDecoder() |
|||
result.success(success) |
|||
} |
|||
"closeCodec" -> { |
|||
val success = bleManager.closeCodec() |
|||
result.success(success) |
|||
} |
|||
"playA2DP" -> { |
|||
val success = bleManager.playA2DP() |
|||
result.success(success) |
|||
} |
|||
"volumeUp" -> { |
|||
val success = bleManager.volumeUp() |
|||
result.success(success) |
|||
} |
|||
"volumeDown" -> { |
|||
val success = bleManager.volumeDown() |
|||
result.success(success) |
|||
} |
|||
"sendAudioData" -> { |
|||
val data = call.argument<ByteArray>("data") |
|||
if (data == null) { |
|||
result.error("INVALID_ARGUMENT", "Audio data cannot be null", null) |
|||
return |
|||
} |
|||
val success = bleManager.sendAudioData(data) |
|||
result.success(success) |
|||
} |
|||
"cleanup" -> { |
|||
bleManager.cleanup() |
|||
result.success(true) |
|||
} |
|||
"getScannedDevices" -> { |
|||
val devices = bleManager.getScannedDevices() |
|||
result.success(devices) |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
binding.addRequestPermissionsResultListener(this) |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
// 无需实现 |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
binding.addRequestPermissionsResultListener(this) |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
// 无需实现 |
|||
} |
|||
|
|||
override fun onRequestPermissionsResult( |
|||
requestCode: Int, |
|||
permissions: Array<out String>, |
|||
grantResults: IntArray |
|||
): Boolean { |
|||
// 简化权限处理 |
|||
return false |
|||
} |
|||
|
|||
// BleService.Callback 接口实现 |
|||
|
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
sendEvent(scanEventSink, mapOf("devices" to devices), "发送扫描结果异常") |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
sendEvent(connectionEventSink, state, "发送连接状态异常") |
|||
sendStatusUpdate() |
|||
} |
|||
|
|||
override fun onDataReceived(data: ByteArray) { |
|||
sendEvent(dataEventSink, mapOf("type" to "rawData", "data" to data), "发送原始数据异常") |
|||
} |
|||
|
|||
override fun onAudioDataReceived(data: ByteArray) { |
|||
sendEvent(dataEventSink, mapOf("type" to "audioData", "data" to data), "发送音频数据异常") |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
sendEvent(dataEventSink, mapOf("type" to "wakeup"), "发送唤醒信号异常") |
|||
} |
|||
|
|||
override fun onCommandResult(commandType: Int, success: Boolean) { |
|||
sendEvent(connectionEventSink, mapOf( |
|||
"type" to "commandResult", |
|||
"commandType" to commandType, |
|||
"success" to success |
|||
), "发送命令结果异常") |
|||
} |
|||
|
|||
override fun onProtocolCommandResult(commandType: Int, data: ByteArray?, success: Boolean) { |
|||
sendEvent(connectionEventSink, mapOf( |
|||
"type" to "protocolCommandResult", |
|||
"commandType" to commandType, |
|||
"data" to (data?.let { String(it) } ?: ""), |
|||
"success" to success |
|||
), "发送协议命令结果异常") |
|||
} |
|||
|
|||
override fun onDeviceInfoReceived(infoType: Int, infoData: String) { |
|||
sendEvent(statusEventSink, mapOf( |
|||
"type" to "deviceInfo", |
|||
"infoType" to infoType, |
|||
"infoData" to infoData |
|||
), "发送设备信息异常") |
|||
sendStatusUpdate() |
|||
} |
|||
|
|||
override fun onStatusUpdate(status: Map<String, Any>) { |
|||
sendEvent(statusEventSink, mapOf( |
|||
"type" to "statusUpdate", |
|||
"status" to status |
|||
), "发送状态更新异常") |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
|
|||
import android.content.* |
|||
import android.bluetooth.le.* |
|||
import android.os.Build |
|||
import com.yunqiinnovation.deepvoice_utils.FileLogger |
|||
|
|||
/** |
|||
* BLE唤醒接收器 |
|||
* 当耳机发送含特定UUID广播时,系统会触发此接收器 |
|||
* 接收器会解析MAC地址并启动前台服务进行连接 |
|||
*/ |
|||
class BleWakeReceiver : BroadcastReceiver() { |
|||
|
|||
companion object { |
|||
private const val TAG = "BleWakeReceiver" |
|||
} |
|||
|
|||
override fun onReceive(ctx: Context, intent: Intent) { |
|||
FileLogger.init(ctx) |
|||
if (intent.action != BleConst.ACTION_SCAN_RESULT) return |
|||
|
|||
val list = intent.getParcelableArrayListExtra<ScanResult>( |
|||
BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT |
|||
) ?: return |
|||
val mac = list.first().device.address |
|||
FileLogger.d(TAG, "被系统唤醒,发现设备 $mac") |
|||
|
|||
// 拉起前台服务并把MAC传过去 |
|||
val svc = Intent(ctx, BleForegroundService::class.java).putExtra("mac", mac) |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) |
|||
ctx.startForegroundService(svc) else ctx.startService(svc) |
|||
} |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,356 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// BLE连接状态常量 |
|||
class BleConnectionState { |
|||
static const int disconnected = 0; |
|||
static const int connecting = 1; |
|||
static const int connected = 2; |
|||
static const int disconnecting = 3; |
|||
static const int error = 4; |
|||
} |
|||
|
|||
/// BLE设备数据模型 |
|||
class BleDevice { |
|||
final String id; |
|||
final String name; |
|||
final int rssi; |
|||
final List<String> uuids; |
|||
|
|||
BleDevice({ |
|||
required this.id, |
|||
required this.name, |
|||
required this.rssi, |
|||
required this.uuids, |
|||
}); |
|||
|
|||
factory BleDevice.fromMap(Map<dynamic, dynamic> map) { |
|||
final uuidsList = (map['uuids'] as List?)?.cast<String>() ?? []; |
|||
|
|||
return BleDevice( |
|||
id: map['id'] as String, |
|||
name: map['name'] as String? ?? 'Unknown', |
|||
rssi: map['rssi'] as int, |
|||
uuids: uuidsList, |
|||
); |
|||
} |
|||
|
|||
@override |
|||
String toString() => 'BleDevice(id: $id, name: $name, rssi: $rssi, uuids: $uuids)'; |
|||
} |
|||
|
|||
/// BLE服务插件 |
|||
class BleService { |
|||
static final BleService _instance = BleService._internal(); |
|||
|
|||
/// 获取单例 |
|||
factory BleService() => _instance; |
|||
|
|||
// 方法通道 |
|||
final MethodChannel _methodChannel = const MethodChannel('com.yunqiinnovation.ble_service'); |
|||
|
|||
// 事件通道 |
|||
late final EventChannel _scanEventChannel; |
|||
late final EventChannel _connectionEventChannel; |
|||
late final EventChannel _dataEventChannel; |
|||
late final EventChannel _statusEventChannel; |
|||
|
|||
// 事件流 |
|||
late final Stream<List<BleDevice>> _scanStream; |
|||
late final Stream<dynamic> _connectionStream; |
|||
late final Stream<dynamic> _dataStream; |
|||
late final Stream<dynamic> _statusStream; |
|||
|
|||
// 获取事件流 |
|||
Stream<List<BleDevice>> get onScanResults => _scanStream; |
|||
Stream<dynamic> get onConnectionChanged => _connectionStream; |
|||
Stream<dynamic> get onDataReceived => _dataStream; |
|||
Stream<dynamic> get onStatusUpdated => _statusStream; |
|||
|
|||
// 内部构造函数 |
|||
BleService._internal() { |
|||
_scanEventChannel = const EventChannel('com.yunqiinnovation.ble_service/scan_events'); |
|||
_connectionEventChannel = const EventChannel('com.yunqiinnovation.ble_service/connection_events'); |
|||
_dataEventChannel = const EventChannel('com.yunqiinnovation.ble_service/data_events'); |
|||
_statusEventChannel = const EventChannel('com.yunqiinnovation.ble_service/status_events'); |
|||
|
|||
// 初始化事件流 |
|||
_scanStream = _scanEventChannel.receiveBroadcastStream().map<List<BleDevice>>((event) { |
|||
final List<dynamic> devices = (event['devices'] as List?) ?? []; |
|||
return devices.map((device) => BleDevice.fromMap(device)).toList(); |
|||
}); |
|||
|
|||
_connectionStream = _connectionEventChannel.receiveBroadcastStream(); |
|||
_dataStream = _dataEventChannel.receiveBroadcastStream(); |
|||
_statusStream = _statusEventChannel.receiveBroadcastStream(); |
|||
} |
|||
|
|||
/// 初始化BLE服务 |
|||
Future<bool> initialize() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('initialize'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('初始化BLE服务失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 注册BLE回调 |
|||
Future<bool> registerCallback() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('registerCallback'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('注册BLE回调失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置BLE方法回调处理器 |
|||
void setMethodCallHandler(Future<dynamic> Function(MethodCall call) handler) { |
|||
_methodChannel.setMethodCallHandler(handler); |
|||
} |
|||
|
|||
/// 开始扫描设备 |
|||
Future<bool> startScan({int timeout = 10000}) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'startScan', |
|||
{'timeout': timeout}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('开始扫描设备失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 停止扫描设备 |
|||
Future<bool> stopScan() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('stopScan'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('停止扫描设备失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 连接到设备 |
|||
Future<bool> connect(String address) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'connect', |
|||
{'address': address}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('连接设备失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 断开设备连接 |
|||
Future<bool> disconnect() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('disconnect'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('断开设备连接失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 检查是否已连接 |
|||
Future<bool> isConnected() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('isConnected'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('检查连接状态失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 获取连接状态 |
|||
Future<int> getConnectionState() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<int>('getConnectionState'); |
|||
return result ?? BleConnectionState.disconnected; |
|||
} catch (e) { |
|||
print('获取连接状态失败: $e'); |
|||
return BleConnectionState.disconnected; |
|||
} |
|||
} |
|||
|
|||
/// 获取已连接设备地址 |
|||
Future<String?> getConnectedDeviceAddress() async { |
|||
try { |
|||
return await _methodChannel.invokeMethod<String>('getConnectedDeviceAddress'); |
|||
} catch (e) { |
|||
print('获取已连接设备地址失败: $e'); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/// 获取电池电量 |
|||
Future<int> getBatteryLevel() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<int>('getBatteryLevel'); |
|||
return result ?? 0; |
|||
} catch (e) { |
|||
print('获取电池电量失败: $e'); |
|||
return 0; |
|||
} |
|||
} |
|||
|
|||
/// 获取固件版本 |
|||
Future<String?> getFirmwareVersion() async { |
|||
try { |
|||
return await _methodChannel.invokeMethod<String>('getFirmwareVersion'); |
|||
} catch (e) { |
|||
print('获取固件版本失败: $e'); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/// 获取设备版本信息 |
|||
Future<bool> getDeviceVersion() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('getDeviceVersion'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('获取设备版本失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 获取电池信息 |
|||
Future<bool> getBatteryInfo() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('getBatteryInfo'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('获取电池信息失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 打开编码器 |
|||
Future<bool> openEncoder() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('openEncoder'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('打开编码器失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 打开解码器 |
|||
Future<bool> openDecoder() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('openDecoder'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('打开解码器失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 关闭编解码器 |
|||
Future<bool> closeCodec() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('closeCodec'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('关闭编解码器失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 播放A2DP |
|||
Future<bool> playA2DP() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('playA2DP'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('播放A2DP失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<bool> volumeUp() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('volumeUp'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('增加音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<bool> volumeDown() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('volumeDown'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('减小音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 发送音频数据 |
|||
Future<bool> sendAudioData(Uint8List data) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'sendAudioData', |
|||
{'data': data}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('发送音频数据失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
Future<bool> cleanup() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('cleanup'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('清理资源失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 获取已扫描到的设备列表 |
|||
Future<List<BleDevice>> getScannedDevices() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<List<dynamic>>('getScannedDevices'); |
|||
if (result != null) { |
|||
return result.map((item) => BleDevice.fromMap(Map<String, dynamic>.from(item as Map))).toList(); |
|||
} |
|||
return []; |
|||
} catch (e) { |
|||
print('获取扫描设备列表失败: $e'); |
|||
return []; |
|||
} |
|||
} |
|||
|
|||
/// 获取平台版本 |
|||
Future<String?> getPlatformVersion() async { |
|||
try { |
|||
return await _methodChannel.invokeMethod<String>('getPlatformVersion'); |
|||
} catch (e) { |
|||
print('获取平台版本失败: $e'); |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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) |
|||
@ -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") |
|||
} |
|||
@ -0,0 +1 @@ |
|||
rootProject.name = "bytedance_speech" |
|||
@ -0,0 +1,8 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.deep_voice.bytedance_speech"> |
|||
|
|||
<uses-permission android:name="android.permission.INTERNET" /> |
|||
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
|||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |
|||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |
|||
</manifest> |
|||
@ -0,0 +1,750 @@ |
|||
package com.deep_voice.bytedance_speech |
|||
|
|||
import android.content.Context |
|||
import com.google.gson.Gson |
|||
import com.google.gson.JsonObject |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.withContext |
|||
import okhttp3.OkHttpClient |
|||
import okhttp3.Request |
|||
import okhttp3.Response |
|||
import okhttp3.WebSocket |
|||
import okhttp3.WebSocketListener |
|||
import okhttp3.logging.HttpLoggingInterceptor |
|||
import okhttp3.logging.HttpLoggingInterceptor.Level |
|||
import okio.ByteString |
|||
import java.io.ByteArrayOutputStream |
|||
import java.util.UUID |
|||
import java.util.concurrent.TimeUnit |
|||
import java.util.zip.GZIPInputStream |
|||
import java.util.zip.GZIPOutputStream |
|||
|
|||
/** |
|||
* 火山引擎大模型语音识别实现 |
|||
* |
|||
* 基于WebSocket协议实现实时语音识别 |
|||
*/ |
|||
class BytedanceASR( |
|||
private val context: Context, |
|||
private val resultCallback: (String, Boolean) -> Unit, |
|||
private val errorCallback: (String, String) -> Unit, |
|||
private val statusCallback: (String) -> Unit |
|||
) { |
|||
companion object { |
|||
// 协议常量 |
|||
private const val PROTOCOL_VERSION = 0x01 |
|||
private const val DEFAULT_HEADER_SIZE = 0x01 |
|||
|
|||
// 消息类型 |
|||
private const val FULL_CLIENT_REQUEST = 0x01 |
|||
private const val AUDIO_ONLY_REQUEST = 0x02 |
|||
private const val FULL_SERVER_RESPONSE = 0x09 |
|||
private const val SERVER_ACK = 0x0B |
|||
private const val SERVER_ERROR_RESPONSE = 0x0F |
|||
|
|||
// 消息类型特定标志 |
|||
private const val NO_SEQUENCE = 0x00 |
|||
private const val POS_SEQUENCE = 0x01 |
|||
private const val NEG_SEQUENCE = 0x02 |
|||
private const val NEG_WITH_SEQUENCE = 0x03 |
|||
|
|||
// 序列化方法 |
|||
private const val NO_SERIALIZATION = 0x00 |
|||
private const val JSON = 0x01 |
|||
|
|||
// 压缩方法 |
|||
private const val NO_COMPRESSION = 0x00 |
|||
private const val GZIP = 0x01 |
|||
|
|||
// 状态常量 |
|||
private const val STATUS_INITIALIZING = "initializing" |
|||
private const val STATUS_READY = "ready" |
|||
private const val STATUS_RECOGNIZING = "recognizing" |
|||
private const val STATUS_STOPPED = "stopped" |
|||
private const val STATUS_ERROR = "error" |
|||
} |
|||
|
|||
// WebSocket连接 |
|||
private var webSocket: WebSocket? = null |
|||
private val client: OkHttpClient |
|||
private var seq = 1 |
|||
|
|||
// 配置参数 |
|||
private var appId: String = "" |
|||
private var accessToken: String = "" |
|||
private var streamMode: Boolean = true |
|||
private var enablePunctuation: Boolean = true |
|||
private var sampleRate: Int = 16000 |
|||
private var bits: Int = 16 |
|||
private var channels: Int = 1 |
|||
private var supportedLanguages: List<String> = listOf("zh-CN") |
|||
|
|||
// 状态控制 |
|||
private var isRunning = false |
|||
private var isLastPacket = false |
|||
private var isContinuousMode = false |
|||
private var isInitialized = false |
|||
private var currentStatus = STATUS_STOPPED |
|||
|
|||
// 单次识别结果收集 |
|||
private var onceResultBuilder = StringBuilder() |
|||
private var singleRecognitionCallback: ((String) -> Unit)? = null |
|||
|
|||
init { |
|||
// 配置OkHttp客户端 |
|||
val loggingInterceptor = HttpLoggingInterceptor().apply { |
|||
level = Level.HEADERS |
|||
} |
|||
|
|||
client = OkHttpClient.Builder() |
|||
.pingInterval(50, TimeUnit.SECONDS) |
|||
.addInterceptor(loggingInterceptor) |
|||
.readTimeout(100, TimeUnit.SECONDS) |
|||
.writeTimeout(100, TimeUnit.SECONDS) |
|||
.build() |
|||
} |
|||
|
|||
/** |
|||
* 初始化语音识别服务 |
|||
*/ |
|||
suspend fun initialize( |
|||
subscriptionKey: String, |
|||
region: String, |
|||
supportedLanguages: List<String>? = null |
|||
): Boolean = withContext(Dispatchers.IO) { |
|||
try { |
|||
updateStatus(STATUS_INITIALIZING) |
|||
|
|||
// 设置必要参数 |
|||
this@BytedanceASR.appId = region // 火山引擎中region对应应用ID |
|||
this@BytedanceASR.accessToken = subscriptionKey // 订阅密钥作为访问令牌 |
|||
|
|||
if (!supportedLanguages.isNullOrEmpty()) { |
|||
this@BytedanceASR.supportedLanguages = supportedLanguages |
|||
} |
|||
|
|||
// 释放旧资源 |
|||
release() |
|||
|
|||
isInitialized = true |
|||
updateStatus(STATUS_READY) |
|||
true |
|||
} catch (e: Exception) { |
|||
errorCallback("INIT_ERROR", e.message ?: "初始化失败") |
|||
updateStatus(STATUS_ERROR) |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 单次识别(非流式),完成后自动断开连接 |
|||
*/ |
|||
suspend fun recognizeOnce(audioData: ByteArray, callback: (String) -> Unit): Boolean = withContext(Dispatchers.IO) { |
|||
try { |
|||
if (!isInitialized) { |
|||
errorCallback("NOT_INITIALIZED", "请先调用initialize方法") |
|||
return@withContext false |
|||
} |
|||
|
|||
if (isRunning) { |
|||
errorCallback("BUSY", "语音识别正在进行中") |
|||
return@withContext false |
|||
} |
|||
|
|||
// 重置状态 |
|||
isContinuousMode = false |
|||
isLastPacket = false |
|||
onceResultBuilder.clear() |
|||
singleRecognitionCallback = callback |
|||
streamMode = false // 使用非流式模式 |
|||
|
|||
// 开始连接 |
|||
start() |
|||
|
|||
// 等待连接建立 |
|||
var attempts = 0 |
|||
while (!isRunning && attempts < 10) { |
|||
delay(300) |
|||
attempts++ |
|||
} |
|||
|
|||
if (!isRunning) { |
|||
errorCallback("CONNECTION_ERROR", "无法建立连接") |
|||
return@withContext false |
|||
} |
|||
|
|||
// 发送音频数据 |
|||
feedAudioData(audioData) |
|||
|
|||
// 发送结束标志 |
|||
stop() |
|||
|
|||
true |
|||
} catch (e: Exception) { |
|||
errorCallback("RECOGNIZE_ERROR", e.message ?: "识别失败") |
|||
updateStatus(STATUS_ERROR) |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 启动连续识别 |
|||
*/ |
|||
suspend fun startContinuousRecognition(): Boolean = withContext(Dispatchers.IO) { |
|||
try { |
|||
if (!isInitialized) { |
|||
errorCallback("NOT_INITIALIZED", "请先调用initialize方法") |
|||
return@withContext false |
|||
} |
|||
|
|||
if (isRunning) { |
|||
errorCallback("BUSY", "语音识别正在进行中") |
|||
return@withContext false |
|||
} |
|||
|
|||
// 设置为连续模式 |
|||
isContinuousMode = true |
|||
isLastPacket = false |
|||
streamMode = true // 使用流式模式 |
|||
|
|||
// 开始连接 |
|||
start() |
|||
|
|||
true |
|||
} catch (e: Exception) { |
|||
errorCallback("START_ERROR", e.message ?: "启动连续识别失败") |
|||
updateStatus(STATUS_ERROR) |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止连续识别 |
|||
*/ |
|||
suspend fun stopContinuousRecognition(): Boolean = withContext(Dispatchers.IO) { |
|||
try { |
|||
if (!isRunning || !isContinuousMode) { |
|||
return@withContext true // 已经停止或非连续模式 |
|||
} |
|||
|
|||
// 发送结束标志 |
|||
stop() |
|||
|
|||
true |
|||
} catch (e: Exception) { |
|||
errorCallback("STOP_ERROR", e.message ?: "停止连续识别失败") |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 推送音频数据 |
|||
*/ |
|||
suspend fun pushAudioData(audioData: ByteArray): Boolean = withContext(Dispatchers.IO) { |
|||
try { |
|||
if (!isRunning) { |
|||
errorCallback("NOT_RUNNING", "语音识别未启动") |
|||
return@withContext false |
|||
} |
|||
|
|||
feedAudioData(audioData) |
|||
true |
|||
} catch (e: Exception) { |
|||
errorCallback("AUDIO_PUSH_ERROR", e.message ?: "推送音频数据失败") |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 用于暂停执行的辅助函数 |
|||
*/ |
|||
private suspend fun delay(ms: Long) = withContext(Dispatchers.IO) { |
|||
try { |
|||
Thread.sleep(ms) |
|||
} catch (e: InterruptedException) { |
|||
// 忽略中断异常 |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新状态并通知Dart层 |
|||
*/ |
|||
private fun updateStatus(status: String) { |
|||
currentStatus = status |
|||
statusCallback(status) |
|||
} |
|||
|
|||
/** |
|||
* 开始语音识别 |
|||
*/ |
|||
private suspend fun start() = withContext(Dispatchers.IO) { |
|||
if (isRunning) return@withContext |
|||
|
|||
isRunning = true |
|||
isLastPacket = false |
|||
seq = 1 |
|||
|
|||
val url = if (streamMode) { |
|||
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" |
|||
} else { |
|||
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_nostream" |
|||
} |
|||
|
|||
val request = Request.Builder() |
|||
.url(url) |
|||
.header("X-Api-App-Key", appId) |
|||
.header("X-Api-Access-Key", accessToken) |
|||
.header("X-Api-Resource-Id", "volc.bigasr.sauc.duration") |
|||
.header("X-Api-Connect-Id", UUID.randomUUID().toString()) |
|||
.build() |
|||
|
|||
updateStatus(STATUS_RECOGNIZING) |
|||
|
|||
webSocket = client.newWebSocket(request, object : WebSocketListener() { |
|||
override fun onOpen(webSocket: WebSocket, response: Response) { |
|||
val logId = response.header("X-Tt-Logid") |
|||
println("ASR WebSocket连接成功, X-Tt-Logid: $logId") |
|||
|
|||
// 发送初始配置请求 |
|||
sendInitialRequest(webSocket) |
|||
} |
|||
|
|||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) { |
|||
try { |
|||
val data = bytes.toByteArray() |
|||
val response = parseResponse(data) |
|||
|
|||
// 根据消息类型处理响应 |
|||
when (response.messageType) { |
|||
FULL_SERVER_RESPONSE -> { |
|||
// 解析识别结果 |
|||
val isFinal = response.messageTypeSpecificFlags == NEG_WITH_SEQUENCE |
|||
|
|||
val resultText = if (response.payload.isNotEmpty()) { |
|||
val decompressedData = if (response.compression == GZIP) { |
|||
gzipDecompress(response.payload) |
|||
} else { |
|||
response.payload |
|||
} |
|||
|
|||
val jsonStr = String(decompressedData) |
|||
extractTextFromJson(jsonStr) |
|||
} else { |
|||
"" |
|||
} |
|||
|
|||
if (resultText.isNotEmpty()) { |
|||
// 处理单次识别模式 |
|||
if (!isContinuousMode) { |
|||
onceResultBuilder.append(resultText) |
|||
} |
|||
|
|||
// 回调识别结果 |
|||
resultCallback(resultText, isFinal) |
|||
} |
|||
|
|||
// 如果是最后一个包,关闭连接 |
|||
if (isFinal) { |
|||
// 单次识别完成回调 |
|||
if (!isContinuousMode && singleRecognitionCallback != null) { |
|||
val finalResult = onceResultBuilder.toString() |
|||
singleRecognitionCallback?.invoke(finalResult) |
|||
onceResultBuilder.clear() |
|||
singleRecognitionCallback = null |
|||
} |
|||
|
|||
if (!isContinuousMode) { |
|||
isRunning = false |
|||
updateStatus(STATUS_STOPPED) |
|||
webSocket.close(1000, "Finished") |
|||
} |
|||
} |
|||
} |
|||
SERVER_ERROR_RESPONSE -> { |
|||
val errorMessage = String(response.payload) |
|||
errorCallback(response.sequence.toString(), errorMessage) |
|||
isRunning = false |
|||
updateStatus(STATUS_ERROR) |
|||
webSocket.close(1000, "Error") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
errorCallback("PARSE_ERROR", e.message ?: "解析响应失败") |
|||
updateStatus(STATUS_ERROR) |
|||
} |
|||
} |
|||
|
|||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { |
|||
isRunning = false |
|||
errorCallback("CONNECTION_ERROR", t.message ?: "连接失败") |
|||
updateStatus(STATUS_ERROR) |
|||
} |
|||
|
|||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { |
|||
isRunning = false |
|||
updateStatus(STATUS_STOPPED) |
|||
println("ASR WebSocket连接关闭: $code, $reason") |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* 发送音频数据 |
|||
*/ |
|||
private suspend fun feedAudioData(audioData: ByteArray) = withContext(Dispatchers.IO) { |
|||
if (!isRunning || isLastPacket) return@withContext |
|||
|
|||
webSocket?.let { ws -> |
|||
// 发送音频请求 |
|||
sendAudioRequest(ws, audioData, false) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止识别 |
|||
*/ |
|||
private suspend fun stop() = withContext(Dispatchers.IO) { |
|||
if (!isRunning || isLastPacket) return@withContext |
|||
|
|||
isLastPacket = true |
|||
|
|||
webSocket?.let { ws -> |
|||
// 发送最后一个音频包 |
|||
sendAudioRequest(ws, ByteArray(0), true) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun release() { |
|||
webSocket?.cancel() |
|||
webSocket = null |
|||
isRunning = false |
|||
isLastPacket = false |
|||
updateStatus(STATUS_STOPPED) |
|||
} |
|||
|
|||
/** |
|||
* 发送初始配置请求 |
|||
*/ |
|||
private fun sendInitialRequest(webSocket: WebSocket) { |
|||
// 构建请求参数 |
|||
val user = JsonObject().apply { |
|||
addProperty("uid", "test") |
|||
} |
|||
|
|||
val audio = JsonObject().apply { |
|||
addProperty("format", "pcm") |
|||
addProperty("sample_rate", sampleRate) |
|||
addProperty("bits", bits) |
|||
addProperty("channel", channels) |
|||
addProperty("codec", "raw") |
|||
} |
|||
|
|||
val request = JsonObject().apply { |
|||
addProperty("model_name", "bigmodel") |
|||
addProperty("enable_punc", enablePunctuation) |
|||
} |
|||
|
|||
val payload = JsonObject().apply { |
|||
add("user", user) |
|||
add("audio", audio) |
|||
add("request", request) |
|||
} |
|||
|
|||
val payloadStr = payload.toString() |
|||
println("ASR初始请求: $payloadStr") |
|||
|
|||
// 压缩负载 |
|||
val compressedPayload = gzipCompress(payloadStr.toByteArray()) |
|||
|
|||
// 构建消息头 |
|||
val header = getHeader( |
|||
FULL_CLIENT_REQUEST, |
|||
POS_SEQUENCE, |
|||
JSON, |
|||
GZIP, |
|||
0 |
|||
) |
|||
|
|||
// 构建消息序列号 |
|||
val seqBytes = intToBytes(seq) |
|||
|
|||
// 构建负载大小 |
|||
val payloadSizeBytes = intToBytes(compressedPayload.size) |
|||
|
|||
// 组装完整请求数据 |
|||
val requestBytes = ByteArray(header.size + seqBytes.size + payloadSizeBytes.size + compressedPayload.size) |
|||
var offset = 0 |
|||
|
|||
System.arraycopy(header, 0, requestBytes, offset, header.size) |
|||
offset += header.size |
|||
|
|||
System.arraycopy(seqBytes, 0, requestBytes, offset, seqBytes.size) |
|||
offset += seqBytes.size |
|||
|
|||
System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size) |
|||
offset += payloadSizeBytes.size |
|||
|
|||
System.arraycopy(compressedPayload, 0, requestBytes, offset, compressedPayload.size) |
|||
|
|||
// 发送请求 |
|||
webSocket.send(ByteString.of(requestBytes)) |
|||
} |
|||
|
|||
/** |
|||
* 发送音频数据请求 |
|||
*/ |
|||
private fun sendAudioRequest(webSocket: WebSocket, audioData: ByteArray, isLast: Boolean) { |
|||
seq++ |
|||
|
|||
// 如果是最后一个包,使用负序列号 |
|||
val actualSeq = if (isLast) -seq else seq |
|||
|
|||
// 设置消息标志 |
|||
val flag = if (isLast) NEG_WITH_SEQUENCE else POS_SEQUENCE |
|||
|
|||
// 构建消息头 |
|||
val header = getHeader( |
|||
AUDIO_ONLY_REQUEST, |
|||
flag, |
|||
NO_SERIALIZATION, |
|||
GZIP, |
|||
0 |
|||
) |
|||
|
|||
// 构建消息序列号 |
|||
val seqBytes = intToBytes(actualSeq) |
|||
|
|||
// 压缩音频数据 |
|||
val compressedPayload = if (audioData.isNotEmpty()) { |
|||
gzipCompress(audioData) |
|||
} else { |
|||
ByteArray(0) |
|||
} |
|||
|
|||
// 构建负载大小 |
|||
val payloadSizeBytes = intToBytes(compressedPayload.size) |
|||
|
|||
// 组装完整请求数据 |
|||
val requestBytes = ByteArray(header.size + seqBytes.size + payloadSizeBytes.size + compressedPayload.size) |
|||
var offset = 0 |
|||
|
|||
System.arraycopy(header, 0, requestBytes, offset, header.size) |
|||
offset += header.size |
|||
|
|||
System.arraycopy(seqBytes, 0, requestBytes, offset, seqBytes.size) |
|||
offset += seqBytes.size |
|||
|
|||
System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size) |
|||
offset += payloadSizeBytes.size |
|||
|
|||
if (compressedPayload.isNotEmpty()) { |
|||
System.arraycopy(compressedPayload, 0, requestBytes, offset, compressedPayload.size) |
|||
} |
|||
|
|||
// 发送请求 |
|||
webSocket.send(ByteString.of(requestBytes)) |
|||
} |
|||
|
|||
/** |
|||
* 解析响应数据 |
|||
*/ |
|||
private fun parseResponse(data: ByteArray): AsrResponse { |
|||
val response = AsrResponse() |
|||
|
|||
// 解析头部 (4字节) |
|||
response.protocolVersion = (data[0].toInt() shr 4) and 0x0F |
|||
response.headerSize = data[0].toInt() and 0x0F |
|||
response.messageType = (data[1].toInt() shr 4) and 0x0F |
|||
response.messageTypeSpecificFlags = data[1].toInt() and 0x0F |
|||
response.serialization = (data[2].toInt() shr 4) and 0x0F |
|||
response.compression = data[2].toInt() and 0x0F |
|||
response.reserved = data[3].toInt() |
|||
|
|||
// 解析序列号 (4字节) |
|||
val seqBytes = ByteArray(4) |
|||
System.arraycopy(data, 4, seqBytes, 0, 4) |
|||
response.sequence = bytesToInt(seqBytes) |
|||
|
|||
// 解析负载大小 (4字节) |
|||
val sizeBytes = ByteArray(4) |
|||
System.arraycopy(data, 8, sizeBytes, 0, 4) |
|||
val payloadSize = bytesToInt(sizeBytes) |
|||
|
|||
// 解析负载数据 |
|||
if (payloadSize > 0 && data.size > 12) { |
|||
val payload = ByteArray(payloadSize) |
|||
System.arraycopy(data, 12, payload, 0, payloadSize) |
|||
response.payload = payload |
|||
} else { |
|||
response.payload = ByteArray(0) |
|||
} |
|||
|
|||
return response |
|||
} |
|||
|
|||
/** |
|||
* 从JSON响应中提取文本 |
|||
*/ |
|||
private fun extractTextFromJson(jsonStr: String): String { |
|||
try { |
|||
val gson = Gson() |
|||
val jsonObject = gson.fromJson(jsonStr, JsonObject::class.java) |
|||
|
|||
// 提取识别结果文本 |
|||
if (jsonObject.has("result") && !jsonObject.get("result").isJsonNull) { |
|||
val result = jsonObject.getAsJsonObject("result") |
|||
|
|||
if (result.has("text") && !result.get("text").isJsonNull) { |
|||
return result.get("text").asString |
|||
} |
|||
|
|||
// 尝试从segments中提取文本 |
|||
if (result.has("segments") && result.get("segments").isJsonArray) { |
|||
val segments = result.getAsJsonArray("segments") |
|||
val builder = StringBuilder() |
|||
|
|||
for (i in 0 until segments.size()) { |
|||
val segment = segments.get(i).asJsonObject |
|||
if (segment.has("text") && !segment.get("text").isJsonNull) { |
|||
builder.append(segment.get("text").asString) |
|||
} |
|||
} |
|||
|
|||
return builder.toString() |
|||
} |
|||
} |
|||
|
|||
return "" |
|||
} catch (e: Exception) { |
|||
println("解析JSON失败: ${e.message}") |
|||
return "" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取消息头 |
|||
*/ |
|||
private fun getHeader( |
|||
messageType: Int, |
|||
messageTypeSpecificFlags: Int, |
|||
serialMethod: Int, |
|||
compressionType: Int, |
|||
reservedData: Int |
|||
): ByteArray { |
|||
val header = ByteArray(4) |
|||
header[0] = ((PROTOCOL_VERSION shl 4) or DEFAULT_HEADER_SIZE).toByte() |
|||
header[1] = ((messageType shl 4) or messageTypeSpecificFlags).toByte() |
|||
header[2] = ((serialMethod shl 4) or compressionType).toByte() |
|||
header[3] = reservedData.toByte() |
|||
return header |
|||
} |
|||
|
|||
/** |
|||
* 整数转字节数组 (大端序) |
|||
*/ |
|||
private fun intToBytes(value: Int): ByteArray { |
|||
return byteArrayOf( |
|||
((value shr 24) and 0xFF).toByte(), |
|||
((value shr 16) and 0xFF).toByte(), |
|||
((value shr 8) and 0xFF).toByte(), |
|||
(value and 0xFF).toByte() |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 字节数组转整数 (大端序) |
|||
*/ |
|||
private fun bytesToInt(bytes: ByteArray): Int { |
|||
if (bytes.size != 4) { |
|||
throw IllegalArgumentException("字节数组长度必须为4") |
|||
} |
|||
|
|||
return ((bytes[0].toInt() and 0xFF) shl 24) or |
|||
((bytes[1].toInt() and 0xFF) shl 16) or |
|||
((bytes[2].toInt() and 0xFF) shl 8) or |
|||
(bytes[3].toInt() and 0xFF) |
|||
} |
|||
|
|||
/** |
|||
* GZIP压缩 |
|||
*/ |
|||
private fun gzipCompress(data: ByteArray): ByteArray { |
|||
if (data.isEmpty()) return ByteArray(0) |
|||
|
|||
val output = ByteArrayOutputStream() |
|||
GZIPOutputStream(output).use { gzip -> |
|||
gzip.write(data) |
|||
} |
|||
|
|||
return output.toByteArray() |
|||
} |
|||
|
|||
/** |
|||
* GZIP解压缩 |
|||
*/ |
|||
private fun gzipDecompress(data: ByteArray): ByteArray { |
|||
if (data.isEmpty()) return ByteArray(0) |
|||
|
|||
val output = ByteArrayOutputStream() |
|||
GZIPInputStream(data.inputStream()).use { gzip -> |
|||
val buffer = ByteArray(1024) |
|||
var len: Int |
|||
|
|||
while (gzip.read(buffer).also { len = it } > 0) { |
|||
output.write(buffer, 0, len) |
|||
} |
|||
} |
|||
|
|||
return output.toByteArray() |
|||
} |
|||
|
|||
/** |
|||
* ASR响应数据结构 |
|||
*/ |
|||
private data class AsrResponse( |
|||
var protocolVersion: Int = 0, |
|||
var headerSize: Int = 0, |
|||
var messageType: Int = 0, |
|||
var messageTypeSpecificFlags: Int = 0, |
|||
var serialization: Int = 0, |
|||
var compression: Int = 0, |
|||
var reserved: Int = 0, |
|||
var sequence: Int = 0, |
|||
var payload: ByteArray = ByteArray(0) |
|||
) { |
|||
override fun equals(other: Any?): Boolean { |
|||
if (this === other) return true |
|||
if (javaClass != other?.javaClass) return false |
|||
|
|||
other as AsrResponse |
|||
|
|||
if (protocolVersion != other.protocolVersion) return false |
|||
if (headerSize != other.headerSize) return false |
|||
if (messageType != other.messageType) return false |
|||
if (messageTypeSpecificFlags != other.messageTypeSpecificFlags) return false |
|||
if (serialization != other.serialization) return false |
|||
if (compression != other.compression) return false |
|||
if (reserved != other.reserved) return false |
|||
if (sequence != other.sequence) return false |
|||
if (!payload.contentEquals(other.payload)) return false |
|||
|
|||
return true |
|||
} |
|||
|
|||
override fun hashCode(): Int { |
|||
var result = protocolVersion |
|||
result = 31 * result + headerSize |
|||
result = 31 * result + messageType |
|||
result = 31 * result + messageTypeSpecificFlags |
|||
result = 31 * result + serialization |
|||
result = 31 * result + compression |
|||
result = 31 * result + reserved |
|||
result = 31 * result + sequence |
|||
result = 31 * result + payload.contentHashCode() |
|||
return result |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,331 @@ |
|||
package com.deep_voice.bytedance_speech |
|||
|
|||
import android.content.Context |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import androidx.annotation.NonNull |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import io.flutter.plugin.common.EventChannel |
|||
import kotlinx.coroutines.CoroutineScope |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.SupervisorJob |
|||
import kotlinx.coroutines.cancel |
|||
import kotlinx.coroutines.launch |
|||
|
|||
/** 火山引擎语音识别和语音合成插件 */ |
|||
class BytedanceSpeechPlugin : FlutterPlugin { |
|||
private val TAG = "BytedanceSpeechPlugin" |
|||
private lateinit var context: Context |
|||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) |
|||
private val mainHandler = Handler(Looper.getMainLooper()) |
|||
|
|||
// ASR相关 |
|||
private lateinit var asrChannel: MethodChannel |
|||
private lateinit var asrEventChannel: EventChannel |
|||
private var asrEventSink: EventChannel.EventSink? = null |
|||
private var asrInstance: BytedanceASR? = null |
|||
|
|||
// TTS相关 |
|||
private lateinit var ttsChannel: MethodChannel |
|||
private lateinit var ttsEventChannel: EventChannel |
|||
private var ttsEventSink: EventChannel.EventSink? = null |
|||
private var ttsInstance: BytedanceTTS? = null |
|||
|
|||
// ASR事件发送方法 |
|||
private fun sendAsrEvent(event: Map<String, Any>) { |
|||
println("发送ASR事件: $event") |
|||
if (asrEventSink == null) { |
|||
println("无法发送ASR事件:事件通道未准备好") |
|||
return |
|||
} |
|||
|
|||
mainHandler.post { |
|||
try { |
|||
asrEventSink?.success(event) |
|||
println("ASR事件发送成功") |
|||
} catch (e: Exception) { |
|||
println("发送ASR事件失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// TTS事件发送方法 |
|||
private fun sendTtsEvent(event: Map<String, Any>) { |
|||
println("发送TTS事件: $event") |
|||
if (ttsEventSink == null) { |
|||
println("无法发送TTS事件:事件通道未准备好") |
|||
return |
|||
} |
|||
|
|||
mainHandler.post { |
|||
try { |
|||
ttsEventSink?.success(event) |
|||
println("TTS事件发送成功") |
|||
} catch (e: Exception) { |
|||
println("发送TTS事件失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
context = flutterPluginBinding.applicationContext |
|||
|
|||
// 初始化ASR通道 |
|||
asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/asr") |
|||
asrChannel.setMethodCallHandler(AsrMethodHandler()) |
|||
|
|||
// 初始化TTS通道 |
|||
ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/tts") |
|||
ttsChannel.setMethodCallHandler(TtsMethodHandler()) |
|||
|
|||
// 初始化ASR事件通道 |
|||
asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/asr_events") |
|||
asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
asrEventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
asrEventSink = null |
|||
} |
|||
}) |
|||
|
|||
// 初始化TTS事件通道 |
|||
ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "bytedance_speech/tts_events") |
|||
ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
ttsEventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
ttsEventSink = null |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// ASR方法处理器 |
|||
inner class AsrMethodHandler : MethodCallHandler { |
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"initialize" -> { |
|||
val appId = call.argument<String>("appId") ?: "" |
|||
val accessToken = call.argument<String>("accessToken") ?: "" |
|||
val streamMode = call.argument<Boolean>("streamMode") ?: true |
|||
val enablePunctuation = call.argument<Boolean>("enablePunctuation") ?: true |
|||
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
|||
val bits = call.argument<Int>("bits") ?: 16 |
|||
val channels = call.argument<Int>("channels") ?: 1 |
|||
|
|||
try { |
|||
asrInstance = BytedanceASR( |
|||
context = context, |
|||
appId = appId, |
|||
accessToken = accessToken, |
|||
streamMode = streamMode, |
|||
enablePunctuation = enablePunctuation, |
|||
sampleRate = sampleRate, |
|||
bits = bits, |
|||
channels = channels, |
|||
resultCallback = { text, isFinal -> |
|||
val eventType = if (isFinal) "result" else "recognizing" |
|||
sendAsrEvent(mapOf( |
|||
"type" to eventType, |
|||
"text" to text, |
|||
"isFinal" to isFinal |
|||
)) |
|||
}, |
|||
errorCallback = { errorCode, errorMessage -> |
|||
sendAsrEvent(mapOf( |
|||
"type" to "error", |
|||
"errorCode" to errorCode, |
|||
"errorMessage" to errorMessage |
|||
)) |
|||
} |
|||
) |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
result.error("INITIALIZATION_ERROR", e.message, null) |
|||
} |
|||
} |
|||
"startRecognition" -> { |
|||
if (asrEventSink == null) { |
|||
result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始识别", null) |
|||
return |
|||
} |
|||
|
|||
scope.launch { |
|||
try { |
|||
asrInstance?.start() |
|||
sendAsrEvent(mapOf("type" to "sessionStarted")) |
|||
scope.launch(Dispatchers.Main) { |
|||
result.success(true) |
|||
} |
|||
} catch (e: Exception) { |
|||
scope.launch(Dispatchers.Main) { |
|||
result.error("START_ERROR", e.message, null) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
"feedAudioData" -> { |
|||
val audioData = call.argument<ByteArray>("audioData") |
|||
if (audioData != null) { |
|||
scope.launch { |
|||
try { |
|||
asrInstance?.feedAudioData(audioData) |
|||
scope.launch(Dispatchers.Main) { |
|||
result.success(true) |
|||
} |
|||
} catch (e: Exception) { |
|||
scope.launch(Dispatchers.Main) { |
|||
result.error("FEED_ERROR", e.message, null) |
|||
} |
|||
} |
|||
} |
|||
} else { |
|||
result.error("INVALID_ARGUMENT", "音频数据不能为空", null) |
|||
} |
|||
} |
|||
"stopRecognition" -> { |
|||
scope.launch { |
|||
try { |
|||
asrInstance?.stop() |
|||
sendAsrEvent(mapOf("type" to "sessionStopped")) |
|||
scope.launch(Dispatchers.Main) { |
|||
result.success(true) |
|||
} |
|||
} catch (e: Exception) { |
|||
scope.launch(Dispatchers.Main) { |
|||
result.error("STOP_ERROR", e.message, null) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
"dispose" -> { |
|||
try { |
|||
asrInstance?.release() |
|||
asrInstance = null |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
result.error("DISPOSE_ERROR", e.message, null) |
|||
} |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// TTS方法处理器 |
|||
inner class TtsMethodHandler : MethodCallHandler { |
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"initialize" -> { |
|||
val appId = call.argument<String>("appId") ?: "" |
|||
val accessToken = call.argument<String>("accessToken") ?: "" |
|||
val speaker = call.argument<String>("speaker") ?: "" |
|||
val format = call.argument<String>("format") ?: "mp3" |
|||
val sampleRate = call.argument<Int>("sampleRate") ?: 24000 |
|||
val enableTimestamp = call.argument<Boolean>("enableTimestamp") ?: true |
|||
|
|||
try { |
|||
ttsInstance = BytedanceTTS( |
|||
context = context, |
|||
appId = appId, |
|||
accessToken = accessToken, |
|||
speaker = speaker, |
|||
format = format, |
|||
sampleRate = sampleRate, |
|||
enableTimestamp = enableTimestamp, |
|||
audioCallback = { audioData -> |
|||
sendTtsEvent(mapOf( |
|||
"type" to "audio", |
|||
"audioData" to audioData |
|||
)) |
|||
}, |
|||
completionCallback = { |
|||
sendTtsEvent(mapOf("type" to "completed")) |
|||
}, |
|||
errorCallback = { errorCode, errorMessage -> |
|||
sendTtsEvent(mapOf( |
|||
"type" to "error", |
|||
"errorCode" to errorCode, |
|||
"errorMessage" to errorMessage |
|||
)) |
|||
} |
|||
) |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
result.error("INITIALIZATION_ERROR", e.message, null) |
|||
} |
|||
} |
|||
"synthesize" -> { |
|||
val text = call.argument<String>("text") ?: "" |
|||
|
|||
if (ttsEventSink == null) { |
|||
result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始合成", null) |
|||
return |
|||
} |
|||
|
|||
scope.launch { |
|||
try { |
|||
sendTtsEvent(mapOf("type" to "started")) |
|||
ttsInstance?.synthesize(text) |
|||
scope.launch(Dispatchers.Main) { |
|||
result.success(true) |
|||
} |
|||
} catch (e: Exception) { |
|||
scope.launch(Dispatchers.Main) { |
|||
result.error("SYNTHESIS_ERROR", e.message, null) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
"stopSynthesis" -> { |
|||
scope.launch { |
|||
try { |
|||
ttsInstance?.stop() |
|||
scope.launch(Dispatchers.Main) { |
|||
result.success(true) |
|||
} |
|||
} catch (e: Exception) { |
|||
scope.launch(Dispatchers.Main) { |
|||
result.error("STOP_ERROR", e.message, null) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
"dispose" -> { |
|||
try { |
|||
ttsInstance?.release() |
|||
ttsInstance = null |
|||
result.success(true) |
|||
} catch (e: Exception) { |
|||
result.error("DISPOSE_ERROR", e.message, null) |
|||
} |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
asrChannel.setMethodCallHandler(null) |
|||
ttsChannel.setMethodCallHandler(null) |
|||
asrEventChannel.setStreamHandler(null) |
|||
ttsEventChannel.setStreamHandler(null) |
|||
|
|||
scope.cancel() |
|||
asrInstance?.release() |
|||
ttsInstance?.release() |
|||
asrInstance = null |
|||
ttsInstance = null |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -0,0 +1,367 @@ |
|||
package com.yunqiinnovation.chat_storage |
|||
|
|||
import android.content.Context |
|||
import android.database.Cursor |
|||
import android.database.sqlite.SQLiteDatabase |
|||
import android.database.sqlite.SQLiteOpenHelper |
|||
import android.util.Log |
|||
import org.json.JSONArray |
|||
import org.json.JSONObject |
|||
import java.text.SimpleDateFormat |
|||
import java.util.* |
|||
import kotlin.collections.ArrayList |
|||
|
|||
/** |
|||
* 数据库帮助类 |
|||
* 负责SQLite数据库操作和业务逻辑 |
|||
*/ |
|||
class ChatStorageHelper(context: Context) { |
|||
private val TAG = "ChatStorageHelper" |
|||
private val dbHelper: ChatDatabaseHelper = ChatDatabaseHelper(context) |
|||
|
|||
/** |
|||
* 保存消息 |
|||
* |
|||
* @param sessionId 会话ID |
|||
* @param message 消息内容 |
|||
* @param sender 发送者("user" 或 "assistant") |
|||
* @param metadata 元数据(可选) |
|||
* @return 成功返回消息ID,失败返回-1 |
|||
*/ |
|||
fun saveMessage( |
|||
sessionId: String, |
|||
message: String, |
|||
sender: String, |
|||
metadata: String? = null |
|||
): Long { |
|||
try { |
|||
val db = dbHelper.writableDatabase |
|||
|
|||
// 获取当前时间作为时间戳 |
|||
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) |
|||
|
|||
// 执行插入操作 |
|||
val values = android.content.ContentValues().apply { |
|||
put(ChatDatabaseHelper.COLUMN_SESSION_ID, sessionId) |
|||
put(ChatDatabaseHelper.COLUMN_MESSAGE, message) |
|||
put(ChatDatabaseHelper.COLUMN_SENDER, sender) |
|||
put(ChatDatabaseHelper.COLUMN_TIMESTAMP, timestamp) |
|||
put(ChatDatabaseHelper.COLUMN_METADATA, metadata) |
|||
} |
|||
|
|||
return db.insert(ChatDatabaseHelper.TABLE_MESSAGES, null, values) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "保存消息失败: ${e.message}", e) |
|||
return -1 |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定会话的消息 |
|||
* |
|||
* @param sessionId 会话ID |
|||
* @param page 页码,从1开始 |
|||
* @param pageSize 每页大小 |
|||
* @return 包含消息列表和分页信息的JSON对象 |
|||
*/ |
|||
fun getMessages(sessionId: String, page: Int = 1, pageSize: Int = 20): JSONObject { |
|||
try { |
|||
val db = dbHelper.readableDatabase |
|||
val offset = (page - 1) * pageSize |
|||
|
|||
// 构建查询语句 |
|||
val query = """ |
|||
SELECT * FROM ${ChatDatabaseHelper.TABLE_MESSAGES} |
|||
WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ? |
|||
ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC |
|||
LIMIT ? OFFSET ? |
|||
""".trimIndent() |
|||
|
|||
val selectionArgs = arrayOf(sessionId, pageSize.toString(), offset.toString()) |
|||
val cursor = db.rawQuery(query, selectionArgs) |
|||
|
|||
val messages = cursorToJsonArray(cursor) |
|||
|
|||
// 获取总记录数 |
|||
val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?" |
|||
val countCursor = db.rawQuery(countQuery, arrayOf(sessionId)) |
|||
var totalCount = 0 |
|||
if (countCursor.moveToFirst()) { |
|||
totalCount = countCursor.getInt(0) |
|||
} |
|||
countCursor.close() |
|||
|
|||
// 计算总页数 |
|||
val totalPages = if (totalCount % pageSize == 0) { |
|||
totalCount / pageSize |
|||
} else { |
|||
(totalCount / pageSize) + 1 |
|||
} |
|||
|
|||
return JSONObject().apply { |
|||
put("messages", messages) |
|||
put("page", page) |
|||
put("pageSize", pageSize) |
|||
put("totalCount", totalCount) |
|||
put("totalPages", totalPages) |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "获取消息失败: ${e.message}", e) |
|||
return JSONObject().apply { |
|||
put("messages", JSONArray()) |
|||
put("error", e.message) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取会话列表 |
|||
* |
|||
* @return 包含会话列表的JSON数组 |
|||
*/ |
|||
fun getSessions(): JSONArray { |
|||
try { |
|||
val db = dbHelper.readableDatabase |
|||
|
|||
// 查询不同的会话ID及其最新消息 |
|||
val query = """ |
|||
SELECT s1.${ChatDatabaseHelper.COLUMN_SESSION_ID}, |
|||
s1.${ChatDatabaseHelper.COLUMN_MESSAGE}, |
|||
s1.${ChatDatabaseHelper.COLUMN_SENDER}, |
|||
s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} |
|||
FROM ${ChatDatabaseHelper.TABLE_MESSAGES} s1 |
|||
INNER JOIN ( |
|||
SELECT ${ChatDatabaseHelper.COLUMN_SESSION_ID}, |
|||
MAX(${ChatDatabaseHelper.COLUMN_TIMESTAMP}) as max_timestamp |
|||
FROM ${ChatDatabaseHelper.TABLE_MESSAGES} |
|||
GROUP BY ${ChatDatabaseHelper.COLUMN_SESSION_ID} |
|||
) s2 ON s1.${ChatDatabaseHelper.COLUMN_SESSION_ID} = s2.${ChatDatabaseHelper.COLUMN_SESSION_ID} |
|||
AND s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} = s2.max_timestamp |
|||
ORDER BY s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC |
|||
""".trimIndent() |
|||
|
|||
val cursor = db.rawQuery(query, null) |
|||
val sessions = JSONArray() |
|||
|
|||
val sessionIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_ID) |
|||
val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE) |
|||
val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER) |
|||
val timestampIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_TIMESTAMP) |
|||
|
|||
while (cursor.moveToNext()) { |
|||
val session = JSONObject().apply { |
|||
put("sessionId", cursor.getString(sessionIdIndex)) |
|||
put("lastMessage", cursor.getString(messageIndex)) |
|||
put("lastSender", cursor.getString(senderIndex)) |
|||
put("timestamp", cursor.getString(timestampIndex)) |
|||
|
|||
// 获取会话中的消息数量 |
|||
val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?" |
|||
val countCursor = db.rawQuery(countQuery, arrayOf(cursor.getString(sessionIdIndex))) |
|||
if (countCursor.moveToFirst()) { |
|||
put("messageCount", countCursor.getInt(0)) |
|||
} |
|||
countCursor.close() |
|||
} |
|||
sessions.put(session) |
|||
} |
|||
|
|||
cursor.close() |
|||
return sessions |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "获取会话列表失败: ${e.message}", e) |
|||
return JSONArray() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 删除消息 |
|||
* |
|||
* @param sessionId 会话ID(可选,如果提供则删除整个会话) |
|||
* @param messageIds 消息ID列表(可选,如果提供则删除指定消息) |
|||
* @return 成功返回true,失败返回false |
|||
*/ |
|||
fun deleteMessages(sessionId: String? = null, messageIds: List<Int>? = null): Boolean { |
|||
try { |
|||
val db = dbHelper.writableDatabase |
|||
|
|||
if (messageIds != null && messageIds.isNotEmpty()) { |
|||
// 删除指定ID的消息 |
|||
val idsStr = messageIds.joinToString(",") |
|||
val deleteQuery = "DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_ID} IN ($idsStr)" |
|||
db.execSQL(deleteQuery) |
|||
return true |
|||
} else if (!sessionId.isNullOrEmpty()) { |
|||
// 删除会话中的所有消息 |
|||
val deleteQuery = "DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?" |
|||
db.execSQL(deleteQuery, arrayOf(sessionId)) |
|||
return true |
|||
} |
|||
return false |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "删除消息失败: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清空数据库 |
|||
* |
|||
* @return 成功返回true,失败返回false |
|||
*/ |
|||
fun clearDatabase(): Boolean { |
|||
try { |
|||
val db = dbHelper.writableDatabase |
|||
db.execSQL("DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES}") |
|||
return true |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "清空数据库失败: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 查询指定会话的最近N条消息 |
|||
* |
|||
* @param sessionId 会话ID |
|||
* @param limit 限制数量 |
|||
* @return 消息列表,按时间正序排列 |
|||
*/ |
|||
fun getRecentMessages(sessionId: String, limit: Int): List<Map<String, Any>> { |
|||
try { |
|||
val db = dbHelper.readableDatabase |
|||
|
|||
// 构建查询语句 - 按时间倒序获取最近的N条,然后在结果中再按时间正序 |
|||
val query = """ |
|||
SELECT * FROM ( |
|||
SELECT * FROM ${ChatDatabaseHelper.TABLE_MESSAGES} |
|||
WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ? |
|||
ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC |
|||
LIMIT ? |
|||
) tmp ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} ASC |
|||
""".trimIndent() |
|||
|
|||
val selectionArgs = arrayOf(sessionId, limit.toString()) |
|||
val cursor = db.rawQuery(query, selectionArgs) |
|||
|
|||
val messages = ArrayList<Map<String, Any>>() |
|||
|
|||
val idIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_ID) |
|||
val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE) |
|||
val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER) |
|||
val timestampIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_TIMESTAMP) |
|||
val metadataIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_METADATA) |
|||
|
|||
while (cursor.moveToNext()) { |
|||
val message = mutableMapOf<String, Any>( |
|||
"id" to cursor.getInt(idIndex), |
|||
"message" to cursor.getString(messageIndex), |
|||
"sender" to cursor.getString(senderIndex), |
|||
"timestamp" to cursor.getString(timestampIndex) |
|||
) |
|||
|
|||
if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) { |
|||
message["metadata"] = cursor.getString(metadataIndex) |
|||
} |
|||
|
|||
messages.add(message) |
|||
} |
|||
|
|||
cursor.close() |
|||
return messages |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "获取最近消息失败: ${e.message}", e) |
|||
return emptyList() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将数据库游标转换为JSON数组 |
|||
*/ |
|||
private fun cursorToJsonArray(cursor: Cursor): JSONArray { |
|||
val jsonArray = JSONArray() |
|||
|
|||
val idIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_ID) |
|||
val sessionIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_ID) |
|||
val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE) |
|||
val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER) |
|||
val timestampIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_TIMESTAMP) |
|||
val metadataIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_METADATA) |
|||
|
|||
while (cursor.moveToNext()) { |
|||
val jsonObject = JSONObject().apply { |
|||
put("id", cursor.getInt(idIndex)) |
|||
put("sessionId", cursor.getString(sessionIdIndex)) |
|||
put("message", cursor.getString(messageIndex)) |
|||
put("sender", cursor.getString(senderIndex)) |
|||
put("timestamp", cursor.getString(timestampIndex)) |
|||
|
|||
if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) { |
|||
put("metadata", cursor.getString(metadataIndex)) |
|||
} |
|||
} |
|||
jsonArray.put(jsonObject) |
|||
} |
|||
|
|||
cursor.close() |
|||
return jsonArray |
|||
} |
|||
|
|||
/** |
|||
* 获取数据库帮助类实例 |
|||
*/ |
|||
fun getDBHelper(): ChatDatabaseHelper { |
|||
return dbHelper |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* SQLite数据库帮助类 |
|||
*/ |
|||
class ChatDatabaseHelper(context: Context) : |
|||
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { |
|||
|
|||
companion object { |
|||
const val DATABASE_NAME = "chat_messages.db" |
|||
const val DATABASE_VERSION = 1 |
|||
|
|||
// 定义消息表 |
|||
const val TABLE_MESSAGES = "messages" |
|||
const val COLUMN_ID = "id" |
|||
const val COLUMN_SESSION_ID = "session_id" |
|||
const val COLUMN_MESSAGE = "message" |
|||
const val COLUMN_SENDER = "sender" |
|||
const val COLUMN_TIMESTAMP = "timestamp" |
|||
const val COLUMN_METADATA = "metadata" |
|||
} |
|||
|
|||
override fun onCreate(db: SQLiteDatabase) { |
|||
// 创建消息表 |
|||
val createMessagesTable = """ |
|||
CREATE TABLE $TABLE_MESSAGES ( |
|||
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, |
|||
$COLUMN_SESSION_ID TEXT NOT NULL, |
|||
$COLUMN_MESSAGE TEXT NOT NULL, |
|||
$COLUMN_SENDER TEXT NOT NULL, |
|||
$COLUMN_TIMESTAMP TEXT NOT NULL, |
|||
$COLUMN_METADATA TEXT |
|||
) |
|||
""".trimIndent() |
|||
|
|||
db.execSQL(createMessagesTable) |
|||
|
|||
// 创建索引以加快查询速度 |
|||
db.execSQL("CREATE INDEX idx_session_id ON $TABLE_MESSAGES ($COLUMN_SESSION_ID)") |
|||
db.execSQL("CREATE INDEX idx_timestamp ON $TABLE_MESSAGES ($COLUMN_TIMESTAMP)") |
|||
} |
|||
|
|||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { |
|||
// 处理数据库升级逻辑 |
|||
if (oldVersion < newVersion) { |
|||
// 简单的升级策略 - 删除旧表并创建新表 |
|||
// 在实际应用中,应该实现数据迁移而非删除 |
|||
db.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES") |
|||
onCreate(db) |
|||
} |
|||
} |
|||
} |
|||
@ -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") |
|||
} |
|||
@ -0,0 +1 @@ |
|||
rootProject.name = "classic_bluetooth" |
|||
@ -0,0 +1,17 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.classic_bluetooth"> |
|||
|
|||
<!-- 蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> |
|||
|
|||
<!-- Android 12+蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
|
|||
<!-- 位置权限(部分Android版本需要) --> |
|||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
|||
|
|||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" /> |
|||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" /> |
|||
</manifest> |
|||
@ -0,0 +1,266 @@ |
|||
package com.yunqiinnovation.classic_bluetooth |
|||
|
|||
import android.bluetooth.BluetoothAdapter |
|||
import android.bluetooth.BluetoothDevice |
|||
import android.bluetooth.BluetoothProfile |
|||
import android.content.Context |
|||
import android.content.pm.PackageManager |
|||
import android.content.BroadcastReceiver |
|||
import android.content.Intent |
|||
import android.content.IntentFilter |
|||
import android.os.Build |
|||
import android.Manifest |
|||
import androidx.core.content.ContextCompat |
|||
import android.util.Log |
|||
|
|||
/** |
|||
* 经典蓝牙助手类,集成蓝牙设备管理和状态监听功能 |
|||
*/ |
|||
class ClassicBluetoothHelper(private val context: Context) { |
|||
|
|||
private val TAG = "ClassicBluetoothHelper" |
|||
|
|||
// 回调接口用于通知蓝牙事件 |
|||
private var bluetoothEventListener: BluetoothEventListener? = null |
|||
|
|||
// 蓝牙状态变化广播接收器 |
|||
private val bluetoothStateReceiver = object : BroadcastReceiver() { |
|||
override fun onReceive(context: Context?, intent: Intent?) { |
|||
when (intent?.action) { |
|||
BluetoothDevice.ACTION_ACL_CONNECTED -> { |
|||
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
|||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) |
|||
} else { |
|||
@Suppress("DEPRECATION") |
|||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) |
|||
} |
|||
|
|||
if (device != null) { |
|||
val deviceInfo = mapOf( |
|||
"name" to (device.name ?: "未知设备"), |
|||
"address" to device.address |
|||
) |
|||
log("蓝牙设备已连接: ${deviceInfo["name"]} (${deviceInfo["address"]})") |
|||
notifyEvent("deviceConnected", deviceInfo) |
|||
} |
|||
} |
|||
BluetoothDevice.ACTION_ACL_DISCONNECTED -> { |
|||
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
|||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) |
|||
} else { |
|||
@Suppress("DEPRECATION") |
|||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) |
|||
} |
|||
|
|||
if (device != null) { |
|||
val deviceInfo = mapOf( |
|||
"name" to (device.name ?: "未知设备"), |
|||
"address" to device.address |
|||
) |
|||
log("蓝牙设备已断开: ${deviceInfo["name"]} (${deviceInfo["address"]})") |
|||
notifyEvent("deviceDisconnected", deviceInfo) |
|||
} |
|||
} |
|||
BluetoothAdapter.ACTION_STATE_CHANGED -> { |
|||
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) |
|||
when (state) { |
|||
BluetoothAdapter.STATE_ON -> { |
|||
log("蓝牙已开启") |
|||
notifyEvent("bluetoothStateChanged", mapOf("state" to "on")) |
|||
} |
|||
BluetoothAdapter.STATE_OFF -> { |
|||
log("蓝牙已关闭") |
|||
notifyEvent("bluetoothStateChanged", mapOf("state" to "off")) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
init { |
|||
// 在构造函数中注册广播接收器 |
|||
registerBroadcastReceivers() |
|||
} |
|||
|
|||
/** |
|||
* 注册蓝牙广播接收器 |
|||
*/ |
|||
private fun registerBroadcastReceivers() { |
|||
val bluetoothFilter = IntentFilter().apply { |
|||
addAction(BluetoothDevice.ACTION_ACL_CONNECTED) |
|||
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) |
|||
addAction(BluetoothAdapter.ACTION_STATE_CHANGED) |
|||
} |
|||
|
|||
if (Build.VERSION.SDK_INT >= 34) { // Android 14 (UPSIDE_DOWN_CAKE) |
|||
context.registerReceiver(bluetoothStateReceiver, bluetoothFilter, Context.RECEIVER_NOT_EXPORTED) |
|||
} else { |
|||
context.registerReceiver(bluetoothStateReceiver, bluetoothFilter) |
|||
} |
|||
|
|||
log("已注册蓝牙状态广播接收器") |
|||
} |
|||
|
|||
/** |
|||
* 通知蓝牙事件 |
|||
*/ |
|||
private fun notifyEvent(type: String, data: Map<String, Any>) { |
|||
val event = mutableMapOf<String, Any>( |
|||
"type" to type, |
|||
"timestamp" to System.currentTimeMillis() |
|||
) |
|||
|
|||
// 添加其他数据 |
|||
if (type == "deviceConnected" || type == "deviceDisconnected") { |
|||
event["device"] = data |
|||
} else { |
|||
event.putAll(data) |
|||
} |
|||
|
|||
// 通知监听器 |
|||
bluetoothEventListener?.onBluetoothEvent(event) |
|||
} |
|||
|
|||
/** |
|||
* 设置蓝牙事件监听器 |
|||
*/ |
|||
fun setBluetoothEventListener(listener: BluetoothEventListener?) { |
|||
this.bluetoothEventListener = listener |
|||
} |
|||
|
|||
/** |
|||
* 检查蓝牙是否启用 |
|||
* @return 蓝牙是否启用 |
|||
*/ |
|||
fun isBluetoothEnabled(): Boolean { |
|||
try { |
|||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() |
|||
if (bluetoothAdapter == null) { |
|||
log("设备不支持蓝牙", true) |
|||
return false |
|||
} |
|||
|
|||
return bluetoothAdapter.isEnabled |
|||
} catch (e: Exception) { |
|||
log("检查蓝牙状态失败: ${e.message}", true) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取所有连接的经典蓝牙设备 |
|||
* @return 设备信息列表,如果出错则返回空列表 |
|||
*/ |
|||
fun getConnectedBluetoothDevices(): List<Map<String, String>> { |
|||
if (!hasBluetoothPermission()) { |
|||
log("缺少蓝牙权限", true) |
|||
return emptyList() |
|||
} |
|||
|
|||
val devices = mutableSetOf<BluetoothDevice>() |
|||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: return emptyList() |
|||
|
|||
if (!bluetoothAdapter.isEnabled) { |
|||
return emptyList() |
|||
} |
|||
|
|||
try { |
|||
// 获取已配对设备 |
|||
val bondedDevices = bluetoothAdapter.bondedDevices ?: setOf() |
|||
|
|||
// 只保留已连接的设备 |
|||
devices.addAll(bondedDevices.filter { device -> |
|||
try { |
|||
val deviceClass = device.bluetoothClass |
|||
val majorDeviceClass = deviceClass.majorDeviceClass |
|||
// 检查设备是否为音频设备、穿戴设备等常见外设类型 |
|||
device.bondState == BluetoothDevice.BOND_BONDED && |
|||
(majorDeviceClass == BluetoothProfile.HEADSET || |
|||
majorDeviceClass == BluetoothProfile.A2DP || |
|||
majorDeviceClass == BluetoothProfile.GATT) |
|||
} catch (e: Exception) { |
|||
false |
|||
} |
|||
}) |
|||
|
|||
return devices.map { device -> |
|||
mapOf( |
|||
"name" to (device.name ?: "未知设备"), |
|||
"address" to device.address, |
|||
"type" to getDeviceTypeName(device) |
|||
) |
|||
} |
|||
} catch (e: Exception) { |
|||
log("获取已连接蓝牙设备失败: ${e.message}", true) |
|||
return emptyList() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取设备类型名称 |
|||
*/ |
|||
private fun getDeviceTypeName(device: BluetoothDevice): String { |
|||
return try { |
|||
val deviceClass = device.bluetoothClass |
|||
when (deviceClass.majorDeviceClass) { |
|||
BluetoothProfile.HEADSET -> "耳机" |
|||
BluetoothProfile.A2DP -> "音频设备" |
|||
BluetoothProfile.GATT -> "低功耗设备" |
|||
else -> "其他设备" |
|||
} |
|||
} catch (e: Exception) { |
|||
"未知类型" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查是否有蓝牙权限 |
|||
*/ |
|||
fun hasBluetoothPermission(): Boolean { |
|||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
|||
ContextCompat.checkSelfPermission( |
|||
context, |
|||
Manifest.permission.BLUETOOTH_CONNECT |
|||
) == PackageManager.PERMISSION_GRANTED |
|||
} else { |
|||
ContextCompat.checkSelfPermission( |
|||
context, |
|||
Manifest.permission.BLUETOOTH |
|||
) == PackageManager.PERMISSION_GRANTED |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 简单的日志函数 |
|||
*/ |
|||
private fun log(message: String, isError: Boolean = false) { |
|||
if (isError) { |
|||
Log.e(TAG, message) |
|||
} else { |
|||
Log.d(TAG, message) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 蓝牙事件回调接口 |
|||
*/ |
|||
interface BluetoothEventListener { |
|||
fun onBluetoothEvent(event: Map<String, Any>) |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun dispose() { |
|||
try { |
|||
context.unregisterReceiver(bluetoothStateReceiver) |
|||
log("已注销蓝牙状态广播接收器") |
|||
} catch (e: Exception) { |
|||
log("注销蓝牙状态广播接收器失败: ${e.message}", true) |
|||
} |
|||
|
|||
// 清除监听器 |
|||
bluetoothEventListener = null |
|||
} |
|||
} |
|||
@ -0,0 +1,186 @@ |
|||
package com.yunqiinnovation.classic_bluetooth |
|||
|
|||
import androidx.annotation.NonNull |
|||
import android.Manifest |
|||
import android.app.Activity |
|||
import android.content.Context |
|||
import android.content.pm.PackageManager |
|||
import android.os.Build |
|||
import androidx.core.app.ActivityCompat |
|||
import androidx.core.content.ContextCompat |
|||
|
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityAware |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import io.flutter.plugin.common.EventChannel |
|||
import io.flutter.plugin.common.EventChannel.EventSink |
|||
import io.flutter.plugin.common.PluginRegistry.RequestPermissionsResultListener |
|||
|
|||
/** ClassicBluetoothPlugin */ |
|||
class ClassicBluetoothPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, RequestPermissionsResultListener, ClassicBluetoothHelper.BluetoothEventListener { |
|||
private lateinit var methodChannel : MethodChannel |
|||
private lateinit var eventChannel : EventChannel |
|||
private var eventSink: EventSink? = null |
|||
|
|||
private lateinit var context: Context |
|||
private var activity: Activity? = null |
|||
private lateinit var bluetoothHelper: ClassicBluetoothHelper |
|||
|
|||
private val REQUEST_BLUETOOTH_PERMISSIONS = 1003 |
|||
private var pendingResult: Result? = null |
|||
private var pendingMethodCall: MethodCall? = null |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
context = flutterPluginBinding.applicationContext |
|||
|
|||
// 设置方法通道 |
|||
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.classic_bluetooth/methods") |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
// 设置事件通道 |
|||
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.classic_bluetooth/events") |
|||
eventChannel.setStreamHandler(object : EventChannel.StreamHandler { |
|||
override fun onListen(arguments: Any?, events: EventSink?) { |
|||
eventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
eventSink = null |
|||
} |
|||
}) |
|||
|
|||
// 初始化蓝牙助手 |
|||
bluetoothHelper = ClassicBluetoothHelper(context) |
|||
bluetoothHelper.setBluetoothEventListener(this) |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"getConnectedDevices" -> { |
|||
if (!bluetoothHelper.hasBluetoothPermission()) { |
|||
if (activity != null) { |
|||
// 保存当前方法调用和结果,以便权限授予后继续执行 |
|||
pendingMethodCall = call |
|||
pendingResult = result |
|||
requestBluetoothPermissions() |
|||
} else { |
|||
result.error("PERMISSION_DENIED", "缺少必要的蓝牙权限,且无法请求权限", null) |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 获取已连接的所有蓝牙设备 |
|||
val devices = bluetoothHelper.getConnectedBluetoothDevices() |
|||
result.success(devices) |
|||
} |
|||
"isBluetoothEnabled" -> { |
|||
val isEnabled = bluetoothHelper.isBluetoothEnabled() |
|||
result.success(isEnabled) |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 请求蓝牙权限 |
|||
*/ |
|||
private fun requestBluetoothPermissions() { |
|||
if (activity == null) return |
|||
|
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
|||
ActivityCompat.requestPermissions( |
|||
activity!!, |
|||
arrayOf( |
|||
Manifest.permission.BLUETOOTH_SCAN, |
|||
Manifest.permission.BLUETOOTH_CONNECT |
|||
), |
|||
REQUEST_BLUETOOTH_PERMISSIONS |
|||
) |
|||
} else { |
|||
ActivityCompat.requestPermissions( |
|||
activity!!, |
|||
arrayOf( |
|||
Manifest.permission.BLUETOOTH, |
|||
Manifest.permission.BLUETOOTH_ADMIN |
|||
), |
|||
REQUEST_BLUETOOTH_PERMISSIONS |
|||
) |
|||
} |
|||
} |
|||
|
|||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean { |
|||
if (requestCode == REQUEST_BLUETOOTH_PERMISSIONS) { |
|||
// 检查是否有足够的权限 |
|||
val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } |
|||
|
|||
if (granted) { |
|||
// 权限已授予,继续执行挂起的方法调用 |
|||
pendingMethodCall?.let { call -> |
|||
pendingResult?.let { result -> |
|||
onMethodCall(call, result) |
|||
pendingMethodCall = null |
|||
pendingResult = null |
|||
} |
|||
} |
|||
} else { |
|||
// 权限被拒绝 |
|||
pendingResult?.error("PERMISSION_DENIED", "蓝牙权限被拒绝", null) |
|||
pendingMethodCall = null |
|||
pendingResult = null |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
binding.addRequestPermissionsResultListener(this) |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
activity = null |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
binding.addRequestPermissionsResultListener(this) |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
activity = null |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
eventChannel.setStreamHandler(null) |
|||
|
|||
// 释放资源 |
|||
bluetoothHelper.setBluetoothEventListener(null) |
|||
bluetoothHelper.dispose() |
|||
} |
|||
|
|||
override fun onBluetoothEvent(event: Map<String, Any>) { |
|||
if (eventSink == null) return |
|||
|
|||
try { |
|||
activity?.runOnUiThread { |
|||
try { |
|||
eventSink?.success(event) |
|||
} catch (e: Exception) { |
|||
// 忽略异常 |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
// 忽略异常 |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
import 'dart:async'; |
|||
|
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// 经典蓝牙设备信息 |
|||
class BluetoothDevice { |
|||
/// 设备名称 |
|||
final String name; |
|||
|
|||
/// 设备地址 |
|||
final String address; |
|||
|
|||
/// 设备类型 |
|||
final String type; |
|||
|
|||
/// 构造函数 |
|||
BluetoothDevice({ |
|||
required this.name, |
|||
required this.address, |
|||
required this.type, |
|||
}); |
|||
|
|||
/// 从Map创建设备信息 |
|||
factory BluetoothDevice.fromMap(Map<String, dynamic> map) { |
|||
return BluetoothDevice( |
|||
name: map['name'] as String? ?? '未知设备', |
|||
address: map['address'] as String? ?? '', |
|||
type: map['type'] as String? ?? '未知类型', |
|||
); |
|||
} |
|||
|
|||
/// 转换为Map |
|||
Map<String, dynamic> toMap() { |
|||
return { |
|||
'name': name, |
|||
'address': address, |
|||
'type': type, |
|||
}; |
|||
} |
|||
|
|||
@override |
|||
String toString() => '$name ($address) [$type]'; |
|||
} |
|||
|
|||
/// 蓝牙事件 |
|||
class BluetoothEvent { |
|||
/// 事件类型 |
|||
final String type; |
|||
|
|||
/// 时间戳 |
|||
final int timestamp; |
|||
|
|||
/// 事件数据 |
|||
final Map<String, dynamic> data; |
|||
|
|||
/// 构造函数 |
|||
BluetoothEvent({ |
|||
required this.type, |
|||
required this.timestamp, |
|||
required this.data, |
|||
}); |
|||
|
|||
/// 从Map创建事件 |
|||
factory BluetoothEvent.fromMap(Map<dynamic, dynamic> map) { |
|||
return BluetoothEvent( |
|||
type: map['type'] as String, |
|||
timestamp: map['timestamp'] as int, |
|||
data: Map<String, dynamic>.from(map) |
|||
..remove('type') |
|||
..remove('timestamp'), |
|||
); |
|||
} |
|||
|
|||
/// 获取关联的设备(如果有) |
|||
BluetoothDevice? get device { |
|||
if (data.containsKey('device')) { |
|||
return BluetoothDevice.fromMap(Map<String, dynamic>.from(data['device'] as Map)); |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
/// 获取蓝牙状态(如果是状态变化事件) |
|||
String? get bluetoothState { |
|||
if (type == 'bluetoothStateChanged') { |
|||
return data['state'] as String?; |
|||
} |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/// 经典蓝牙插件类 |
|||
class ClassicBluetooth { |
|||
static const MethodChannel _methodChannel = |
|||
MethodChannel('com.yunqiinnovation.classic_bluetooth/methods'); |
|||
static const EventChannel _eventChannel = |
|||
EventChannel('com.yunqiinnovation.classic_bluetooth/events'); |
|||
|
|||
static Stream<BluetoothEvent>? _eventStream; |
|||
|
|||
/// 获取蓝牙事件流 |
|||
static Stream<BluetoothEvent> get events { |
|||
_eventStream ??= _eventChannel.receiveBroadcastStream() |
|||
.map<BluetoothEvent>((dynamic event) => BluetoothEvent.fromMap(Map<dynamic, dynamic>.from(event as Map))); |
|||
return _eventStream!; |
|||
} |
|||
|
|||
/// 获取当前连接的蓝牙设备 |
|||
static Future<List<BluetoothDevice>> getConnectedDevices() async { |
|||
final List<dynamic> devices = await _methodChannel.invokeMethod('getConnectedDevices'); |
|||
return devices |
|||
.map((device) => BluetoothDevice.fromMap(Map<String, dynamic>.from(device as Map))) |
|||
.toList(); |
|||
} |
|||
|
|||
/// 检查蓝牙是否启用 |
|||
static Future<bool> isBluetoothEnabled() async { |
|||
return await _methodChannel.invokeMethod('isBluetoothEnabled'); |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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") |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
rootProject.name = "deepvoice_utils" |
|||
@ -0,0 +1,7 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.deepvoice_utils"> |
|||
|
|||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |
|||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |
|||
</manifest> |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -1,4 +1,4 @@ |
|||
package com.yunqiinnovation.deepsound.core.utils |
|||
package com.yunqiinnovation.deepvoice_utils |
|||
|
|||
import android.content.Context |
|||
import android.util.Log |
|||
@ -0,0 +1,3 @@ |
|||
library deepvoice_utils; |
|||
|
|||
export '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()); |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 |
|||
|
|||
@ -1,863 +0,0 @@ |
|||
package com.csdcorp.speech_to_text |
|||
|
|||
import android.Manifest |
|||
import android.R.attr.data |
|||
import android.annotation.TargetApi |
|||
import android.app.Activity |
|||
import android.bluetooth.BluetoothAdapter |
|||
import android.bluetooth.BluetoothHeadset |
|||
import android.bluetooth.BluetoothProfile |
|||
import android.content.BroadcastReceiver |
|||
import android.content.ComponentName |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.pm.PackageManager |
|||
import android.content.pm.ResolveInfo |
|||
import android.net.Uri |
|||
import android.os.Build |
|||
import android.os.Bundle |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.speech.* |
|||
import android.speech.SpeechRecognizer.createOnDeviceSpeechRecognizer |
|||
import android.speech.SpeechRecognizer.createSpeechRecognizer |
|||
import android.util.Log |
|||
import androidx.annotation.NonNull |
|||
import androidx.core.app.ActivityCompat |
|||
import androidx.core.content.ContextCompat |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityAware |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding |
|||
import io.flutter.plugin.common.BinaryMessenger |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import io.flutter.plugin.common.PluginRegistry |
|||
import io.flutter.plugin.common.PluginRegistry.Registrar |
|||
import org.json.JSONArray |
|||
import org.json.JSONObject |
|||
import java.util.* |
|||
import java.util.concurrent.Executors |
|||
|
|||
|
|||
enum class SpeechToTextErrors { |
|||
multipleRequests, |
|||
unimplemented, |
|||
noLanguageIntent, |
|||
recognizerNotAvailable, |
|||
missingOrInvalidArg, |
|||
missingContext, |
|||
unknown |
|||
} |
|||
|
|||
enum class SpeechToTextCallbackMethods { |
|||
textRecognition, |
|||
notifyStatus, |
|||
notifyError, |
|||
soundLevelChange, |
|||
} |
|||
|
|||
enum class SpeechToTextStatus { |
|||
listening, |
|||
notListening, |
|||
unavailable, |
|||
available, |
|||
done, |
|||
doneNoResult, |
|||
} |
|||
|
|||
enum class ListenMode { |
|||
deviceDefault, |
|||
dictation, |
|||
search, |
|||
confirmation, |
|||
} |
|||
|
|||
const val pluginChannelName = "plugin.csdcorp.com/speech_to_text" |
|||
|
|||
@TargetApi(8) |
|||
/** SpeechToTextPlugin */ |
|||
public class SpeechToTextPlugin : |
|||
MethodCallHandler, RecognitionListener, |
|||
PluginRegistry.RequestPermissionsResultListener, FlutterPlugin, |
|||
ActivityAware { |
|||
private var pluginContext: Context? = null |
|||
private var channel: MethodChannel? = null |
|||
private val minSdkForSpeechSupport = 21 |
|||
private val brokenStopSdk = 29 |
|||
private val minSdkForOnDeviceSpeechSupport = 31 |
|||
private val speechToTextPermissionCode = 28521 |
|||
private val missingConfidence: Double = -1.0 |
|||
private var speechThresholdRms = 9 |
|||
private val logTag = "SpeechToTextPlugin" |
|||
private var recognizerStops = true |
|||
private var currentActivity: Activity? = null |
|||
private var activeResult: Result? = null |
|||
private var initializedSuccessfully: Boolean = false |
|||
private var permissionToRecordAudio: Boolean = false |
|||
private var listening = false |
|||
private var debugLogging: Boolean = false |
|||
private var alwaysUseStop: Boolean = false |
|||
private var intentLookup: Boolean = false |
|||
private var noBluetoothOpt: Boolean = false // user-defined option |
|||
private var bluetoothDisabled = true // final bluetooth state (combines user-defined option and permissions) |
|||
private var resultSent: Boolean = false |
|||
private var lastOnDevice: Boolean = false |
|||
private var speechRecognizer: SpeechRecognizer? = null |
|||
private var recognizerIntent: Intent? = null |
|||
private var bluetoothAdapter: android.bluetooth.BluetoothAdapter? = null |
|||
private var pairedDevices: Set<android.bluetooth.BluetoothDevice>? = null |
|||
private var activeBluetooth: android.bluetooth.BluetoothDevice? = null |
|||
private var bluetoothHeadset: BluetoothHeadset? = null |
|||
private var previousRecognizerLang: String? = null |
|||
private var previousPartialResults: Boolean = true |
|||
private var previousListenMode: ListenMode = ListenMode.deviceDefault |
|||
private var lastFinalTime: Long = 0 |
|||
private var speechStartTime: Long = 0 |
|||
private var minRms: Float = 1000.0F |
|||
private var maxRms: Float = -100.0F |
|||
private val handler: Handler = Handler(Looper.getMainLooper()) |
|||
private val defaultLanguageTag: String = Locale.getDefault().toLanguageTag() |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
|
|||
onAttachedToEngine(flutterPluginBinding.getApplicationContext(), flutterPluginBinding.getBinaryMessenger()); |
|||
} |
|||
|
|||
// This static function is optional and equivalent to onAttachedToEngine. It supports the old |
|||
// pre-Flutter-1.12 Android projects. You are encouraged to continue supporting |
|||
// plugin registration via this function while apps migrate to use the new Android APIs |
|||
// post-flutter-1.12 via https://flutter.dev/go/android-project-migration. |
|||
// |
|||
// It is encouraged to share logic between onAttachedToEngine and registerWith to keep |
|||
// them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called |
|||
// depending on the user's project. onAttachedToEngine or registerWith must both be defined |
|||
// in the same class. |
|||
companion object { |
|||
@JvmStatic |
|||
fun registerWith(registrar: Registrar) { |
|||
val speechPlugin = SpeechToTextPlugin() |
|||
speechPlugin.currentActivity = registrar.activity() |
|||
registrar.addRequestPermissionsResultListener(speechPlugin) |
|||
speechPlugin.onAttachedToEngine(registrar.context(), registrar.messenger()) |
|||
} |
|||
} |
|||
|
|||
private fun onAttachedToEngine(applicationContext: Context, messenger: BinaryMessenger) { |
|||
this.pluginContext = applicationContext; |
|||
channel = MethodChannel(messenger, pluginChannelName) |
|||
channel?.setMethodCallHandler(this) |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
this.pluginContext = null; |
|||
channel?.setMethodCallHandler(null) |
|||
channel = null |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
currentActivity = null |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
currentActivity = binding.activity |
|||
binding.addRequestPermissionsResultListener(this) |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
currentActivity = binding.activity |
|||
binding.addRequestPermissionsResultListener(this) |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
currentActivity = null |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull rawrResult: Result) { |
|||
val result = ChannelResultWrapper(rawrResult) |
|||
try { |
|||
when (call.method) { |
|||
"has_permission" -> hasPermission(result) |
|||
"initialize" -> { |
|||
var dlog = call.argument<Boolean>("debugLogging") |
|||
if (null != dlog) { |
|||
debugLogging = dlog |
|||
} |
|||
var ausOpt = call.argument<Boolean>("alwaysUseStop") |
|||
if (null != ausOpt) { |
|||
alwaysUseStop = ausOpt == true |
|||
} |
|||
var iOpt = call.argument<Boolean>("intentLookup") |
|||
if (null != iOpt) { |
|||
intentLookup = iOpt == true |
|||
} |
|||
var noBtOpt = call.argument<Boolean>("noBluetooth") |
|||
if (null != noBtOpt) { |
|||
noBluetoothOpt = noBtOpt == true |
|||
} |
|||
initialize(result) |
|||
} |
|||
"listen" -> { |
|||
var localeId = call.argument<String>("localeId") |
|||
if (null == localeId) { |
|||
localeId = defaultLanguageTag |
|||
} |
|||
var partialResults = call.argument<Boolean>("partialResults") |
|||
if (null == partialResults) { |
|||
partialResults = true |
|||
} |
|||
var onDevice = call.argument<Boolean>("onDevice") |
|||
if ( null == onDevice ) { |
|||
onDevice = false |
|||
} |
|||
val listenModeIndex = call.argument<Int>("listenMode") |
|||
if ( null == listenModeIndex ) { |
|||
result.error(SpeechToTextErrors.missingOrInvalidArg.name, |
|||
"listenMode is required", null) |
|||
return |
|||
} |
|||
startListening(result, localeId, partialResults, listenModeIndex, onDevice ) |
|||
} |
|||
"stop" -> stopListening(result) |
|||
"cancel" -> cancelListening(result) |
|||
"locales" -> locales(result) |
|||
else -> result.notImplemented() |
|||
} |
|||
} catch (exc: Exception) { |
|||
Log.e(logTag, "Unexpected exception", exc) |
|||
result.error(SpeechToTextErrors.unknown.name, |
|||
"Unexpected exception", exc.localizedMessage) |
|||
} |
|||
} |
|||
|
|||
private fun hasPermission(result: Result) { |
|||
if (sdkVersionTooLow()) { |
|||
result.success(false) |
|||
return |
|||
} |
|||
debugLog("Start has_permission") |
|||
val localContext = pluginContext |
|||
if (localContext != null) { |
|||
val hasPerm = ContextCompat.checkSelfPermission(localContext, |
|||
Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED |
|||
result.success(hasPerm) |
|||
} |
|||
} |
|||
|
|||
private fun initialize(result: Result) { |
|||
if (sdkVersionTooLow()) { |
|||
result.success(false) |
|||
return |
|||
} |
|||
recognizerStops = Build.VERSION.SDK_INT != brokenStopSdk || alwaysUseStop |
|||
debugLog("Start initialize") |
|||
if (null != activeResult) { |
|||
result.error(SpeechToTextErrors.multipleRequests.name, |
|||
"Only one initialize at a time", null) |
|||
return |
|||
} |
|||
activeResult = result |
|||
initializeIfPermitted(pluginContext) |
|||
} |
|||
|
|||
private fun sdkVersionTooLow(): Boolean { |
|||
if (Build.VERSION.SDK_INT < minSdkForSpeechSupport) { |
|||
return true; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private fun isNotInitialized(): Boolean { |
|||
return !initializedSuccessfully |
|||
} |
|||
|
|||
private fun isListening(): Boolean { |
|||
return listening |
|||
} |
|||
|
|||
private fun isNotListening(): Boolean { |
|||
return !listening |
|||
} |
|||
|
|||
private fun startListening(result: Result, languageTag: String, partialResults: Boolean, |
|||
listenModeIndex: Int, onDevice: Boolean) { |
|||
if (sdkVersionTooLow() || isNotInitialized() || isListening()) { |
|||
result.success(false) |
|||
return |
|||
} |
|||
resultSent = false |
|||
createRecognizer(onDevice) |
|||
minRms = 1000.0F |
|||
maxRms = -100.0F |
|||
debugLog("Start listening") |
|||
var listenMode = ListenMode.deviceDefault |
|||
if ( listenModeIndex == ListenMode.dictation.ordinal) { |
|||
listenMode = ListenMode.dictation |
|||
} |
|||
optionallyStartBluetooth() |
|||
setupRecognizerIntent(languageTag, partialResults, listenMode, onDevice ) |
|||
handler.post { |
|||
run { |
|||
speechRecognizer?.startListening(recognizerIntent) |
|||
} |
|||
} |
|||
speechStartTime = System.currentTimeMillis() |
|||
notifyListening(isRecording = true) |
|||
result.success(true) |
|||
debugLog("Start listening done") |
|||
} |
|||
|
|||
private fun optionallyStartBluetooth() { |
|||
if ( bluetoothDisabled ) return |
|||
val context = pluginContext |
|||
val lbt = bluetoothAdapter |
|||
val lpaired = pairedDevices |
|||
val lhead = bluetoothHeadset |
|||
if (null != lbt && null!= lhead && null != lpaired && lbt.isEnabled) { |
|||
for (tryDevice in lpaired) { |
|||
//This loop tries to start VoiceRecognition mode on every paired device until it finds one that works(which will be the currently in use bluetooth headset) |
|||
if (lhead.startVoiceRecognition(tryDevice)) { |
|||
debugLog("Starting bluetooth voice recognition") |
|||
activeBluetooth = tryDevice; |
|||
break |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun stopListening(result: Result) { |
|||
if (sdkVersionTooLow() || isNotInitialized() || isNotListening()) { |
|||
result.success(false) |
|||
return |
|||
} |
|||
debugLog("Stop listening") |
|||
handler.post { |
|||
run { |
|||
speechRecognizer?.stopListening() |
|||
} |
|||
} |
|||
if ( !recognizerStops ) { |
|||
destroyRecognizer() |
|||
} |
|||
notifyListening(isRecording = false) |
|||
result.success(true) |
|||
debugLog("Stop listening done") |
|||
} |
|||
|
|||
private fun cancelListening(result: Result) { |
|||
if (sdkVersionTooLow() || isNotInitialized() || isNotListening()) { |
|||
result.success(false) |
|||
return |
|||
} |
|||
debugLog("Cancel listening") |
|||
handler.post { |
|||
run { |
|||
speechRecognizer?.cancel() |
|||
} |
|||
} |
|||
if ( !recognizerStops ) { |
|||
destroyRecognizer() |
|||
} |
|||
notifyListening(isRecording = false) |
|||
result.success(true) |
|||
debugLog("Cancel listening done") |
|||
} |
|||
|
|||
private fun locales(result: Result) { |
|||
if (sdkVersionTooLow()) { |
|||
result.success(false) |
|||
return |
|||
} |
|||
var hasPermission = ContextCompat.checkSelfPermission(pluginContext!!, |
|||
Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED |
|||
if (Build.VERSION.SDK_INT >= 33 && hasPermission) { |
|||
if ( SpeechRecognizer.isOnDeviceRecognitionAvailable(pluginContext!!)) { |
|||
// after much experimentation this was the only working iteration of the |
|||
// checkRecognitionSupport that works. |
|||
var recognizer = createOnDeviceSpeechRecognizer(pluginContext!!) |
|||
var recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) |
|||
// var recognizer = createSpeechRecognizer(pluginContext!!) |
|||
// var recognizerIntent = Intent(RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS) |
|||
recognizer?.checkRecognitionSupport(recognizerIntent, Executors.newSingleThreadExecutor(), |
|||
object : RecognitionSupportCallback { |
|||
override fun onSupportResult(recognitionSupport: RecognitionSupport) { |
|||
var details = LanguageDetailsChecker( result, debugLogging ) |
|||
details.createResponse(recognitionSupport.supportedOnDeviceLanguages ) |
|||
recognizer?.destroy() |
|||
} |
|||
override fun onError(error: Int) { |
|||
debugLog("error from checkRecognitionSupport: " + error) |
|||
recognizer?.destroy() |
|||
} |
|||
}) |
|||
} |
|||
} else { |
|||
var detailsIntent = RecognizerIntent.getVoiceDetailsIntent(pluginContext) |
|||
if (null == detailsIntent) { |
|||
detailsIntent = Intent(RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS) |
|||
detailsIntent.setPackage("com.google.android.googlequicksearchbox") |
|||
} |
|||
pluginContext?.sendOrderedBroadcast( |
|||
detailsIntent, null, LanguageDetailsChecker(result, debugLogging), |
|||
null, Activity.RESULT_OK, null, null) |
|||
} |
|||
} |
|||
|
|||
private fun notifyListening(isRecording: Boolean ) { |
|||
if ( listening == isRecording ) return; |
|||
listening = isRecording |
|||
val status = when (isRecording) { |
|||
true -> SpeechToTextStatus.listening.name |
|||
false -> SpeechToTextStatus.notListening.name |
|||
} |
|||
debugLog("Notify status:" + status) |
|||
channel?.invokeMethod(SpeechToTextCallbackMethods.notifyStatus.name, status) |
|||
if ( !isRecording ) { |
|||
val doneStatus = when( resultSent) { |
|||
false -> SpeechToTextStatus.doneNoResult.name |
|||
else -> SpeechToTextStatus.done.name |
|||
} |
|||
debugLog("Notify status:" + doneStatus ) |
|||
optionallyStopBluetooth(); |
|||
channel?.invokeMethod(SpeechToTextCallbackMethods.notifyStatus.name, |
|||
doneStatus ) |
|||
} |
|||
} |
|||
|
|||
private fun optionallyStopBluetooth() { |
|||
if ( bluetoothDisabled ) return |
|||
val lactive = activeBluetooth |
|||
val lbt = bluetoothHeadset |
|||
if (null != lactive && null != lbt ) { |
|||
debugLog("Stopping bluetooth voice recognition") |
|||
lbt.stopVoiceRecognition(lactive) |
|||
activeBluetooth = null |
|||
} |
|||
} |
|||
|
|||
private fun updateResults(speechBundle: Bundle?, isFinal: Boolean) { |
|||
if (isDuplicateFinal( isFinal )) { |
|||
debugLog("Discarding duplicate final") |
|||
return |
|||
} |
|||
val userSaid = speechBundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) |
|||
if (null != userSaid && userSaid.isNotEmpty()) { |
|||
val speechResult = JSONObject() |
|||
speechResult.put("finalResult", isFinal) |
|||
val confidence = speechBundle.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES) |
|||
val alternates = JSONArray() |
|||
for (resultIndex in 0..userSaid.size - 1) { |
|||
val speechWords = JSONObject() |
|||
speechWords.put("recognizedWords", userSaid[resultIndex]) |
|||
if (null != confidence && confidence.size >= userSaid.size) { |
|||
speechWords.put("confidence", confidence[resultIndex]) |
|||
} else { |
|||
speechWords.put("confidence", missingConfidence) |
|||
} |
|||
alternates.put(speechWords) |
|||
} |
|||
speechResult.put("alternates", alternates) |
|||
val jsonResult = speechResult.toString() |
|||
debugLog("Calling results callback") |
|||
resultSent = true |
|||
channel?.invokeMethod(SpeechToTextCallbackMethods.textRecognition.name, |
|||
jsonResult) |
|||
} else { |
|||
debugLog("Results null or empty") |
|||
} |
|||
} |
|||
|
|||
private fun isDuplicateFinal( isFinal: Boolean ) : Boolean { |
|||
if ( !isFinal ) { |
|||
return false |
|||
} |
|||
val delta = System.currentTimeMillis() - lastFinalTime |
|||
lastFinalTime = System.currentTimeMillis() |
|||
return delta >= 0 && delta < 100 |
|||
} |
|||
|
|||
private fun initializeIfPermitted(context: Context?) { |
|||
val localContext = context |
|||
if (null == localContext) { |
|||
completeInitialize() |
|||
return |
|||
} |
|||
permissionToRecordAudio = ContextCompat.checkSelfPermission(localContext, |
|||
Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED |
|||
val permissionToEnableBluetooth = ContextCompat.checkSelfPermission(localContext, |
|||
Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED |
|||
bluetoothDisabled = !permissionToEnableBluetooth || noBluetoothOpt |
|||
debugLog("Checked permission") |
|||
if (!permissionToRecordAudio) { |
|||
val localActivity = currentActivity |
|||
if (null != localActivity) { |
|||
debugLog("Requesting permission") |
|||
var requiredPermissions = arrayOf(Manifest.permission.RECORD_AUDIO) |
|||
if ( !noBluetoothOpt ) { |
|||
requiredPermissions = requiredPermissions.plus(Manifest.permission.BLUETOOTH_CONNECT) |
|||
} |
|||
ActivityCompat.requestPermissions(localActivity, requiredPermissions, speechToTextPermissionCode) |
|||
} else { |
|||
debugLog("no permission, no activity, completing") |
|||
completeInitialize() |
|||
} |
|||
} else { |
|||
debugLog("has permission, completing") |
|||
completeInitialize() |
|||
} |
|||
debugLog("leaving initializeIfPermitted") |
|||
} |
|||
|
|||
private fun completeInitialize() { |
|||
|
|||
debugLog("completeInitialize") |
|||
if (permissionToRecordAudio) { |
|||
debugLog("Testing recognition availability") |
|||
val localContext = pluginContext |
|||
if (localContext != null) { |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
|||
if (!SpeechRecognizer.isRecognitionAvailable(localContext) && !SpeechRecognizer.isOnDeviceRecognitionAvailable( |
|||
localContext |
|||
) |
|||
) { |
|||
Log.e(logTag, "Speech recognition not available on this device") |
|||
activeResult?.error( |
|||
SpeechToTextErrors.recognizerNotAvailable.name, |
|||
"Speech recognition not available on this device", "" |
|||
) |
|||
activeResult = null |
|||
return |
|||
} |
|||
} else { |
|||
if (!SpeechRecognizer.isRecognitionAvailable(localContext)) { |
|||
Log.e(logTag, "Speech recognition not available on this device") |
|||
activeResult?.error( |
|||
SpeechToTextErrors.recognizerNotAvailable.name, |
|||
"Speech recognition not available on this device", "" |
|||
) |
|||
activeResult = null |
|||
return |
|||
} |
|||
} |
|||
setupBluetooth() |
|||
} else { |
|||
debugLog("null context during initialization") |
|||
activeResult?.success(false) |
|||
activeResult?.error( |
|||
SpeechToTextErrors.missingContext.name, |
|||
"context unexpectedly null, initialization failed", "") |
|||
activeResult = null |
|||
return |
|||
} |
|||
} |
|||
|
|||
initializedSuccessfully = permissionToRecordAudio |
|||
debugLog("sending result") |
|||
activeResult?.success(permissionToRecordAudio) |
|||
debugLog("leaving complete") |
|||
activeResult = null |
|||
} |
|||
|
|||
private fun setupBluetooth() { |
|||
if ( bluetoothDisabled ) return |
|||
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() |
|||
pairedDevices = bluetoothAdapter?.getBondedDevices() |
|||
|
|||
val mProfileListener: BluetoothProfile.ServiceListener = object : BluetoothProfile.ServiceListener { |
|||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { |
|||
if (profile == BluetoothProfile.HEADSET) { |
|||
bluetoothHeadset = proxy as BluetoothHeadset |
|||
debugLog("Found a headset: " + bluetoothHeadset.toString()) |
|||
} |
|||
} |
|||
|
|||
override fun onServiceDisconnected(profile: Int) { |
|||
if (profile == BluetoothProfile.HEADSET) { |
|||
debugLog("Clearing headset: ") |
|||
bluetoothHeadset = null |
|||
} |
|||
} |
|||
} |
|||
bluetoothAdapter?.getProfileProxy(pluginContext, mProfileListener, BluetoothProfile.HEADSET) |
|||
} |
|||
|
|||
private fun Context.findComponentName(): ComponentName? { |
|||
val list: List<ResolveInfo> = packageManager.queryIntentServices(Intent(RecognitionService.SERVICE_INTERFACE), 0) |
|||
debugLog("RecognitionService, found: ${list.size}") |
|||
list.forEach() { it.serviceInfo?.let { it1 -> debugLog("RecognitionService: packageName: ${it1.packageName}, name: ${it1.name}") } } |
|||
return list.firstOrNull()?.serviceInfo?.let { ComponentName(it.packageName, it.name) } |
|||
} |
|||
|
|||
private fun createRecognizer(onDevice: Boolean) { |
|||
if ( null != speechRecognizer && onDevice == lastOnDevice ) { |
|||
return |
|||
} |
|||
lastOnDevice = onDevice |
|||
speechRecognizer?.destroy() |
|||
speechRecognizer = null |
|||
handler.post { |
|||
run { |
|||
debugLog("Creating recognizer") |
|||
if (intentLookup) { |
|||
speechRecognizer = createSpeechRecognizer( |
|||
pluginContext, |
|||
pluginContext?.findComponentName() |
|||
).apply { |
|||
debugLog("Setting listener after intent lookup") |
|||
setRecognitionListener(this@SpeechToTextPlugin) |
|||
} |
|||
} else { |
|||
var supportsLocal = false |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && onDevice) { |
|||
supportsLocal = SpeechRecognizer.isOnDeviceRecognitionAvailable(pluginContext!!) |
|||
if (supportsLocal ) { |
|||
speechRecognizer = createOnDeviceSpeechRecognizer(pluginContext!!).apply { |
|||
debugLog("Setting on device listener") |
|||
setRecognitionListener(this@SpeechToTextPlugin) |
|||
} |
|||
} |
|||
} |
|||
if ( null == speechRecognizer) { |
|||
speechRecognizer = createSpeechRecognizer(pluginContext).apply { |
|||
debugLog("Setting default listener") |
|||
setRecognitionListener(this@SpeechToTextPlugin) |
|||
} |
|||
} |
|||
} |
|||
if (null == speechRecognizer) { |
|||
Log.e(logTag, "Speech recognizer null") |
|||
activeResult?.error( |
|||
SpeechToTextErrors.recognizerNotAvailable.name, |
|||
"Speech recognizer null", "" |
|||
) |
|||
activeResult = null |
|||
} |
|||
} |
|||
} |
|||
debugLog("before setup intent") |
|||
setupRecognizerIntent(defaultLanguageTag, true, ListenMode.deviceDefault, false ) |
|||
debugLog("after setup intent") |
|||
} |
|||
|
|||
private fun setupRecognizerIntent(languageTag: String, partialResults: Boolean, listenMode: ListenMode, onDevice: Boolean ) { |
|||
debugLog("setupRecognizerIntent") |
|||
if (previousRecognizerLang == null || |
|||
previousRecognizerLang != languageTag || |
|||
partialResults != previousPartialResults || previousListenMode != listenMode ) { |
|||
previousRecognizerLang = languageTag; |
|||
previousPartialResults = partialResults |
|||
previousListenMode = listenMode |
|||
handler.post { |
|||
run { |
|||
recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { |
|||
debugLog("In RecognizerIntent apply") |
|||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) |
|||
debugLog("put model") |
|||
val localContext = pluginContext |
|||
if (null != localContext) { |
|||
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, |
|||
localContext.applicationInfo.packageName) |
|||
} |
|||
debugLog("put package") |
|||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, partialResults) |
|||
debugLog("put partial") |
|||
if (languageTag != Locale.getDefault().toLanguageTag()) { |
|||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageTag); |
|||
debugLog("put languageTag") |
|||
} |
|||
if ( onDevice ) { |
|||
putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, onDevice ); |
|||
} |
|||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS,10) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun destroyRecognizer() { |
|||
|
|||
handler.postDelayed( { |
|||
run { |
|||
debugLog("Recognizer destroy") |
|||
speechRecognizer?.destroy(); |
|||
speechRecognizer = null; |
|||
} |
|||
}, 50 ) |
|||
} |
|||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean { |
|||
when (requestCode) { |
|||
speechToTextPermissionCode -> { |
|||
permissionToRecordAudio = grantResults.isNotEmpty() && |
|||
grantResults[0] == PackageManager.PERMISSION_GRANTED |
|||
bluetoothDisabled = (grantResults.isEmpty() || grantResults.size == 1 || |
|||
grantResults[1] != PackageManager.PERMISSION_GRANTED) || |
|||
noBluetoothOpt |
|||
completeInitialize() |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
|
|||
override fun onPartialResults(results: Bundle?) = updateResults(results, false) |
|||
override fun onResults(results: Bundle?) = updateResults(results, true) |
|||
override fun onEndOfSpeech() = notifyListening(isRecording = false) |
|||
|
|||
override fun onError(errorCode: Int) { |
|||
val delta = System.currentTimeMillis() - speechStartTime |
|||
var errorReturn = errorCode |
|||
if ( SpeechRecognizer.ERROR_NO_MATCH == errorCode && maxRms < speechThresholdRms ) { |
|||
errorReturn = SpeechRecognizer.ERROR_SPEECH_TIMEOUT |
|||
} |
|||
debugLog( "Error $errorCode after start at $delta $minRms / $maxRms") |
|||
val errorMsg = when (errorReturn) { |
|||
SpeechRecognizer.ERROR_AUDIO -> "error_audio_error" |
|||
SpeechRecognizer.ERROR_CLIENT -> "error_client" |
|||
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "error_permission" |
|||
SpeechRecognizer.ERROR_NETWORK -> "error_network" |
|||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "error_network_timeout" |
|||
SpeechRecognizer.ERROR_NO_MATCH -> "error_no_match" |
|||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "error_busy" |
|||
SpeechRecognizer.ERROR_SERVER -> "error_server" |
|||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "error_speech_timeout" |
|||
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "error_language_not_supported" |
|||
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "error_language_unavailable" |
|||
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "error_server_disconnected" |
|||
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "error_too_many_requests" |
|||
else -> "error_unknown ($errorCode)" |
|||
} |
|||
|
|||
sendError(errorMsg) |
|||
if ( isListening()) { |
|||
notifyListening(false) |
|||
} |
|||
} |
|||
|
|||
private fun debugLog( msg: String ) { |
|||
if ( debugLogging ) { |
|||
Log.d( logTag, msg ) |
|||
} |
|||
} |
|||
|
|||
private fun sendError(errorMsg: String) { |
|||
val speechError = JSONObject() |
|||
speechError.put("errorMsg", errorMsg) |
|||
speechError.put("permanent", true) |
|||
handler.post { |
|||
run { |
|||
channel?.invokeMethod(SpeechToTextCallbackMethods.notifyError.name, speechError.toString()) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onRmsChanged(rmsdB: Float) { |
|||
if ( rmsdB < minRms ) { |
|||
minRms = rmsdB |
|||
} |
|||
if ( rmsdB > maxRms ) { |
|||
maxRms = rmsdB |
|||
} |
|||
debugLog("rmsDB $minRms / $maxRms") |
|||
handler.post { |
|||
run { |
|||
channel?.invokeMethod(SpeechToTextCallbackMethods.soundLevelChange.name, rmsdB) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onReadyForSpeech(p0: Bundle?) {} |
|||
override fun onBufferReceived(p0: ByteArray?) {} |
|||
override fun onEvent(p0: Int, p1: Bundle?) {} |
|||
override fun onBeginningOfSpeech() {} |
|||
} |
|||
|
|||
// See https://stackoverflow.com/questions/10538791/how-to-set-the-language-in-speech-recognition-on-android/10548680#10548680 |
|||
class LanguageDetailsChecker(flutterResult: Result, logging: Boolean ) : BroadcastReceiver() { |
|||
private val logTag = "SpeechToTextPlugin" |
|||
private val result: Result = flutterResult |
|||
private val debugLogging: Boolean = logging |
|||
private var supportedLanguages: List<String>? = null |
|||
|
|||
private var languagePreference: String? = null |
|||
|
|||
override fun onReceive(context: Context, intent: Intent) { |
|||
debugLog( "Received extra language broadcast" ) |
|||
val results = getResultExtras(true) |
|||
if (results.containsKey(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE)) { |
|||
languagePreference = results.getString(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE) |
|||
} |
|||
if (results.containsKey(RecognizerIntent.EXTRA_SUPPORTED_LANGUAGES)) { |
|||
debugLog( "Extra supported languages" ) |
|||
supportedLanguages = results.getStringArrayList( |
|||
RecognizerIntent.EXTRA_SUPPORTED_LANGUAGES) |
|||
createResponse(supportedLanguages) |
|||
} |
|||
else { |
|||
debugLog( "No extra supported languages" ) |
|||
createResponse( ArrayList<String>()) |
|||
} |
|||
} |
|||
|
|||
public fun createResponse(supportedLanguages: List<String>?) { |
|||
val currentLocale = Locale.getDefault() |
|||
val localeNames = ArrayList<String>() |
|||
localeNames.add(buildIdNameForLocale(currentLocale)) |
|||
if (null != supportedLanguages) { |
|||
for (lang in supportedLanguages) { |
|||
if (currentLocale.toLanguageTag() == lang) { |
|||
continue |
|||
} |
|||
val locale = Locale.forLanguageTag(lang) |
|||
localeNames.add(buildIdNameForLocale(locale)) |
|||
} |
|||
} |
|||
result.success(localeNames) |
|||
|
|||
} |
|||
|
|||
private fun buildIdNameForLocale(locale: Locale): String { |
|||
val name = locale.displayName.replace(':', ' ') |
|||
return "${locale.language}_${locale.country}:$name" |
|||
} |
|||
|
|||
private fun debugLog( msg: String ) { |
|||
if ( debugLogging ) { |
|||
Log.d( logTag, msg ) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private class ChannelResultWrapper(result: Result) : Result { |
|||
// Caller handler |
|||
val handler: Handler = Handler(Looper.getMainLooper()) |
|||
val result: Result = result |
|||
|
|||
// make sure to respond in the caller thread |
|||
override fun success(results: Any?) { |
|||
|
|||
handler.post { |
|||
run { |
|||
result.success(results); |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun error(errorCode: String, errorMessage: String?, data: Any?) { |
|||
handler.post { |
|||
run { |
|||
result.error(errorCode, errorMessage, data); |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun notImplemented() { |
|||
handler.post { |
|||
run { |
|||
result.notImplemented(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,603 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// ASR 事件类型 |
|||
enum AsrEventType { |
|||
/// 会话开始 |
|||
sessionStarted, |
|||
|
|||
/// 会话结束 |
|||
sessionStopped, |
|||
|
|||
/// 正在识别(中间结果) |
|||
recognizing, |
|||
|
|||
/// 识别结果(最终结果) |
|||
result, |
|||
|
|||
/// 音量变化 |
|||
volumeChanged, |
|||
|
|||
/// 错误 |
|||
error |
|||
} |
|||
|
|||
/// TTS 工作模式 |
|||
enum TtsWorkMode { |
|||
/// 在线合成 |
|||
online, |
|||
|
|||
/// 离线合成 |
|||
offline, |
|||
|
|||
/// 同时在线离线 |
|||
both, |
|||
|
|||
/// 先在线再离线(网络不好时自动切换) |
|||
alternate, |
|||
|
|||
/// 文件模式 |
|||
file |
|||
} |
|||
|
|||
/// TTS 文本类型 |
|||
enum TtsTextType { |
|||
/// 纯文本 |
|||
plain, |
|||
|
|||
/// SSML格式 |
|||
ssml |
|||
} |
|||
|
|||
/// 协议类型 |
|||
enum ProtocolType { |
|||
/// 默认协议 |
|||
defaultProtocol, |
|||
|
|||
/// Seed协议(用于大模型) |
|||
seed |
|||
} |
|||
|
|||
/// TTS 播放进度事件 |
|||
class TtsProgressEvent { |
|||
/// 播放进度 0.0-1.0 |
|||
final double progress; |
|||
|
|||
/// 请求ID |
|||
final String reqId; |
|||
|
|||
const TtsProgressEvent({ |
|||
required this.progress, |
|||
required this.reqId, |
|||
}); |
|||
|
|||
@override |
|||
String toString() { |
|||
return 'TtsProgressEvent{progress: $progress, reqId: $reqId}'; |
|||
} |
|||
} |
|||
|
|||
/// ASR 事件 |
|||
class AsrEvent { |
|||
/// 事件类型 |
|||
final AsrEventType type; |
|||
|
|||
/// 识别文本(仅在recognizing和result类型时有效) |
|||
final String? text; |
|||
|
|||
/// 识别语言(仅在recognizing和result类型时有效) |
|||
final String? language; |
|||
|
|||
/// 音量值(仅在volumeChanged类型时有效) |
|||
final int? volume; |
|||
|
|||
/// 错误信息(仅在error类型时有效) |
|||
final String? errorMessage; |
|||
|
|||
const AsrEvent({ |
|||
required this.type, |
|||
this.text, |
|||
this.language, |
|||
this.volume, |
|||
this.errorMessage, |
|||
}); |
|||
|
|||
factory AsrEvent.fromMap(Map<dynamic, dynamic> map) { |
|||
final typeStr = map['type'] as String; |
|||
|
|||
AsrEventType type; |
|||
switch (typeStr) { |
|||
case 'sessionStarted': |
|||
type = AsrEventType.sessionStarted; |
|||
break; |
|||
case 'sessionStopped': |
|||
type = AsrEventType.sessionStopped; |
|||
break; |
|||
case 'recognizing': |
|||
type = AsrEventType.recognizing; |
|||
break; |
|||
case 'result': |
|||
type = AsrEventType.result; |
|||
break; |
|||
case 'volumeChanged': |
|||
type = AsrEventType.volumeChanged; |
|||
break; |
|||
case 'error': |
|||
type = AsrEventType.error; |
|||
break; |
|||
default: |
|||
throw ArgumentError('未知的事件类型: $typeStr'); |
|||
} |
|||
|
|||
return AsrEvent( |
|||
type: type, |
|||
text: map['text'] as String?, |
|||
language: map['language'] as String?, |
|||
volume: map['volume'] as int?, |
|||
errorMessage: map['message'] as String?, |
|||
); |
|||
} |
|||
|
|||
@override |
|||
String toString() { |
|||
return 'AsrEvent{type: $type, text: $text, language: $language, volume: $volume, errorMessage: $errorMessage}'; |
|||
} |
|||
} |
|||
|
|||
/// 火山引擎语音服务插件 |
|||
class VolcanoSpeech { |
|||
static final VolcanoSpeechAsr asr = VolcanoSpeechAsr._(); |
|||
static final VolcanoSpeechTts tts = VolcanoSpeechTts._(); |
|||
|
|||
/// 释放资源 |
|||
static Future<void> dispose() async { |
|||
await asr.dispose(); |
|||
await tts.dispose(); |
|||
} |
|||
} |
|||
|
|||
/// 火山引擎语音识别服务 |
|||
class VolcanoSpeechAsr { |
|||
static const MethodChannel _channel = MethodChannel('volcano_speech/asr'); |
|||
static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events'); |
|||
|
|||
/// ASR事件流控制器 |
|||
static final StreamController<AsrEvent> _eventStreamController = StreamController<AsrEvent>.broadcast(); |
|||
|
|||
/// ASR事件流 |
|||
Stream<AsrEvent> get events => _eventStreamController.stream; |
|||
|
|||
/// 是否已初始化事件监听 |
|||
bool _eventListenerInitialized = false; |
|||
|
|||
VolcanoSpeechAsr._() { |
|||
_initEventListener(); |
|||
} |
|||
|
|||
/// 初始化ASR事件监听 |
|||
void _initEventListener() { |
|||
if (_eventListenerInitialized) return; |
|||
|
|||
_eventChannel.receiveBroadcastStream().listen((dynamic event) { |
|||
if (event is Map) { |
|||
_eventStreamController.add(AsrEvent.fromMap(event)); |
|||
} |
|||
}); |
|||
|
|||
_eventListenerInitialized = true; |
|||
} |
|||
|
|||
/// 获取平台版本信息 |
|||
Future<String?> getPlatformVersion() async { |
|||
return await _channel.invokeMethod('getPlatformVersion'); |
|||
} |
|||
|
|||
/// 初始化语音识别引擎 |
|||
/// |
|||
/// [appId] 火山引擎AppID |
|||
/// [apiKey] 火山引擎ApiKey |
|||
/// [supportedLanguages] 支持的语言列表 |
|||
/// [useBigModel] 是否使用大模型识别 |
|||
/// [resourceId] 大模型资源ID(仅当useBigModel为true时有效) |
|||
Future<bool> initialize({ |
|||
required String appId, |
|||
required String apiKey, |
|||
List<String> supportedLanguages = const ['zh-CN'], |
|||
bool useBigModel = false, |
|||
String resourceId = '', |
|||
}) async { |
|||
return await _channel.invokeMethod('initialize', { |
|||
'appId': appId, |
|||
'apiKey': apiKey, |
|||
'supportedLanguages': supportedLanguages, |
|||
'useBigModel': useBigModel, |
|||
'resourceId': resourceId, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置识别语言 |
|||
/// |
|||
/// [language] 语言代码,例如 zh-CN、en-US |
|||
Future<bool> setLanguage(String language) async { |
|||
return await _channel.invokeMethod('setLanguage', { |
|||
'language': language, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置热词 |
|||
/// |
|||
/// [hotWords] 热词JSON字符串,例如 {"hotwords":[{"word":"快速入门","scale":2.0}]} |
|||
Future<bool> setHotWords(String hotWords) async { |
|||
return await _channel.invokeMethod('setHotWords', { |
|||
'hotWords': hotWords, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置ASR请求参数 |
|||
/// |
|||
/// [params] 请求参数JSON字符串 |
|||
Future<bool> setRequestParams(String params) async { |
|||
return await _channel.invokeMethod('setRequestParams', { |
|||
'params': params, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 启用语音停顿、分句、分词信息输出 |
|||
/// |
|||
/// [enable] 是否启用 |
|||
Future<bool> setShowUtterances(bool enable) async { |
|||
return await _channel.invokeMethod('setShowUtterances', { |
|||
'enable': enable, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 一次性识别(直到说话结束) |
|||
/// |
|||
/// 返回识别结果文本和语言 |
|||
Future<Map<String, String>> recognizeOnce() async { |
|||
final result = await _channel.invokeMethod('recognizeOnce'); |
|||
return { |
|||
'text': result['text'] ?? '', |
|||
'language': result['language'] ?? 'zh-CN', |
|||
}; |
|||
} |
|||
|
|||
/// 开始连续识别 |
|||
/// |
|||
/// 通过[events]流监听识别结果 |
|||
Future<bool> startContinuousRecognition() async { |
|||
return await _channel.invokeMethod('startContinuousRecognition') ?? false; |
|||
} |
|||
|
|||
/// 停止连续识别 |
|||
Future<bool> stopContinuousRecognition() async { |
|||
return await _channel.invokeMethod('stopContinuousRecognition') ?? false; |
|||
} |
|||
|
|||
/// 检查是否正在连续识别 |
|||
Future<bool> isContinuousRecognitionActive() async { |
|||
return await _channel.invokeMethod('isContinuousRecognitionActive') ?? false; |
|||
} |
|||
|
|||
/// 开始长按识别(按下开始,抬起结束) |
|||
/// |
|||
/// 通过[events]流监听识别结果 |
|||
Future<bool> startListening() async { |
|||
return await _channel.invokeMethod('startListening') ?? false; |
|||
} |
|||
|
|||
/// 停止长按识别 |
|||
Future<bool> stopListening() async { |
|||
return await _channel.invokeMethod('stopListening') ?? false; |
|||
} |
|||
|
|||
/// 释放ASR资源 |
|||
Future<bool> dispose() async { |
|||
return await _channel.invokeMethod('dispose') ?? false; |
|||
} |
|||
} |
|||
|
|||
/// 火山引擎语音合成服务 |
|||
class VolcanoSpeechTts { |
|||
static const MethodChannel _channel = MethodChannel('volcano_speech/tts'); |
|||
static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events'); |
|||
|
|||
/// TTS进度事件流控制器 |
|||
static final StreamController<TtsProgressEvent> _progressStreamController = StreamController<TtsProgressEvent>.broadcast(); |
|||
|
|||
/// TTS进度事件流 |
|||
Stream<TtsProgressEvent> get progressEvents => _progressStreamController.stream; |
|||
|
|||
/// 是否已初始化事件监听 |
|||
bool _eventListenerInitialized = false; |
|||
|
|||
VolcanoSpeechTts._() { |
|||
_initEventListener(); |
|||
} |
|||
|
|||
/// 初始化TTS事件监听 |
|||
void _initEventListener() { |
|||
if (_eventListenerInitialized) return; |
|||
|
|||
_eventChannel.receiveBroadcastStream().listen((dynamic event) { |
|||
if (event is Map) { |
|||
final progress = event['progress'] as double?; |
|||
final reqId = event['reqId'] as String?; |
|||
|
|||
if (progress != null && reqId != null) { |
|||
_progressStreamController.add(TtsProgressEvent( |
|||
progress: progress, |
|||
reqId: reqId, |
|||
)); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
_eventListenerInitialized = true; |
|||
} |
|||
|
|||
/// 获取平台版本信息 |
|||
Future<String?> getPlatformVersion() async { |
|||
return await _channel.invokeMethod('getPlatformVersion'); |
|||
} |
|||
|
|||
/// 启用大模型TTS |
|||
/// |
|||
/// [enable] 是否启用大模型TTS |
|||
/// [resourceId] 资源ID |
|||
Future<bool> enableBigModelTts({ |
|||
required bool enable, |
|||
String resourceId = '', |
|||
}) async { |
|||
return await _channel.invokeMethod('enableBigModelTts', { |
|||
'enable': enable, |
|||
'resourceId': resourceId, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 初始化语音合成引擎 |
|||
/// |
|||
/// [appId] 火山引擎AppID |
|||
/// [apiKey] 火山引擎ApiKey |
|||
Future<bool> initialize({ |
|||
required String appId, |
|||
required String apiKey, |
|||
}) async { |
|||
return await _channel.invokeMethod('initialize', { |
|||
'appId': appId, |
|||
'apiKey': apiKey, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置音色 |
|||
/// |
|||
/// [voiceName] 音色名称 |
|||
/// [voiceType] 音色类型,默认 qingxin |
|||
Future<bool> setVoice({ |
|||
required String voiceName, |
|||
String voiceType = 'qingxin', |
|||
}) async { |
|||
return await _channel.invokeMethod('setVoice', { |
|||
'voiceName': voiceName, |
|||
'voiceType': voiceType, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置离线音色 |
|||
/// |
|||
/// [voiceName] 音色名称 |
|||
/// [voiceType] 音色类型,默认 qingxin |
|||
Future<bool> setOfflineVoice({ |
|||
required String voiceName, |
|||
String voiceType = 'qingxin', |
|||
}) async { |
|||
return await _channel.invokeMethod('setOfflineVoice', { |
|||
'voiceName': voiceName, |
|||
'voiceType': voiceType, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置大模型声音ID |
|||
/// |
|||
/// [voiceId] 声音ID |
|||
Future<bool> setBigModelVoiceId(String voiceId) async { |
|||
return await _channel.invokeMethod('setBigModelVoiceId', { |
|||
'voiceId': voiceId, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置大模型请求参数 |
|||
/// |
|||
/// [params] 请求参数,JSON字符串 |
|||
Future<bool> setBigModelRequestParams(String params) async { |
|||
return await _channel.invokeMethod('setBigModelRequestParams', { |
|||
'params': params, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置工作模式 |
|||
/// |
|||
/// [mode] 工作模式 |
|||
Future<bool> setWorkMode(TtsWorkMode mode) async { |
|||
String modeStr; |
|||
switch (mode) { |
|||
case TtsWorkMode.online: |
|||
modeStr = 'online'; |
|||
break; |
|||
case TtsWorkMode.offline: |
|||
modeStr = 'offline'; |
|||
break; |
|||
case TtsWorkMode.both: |
|||
modeStr = 'both'; |
|||
break; |
|||
case TtsWorkMode.alternate: |
|||
modeStr = 'alternate'; |
|||
break; |
|||
case TtsWorkMode.file: |
|||
modeStr = 'file'; |
|||
break; |
|||
} |
|||
|
|||
return await _channel.invokeMethod('setWorkMode', { |
|||
'mode': modeStr, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置文本类型 |
|||
/// |
|||
/// [type] 文本类型,plain或ssml |
|||
Future<bool> setTextType(TtsTextType type) async { |
|||
String typeStr; |
|||
switch (type) { |
|||
case TtsTextType.plain: |
|||
typeStr = 'plain'; |
|||
break; |
|||
case TtsTextType.ssml: |
|||
typeStr = 'ssml'; |
|||
break; |
|||
} |
|||
|
|||
return await _channel.invokeMethod('setTextType', { |
|||
'type': typeStr, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置是否启用缓存 |
|||
/// |
|||
/// [enable] 是否启用 |
|||
Future<bool> setEnableCache(bool enable) async { |
|||
return await _channel.invokeMethod('setEnableCache', { |
|||
'enable': enable, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置情感 |
|||
/// |
|||
/// [emotion] 情感,例如 neutral、happy、angry、sad等 |
|||
Future<bool> setEmotion(String emotion) async { |
|||
return await _channel.invokeMethod('setEmotion', { |
|||
'emotion': emotion, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置是否启用情感预测 |
|||
/// |
|||
/// [enable] 是否启用 |
|||
Future<bool> setEnableEmotionPredict(bool enable) async { |
|||
return await _channel.invokeMethod('setEnableEmotionPredict', { |
|||
'enable': enable, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置是否启用声音克隆 |
|||
/// |
|||
/// [enable] 是否启用 |
|||
/// [backendCluster] 后端集群 |
|||
Future<bool> setEnableVoiceClone({ |
|||
required bool enable, |
|||
String backendCluster = '', |
|||
}) async { |
|||
return await _channel.invokeMethod('setEnableVoiceClone', { |
|||
'enable': enable, |
|||
'backendCluster': backendCluster, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置是否启用回声消除 |
|||
/// |
|||
/// [enable] 是否启用 |
|||
Future<bool> setEnableAEC(bool enable) async { |
|||
return await _channel.invokeMethod('setEnableAEC', { |
|||
'enable': enable, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 下载离线资源 |
|||
/// |
|||
/// [voiceTypes] 音色类型列表 |
|||
/// [languages] 语言列表 |
|||
Future<bool> downloadOfflineResource({ |
|||
List<String> voiceTypes = const ['qingxin'], |
|||
List<String> languages = const ['zh-CN'], |
|||
}) async { |
|||
return await _channel.invokeMethod('downloadOfflineResource', { |
|||
'voiceTypes': voiceTypes, |
|||
'languages': languages, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置语音参数 |
|||
/// |
|||
/// [rate] 语速 -500~500 |
|||
/// [volume] 音量 0~100 |
|||
/// [pitch] 音调 -500~500 |
|||
/// [silenceDuration] 静音时长,毫秒 |
|||
Future<bool> setSpeechParams({ |
|||
int rate = 0, |
|||
int volume = 100, |
|||
int pitch = 0, |
|||
int silenceDuration = 0, |
|||
}) async { |
|||
return await _channel.invokeMethod('setSpeechParams', { |
|||
'rate': rate, |
|||
'volume': volume, |
|||
'pitch': pitch, |
|||
'silenceDuration': silenceDuration, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 设置音频输出类型 |
|||
/// |
|||
/// [outputType] 输出类型,speaker(扬声器),earpiece(听筒),auto(自动) |
|||
Future<bool> setAudioOutputType(String outputType) async { |
|||
return await _channel.invokeMethod('setAudioOutputType', { |
|||
'outputType': outputType, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 合成并播放文本 |
|||
/// |
|||
/// [text] 待合成的文本 |
|||
Future<bool> speakText(String text) async { |
|||
return await _channel.invokeMethod('speakText', { |
|||
'text': text, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 使用大模型合成并播放文本 |
|||
/// |
|||
/// [text] 待合成的文本 |
|||
/// [voiceId] 声音ID |
|||
/// [params] 额外参数,JSON字符串 |
|||
Future<bool> speakWithBigModel({ |
|||
required String text, |
|||
required String voiceId, |
|||
String params = '', |
|||
}) async { |
|||
return await _channel.invokeMethod('speakWithBigModel', { |
|||
'text': text, |
|||
'voiceId': voiceId, |
|||
'params': params, |
|||
}) ?? false; |
|||
} |
|||
|
|||
/// 暂停播放 |
|||
Future<bool> pausePlayback() async { |
|||
return await _channel.invokeMethod('pausePlayback') ?? false; |
|||
} |
|||
|
|||
/// 恢复播放 |
|||
Future<bool> resumePlayback() async { |
|||
return await _channel.invokeMethod('resumePlayback') ?? false; |
|||
} |
|||
|
|||
/// 停止播放 |
|||
Future<bool> stopSpeaking() async { |
|||
return await _channel.invokeMethod('stopSpeaking') ?? false; |
|||
} |
|||
|
|||
/// 释放TTS资源 |
|||
Future<bool> dispose() async { |
|||
return await _channel.invokeMethod('dispose') ?? false; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue