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