131 changed files with 9259 additions and 5689 deletions
File diff suppressed because it is too large
@ -1,110 +1,211 @@ |
|||
// import 'dart:ffi'; // Not supported on web; not needed here |
|||
import 'dart:async'; |
|||
|
|||
import '/data/models/appconfig_model.dart'; |
|||
import 'package:device_jieli/device_jieli.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
|
|||
import '/core/utils/logger.dart'; |
|||
import '/data/models/user_Info.dart'; |
|||
import '/data/services/ble_manager.dart'; |
|||
import '/data/services/network/api.dart'; |
|||
import '/routes/app_routes.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
/* |
|||
设备管理 |
|||
*/ |
|||
/// 设备页控制器:扫描杰理设备、点击连接、断开。 |
|||
/// 旧的绑定 / 解绑流程已下线。 |
|||
class DevicesController extends GetxController { |
|||
late final BleManager _bleManager; |
|||
|
|||
// 修改为响应式变量 |
|||
final RxMap<int, List<UserDevice>> devices = <int, List<UserDevice>>{}.obs; |
|||
// 扫描结果(直接绑定到 BleManager 内部 Rx 列表) |
|||
RxList<JieliDevice> get scannedDevices => _bleManager.scannedDevices; |
|||
RxBool get isScanning => _bleManager.isScanning; |
|||
|
|||
// 当前连接地址(响应式) |
|||
RxInt get connectionStateRx => _bleManager.connectionStateRx; |
|||
|
|||
// 或者使用这种方式 |
|||
// final Rx<Map<int, List<UserDevice>>> devices = Rx<Map<int, List<UserDevice>>>({}); |
|||
String? get currentDeviceAddress => _bleManager.currentDeviceAddress; |
|||
|
|||
// 正在连接的目标地址(用于 tile UI 反馈) |
|||
final RxnString connectingAddress = RxnString(); |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
print("初始化设备管理1"); |
|||
_bleManager = Get.find<BleManager>(); |
|||
print("初始化设备管理2"); |
|||
// 在初始化时加载设备数据 |
|||
_loadDevices(); |
|||
Future.microtask(startScan); |
|||
} |
|||
|
|||
// 加载设备数据 |
|||
void _loadDevices() async { |
|||
Logger.info("加载设备"); |
|||
final response = await Api.getdevices(); |
|||
if (response != null) { |
|||
if (response['devices'] != null) { |
|||
var devices = (response['devices'] as List<dynamic>) |
|||
.map((e) => UserDevice.fromJson(e as Map<String, dynamic>)) |
|||
.toList(); |
|||
User.instance.devices = devices; |
|||
} else { |
|||
User.instance.devices = []; |
|||
} |
|||
Logger.info("加载设备 ${User.instance.devices.length}"); |
|||
@override |
|||
void onClose() { |
|||
_bleManager.stopScan(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
Future<void> startScan() async { |
|||
print('[BLE] [SCAN] DevicesController.startScan 被调用'); |
|||
final granted = await _ensureBluetoothPermission(); |
|||
print('[BLE] [SCAN] 权限申请结果: granted=$granted'); |
|||
if (!granted) { |
|||
Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr); |
|||
return; |
|||
} |
|||
devices.clear(); |
|||
for (var device in User.instance.devices) { |
|||
if (!devices.containsKey(device.devicetype)) { |
|||
devices[device.devicetype] = []; |
|||
} |
|||
devices[device.devicetype]!.add(device); |
|||
final ok = await _bleManager.startScan(); |
|||
if (!ok) { |
|||
final err = _bleManager.lastScanError ?? 'unknown'; |
|||
print('[BLE] [SCAN] 扫描启动失败: $err'); |
|||
Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err'); |
|||
} |
|||
devices.refresh(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
super.onClose(); |
|||
// onClose 中不应该修改数据,这里应该是清理资源 |
|||
Future<void> stopScan() async { |
|||
await _bleManager.stopScan(); |
|||
} |
|||
|
|||
void unbindearphone(UserDevice device) async { |
|||
final response = await Api.unbinddevice({ |
|||
"id": device.id, |
|||
}); |
|||
Logger.info("解绑设备 $response"); |
|||
if (response != null) { |
|||
// 响应式更新 |
|||
User.instance.devices.remove(device); |
|||
devices.clear(); |
|||
for (var device in User.instance.devices) { |
|||
if (!devices.containsKey(device.devicetype)) { |
|||
devices[device.devicetype] = []; |
|||
Future<void> connect(JieliDevice device) async { |
|||
if (connectingAddress.value != null) { |
|||
Logger.info('连接进行中,忽略重复点击: ${device.address}'); |
|||
return; |
|||
} |
|||
// 已连上同一台设备 → 直接进首页,避免卡在「连接中」对话框 |
|||
if (_bleManager.isConnected && |
|||
_bleManager.currentDeviceAddress == device.address) { |
|||
Logger.info('设备已处于连接状态,直接进入首页: ${device.address}'); |
|||
Get.offAllNamed(Routes.home); |
|||
return; |
|||
} |
|||
|
|||
Logger.info('连接设备: ${device.name} ${device.address}'); |
|||
connectingAddress.value = device.address; |
|||
await _bleManager.stopScan(); |
|||
|
|||
// 必须 BLE 连接 OK + RCSP init 成功 才算「真正连上」。 |
|||
// 任一环节失败立即判失败,避免 dialog 关掉之后又被断开。 |
|||
final completer = Completer<bool>(); |
|||
bool gotConnectionOk = false; |
|||
|
|||
final evtSub = Jielihome.instance.events.listen((evt) { |
|||
if (evt is ConnectionStateEvent) { |
|||
if (evt.address != device.address) return; |
|||
if (evt.state == ConnectionStateEvent.connectionOk) { |
|||
gotConnectionOk = true; |
|||
} else if (evt.state == ConnectionStateEvent.connectionDisconnect) { |
|||
if (!completer.isCompleted) completer.complete(false); |
|||
} |
|||
} else if (evt is RcspInitEvent) { |
|||
if (evt.address != device.address) return; |
|||
if (evt.success && gotConnectionOk) { |
|||
if (!completer.isCompleted) completer.complete(true); |
|||
} else if (!evt.success) { |
|||
if (!completer.isCompleted) completer.complete(false); |
|||
} |
|||
devices[device.devicetype]!.add(device); |
|||
} |
|||
devices.refresh(); |
|||
Logger.info( |
|||
"解绑设备 ${device.devicemac} 当前使用设备:${_bleManager.currentDeviceAddress}"); |
|||
if (device.devicemac == _bleManager.currentDeviceAddress) { |
|||
Logger.info("解绑设备 $device 重新匹配!"); |
|||
User.instance.removeUserDevice(device.id); |
|||
_bleManager.clearPairedDevices(); |
|||
_bleManager.disconnect(); |
|||
await Future.delayed(const Duration(seconds: 1)); |
|||
Get.toNamed(Routes.pairing); |
|||
}); |
|||
|
|||
_showConnectingDialog(device); |
|||
|
|||
try { |
|||
await _bleManager.connectDevice(device); |
|||
final ok = await completer.future |
|||
.timeout(const Duration(seconds: 20), onTimeout: () => false); |
|||
|
|||
if (Get.isDialogOpen ?? false) Get.back(); |
|||
|
|||
if (ok) { |
|||
Get.offAllNamed(Routes.home); |
|||
} else { |
|||
Get.snackbar('error'.tr, 'connectionFailure'.tr); |
|||
} |
|||
return; |
|||
} else { |
|||
return; |
|||
} catch (e) { |
|||
Logger.error('连接异常: $e'); |
|||
if (Get.isDialogOpen ?? false) Get.back(); |
|||
Get.snackbar('error'.tr, 'connectionFailure'.tr); |
|||
} finally { |
|||
await evtSub.cancel(); |
|||
connectingAddress.value = null; |
|||
} |
|||
} |
|||
|
|||
// 添加设备 |
|||
void addDevice(UserDevice device) { |
|||
if (!devices.containsKey(device.devicetype)) { |
|||
devices[device.devicetype] = []; |
|||
} |
|||
devices[device.devicetype]!.add(device); |
|||
devices.refresh(); // 触发UI更新 |
|||
void _showConnectingDialog(JieliDevice device) { |
|||
Get.dialog( |
|||
PopScope( |
|||
canPop: false, |
|||
child: Dialog( |
|||
backgroundColor: Colors.transparent, |
|||
elevation: 0, |
|||
insetPadding: EdgeInsets.symmetric(horizontal: 40.w), |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 28.h), |
|||
decoration: BoxDecoration( |
|||
color: Get.isDarkMode |
|||
? const Color(0xFF1C1C1E) |
|||
: Colors.white, |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
SizedBox( |
|||
width: 44.w, |
|||
height: 44.w, |
|||
child: const CircularProgressIndicator( |
|||
strokeWidth: 3, |
|||
valueColor: |
|||
AlwaysStoppedAnimation<Color>(Color(0xFF0066CC)), |
|||
), |
|||
), |
|||
SizedBox(height: 18.h), |
|||
Text( |
|||
'connectingDevice'.tr, |
|||
style: TextStyle( |
|||
fontSize: 15.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: Get.isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 6.h), |
|||
Text( |
|||
device.name.isEmpty ? device.address : device.name, |
|||
maxLines: 1, |
|||
overflow: TextOverflow.ellipsis, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Get.isDarkMode ? Colors.white54 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
), |
|||
barrierDismissible: false, |
|||
); |
|||
} |
|||
|
|||
// 获取指定类型的设备列表 |
|||
List<UserDevice> getDevicesByType(int deviceType) { |
|||
return devices[deviceType] ?? []; |
|||
Future<void> disconnect() async { |
|||
await _bleManager.disconnect(); |
|||
} |
|||
|
|||
bool isConnected(JieliDevice device) { |
|||
return _bleManager.isConnected && |
|||
_bleManager.currentDeviceAddress == device.address; |
|||
} |
|||
|
|||
Future<bool> _ensureBluetoothPermission() async { |
|||
try { |
|||
// Android 12+:BLUETOOTH_SCAN 已声明 neverForLocation,所以不强制要求定位。 |
|||
// 但实际不少 ROM 仍要求定位才能扫到 BLE 设备 —— 同时申请,谁拒绝就报错。 |
|||
final results = await [ |
|||
Permission.bluetoothScan, |
|||
Permission.bluetoothConnect, |
|||
Permission.locationWhenInUse, |
|||
].request(); |
|||
print('[BLE] [SCAN] 权限申请详情: $results'); |
|||
// 至少 scan + connect 必须授权;location 缺失警告但不一定阻塞 |
|||
final scanOk = results[Permission.bluetoothScan]?.isGranted ?? false; |
|||
final connectOk = results[Permission.bluetoothConnect]?.isGranted ?? false; |
|||
return scanOk && connectOk; |
|||
} catch (e) { |
|||
Logger.error('请求蓝牙权限失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
|
|||
File diff suppressed because it is too large
@ -1,9 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/pairing_controller.dart'; |
|||
|
|||
class PairingBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.put<PairingController>(PairingController()); |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -1,879 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/pairing_controller.dart'; |
|||
|
|||
class PairingView extends StatefulWidget { |
|||
const PairingView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
State<PairingView> createState() => _PairingViewState(); |
|||
} |
|||
|
|||
class _PairingViewState extends State<PairingView> |
|||
with TickerProviderStateMixin { |
|||
late AnimationController _pulseController; |
|||
late AnimationController _rotationController; |
|||
late Animation<double> _pulseAnimation; |
|||
late Animation<double> _rotationAnimation; |
|||
|
|||
PairingController get controller => Get.find<PairingController>(); |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
|
|||
// 脉搏动画控制器 |
|||
_pulseController = AnimationController( |
|||
duration: const Duration(milliseconds: 1500), |
|||
vsync: this, |
|||
); |
|||
|
|||
// 旋转动画控制器 |
|||
_rotationController = AnimationController( |
|||
duration: const Duration(milliseconds: 2000), |
|||
vsync: this, |
|||
); |
|||
|
|||
// 脉搏动画 |
|||
_pulseAnimation = Tween<double>( |
|||
begin: 0.8, |
|||
end: 1.2, |
|||
).animate(CurvedAnimation( |
|||
parent: _pulseController, |
|||
curve: Curves.easeInOut, |
|||
)); |
|||
|
|||
// 旋转动画 |
|||
_rotationAnimation = Tween<double>( |
|||
begin: 0.0, |
|||
end: 1.0, |
|||
).animate(CurvedAnimation( |
|||
parent: _rotationController, |
|||
curve: Curves.linear, |
|||
)); |
|||
|
|||
// 开始动画 |
|||
_pulseController.repeat(reverse: true); |
|||
_rotationController.repeat(); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_pulseController.dispose(); |
|||
_rotationController.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
body: SafeArea( |
|||
child: Column( |
|||
children: [ |
|||
// 固定顶部区域 - 不受下方内容影响 |
|||
_buildFixedTopSection(isDarkMode), |
|||
|
|||
// 可滚动的动态内容区域 |
|||
Expanded( |
|||
child: _buildDynamicContentSection(isDarkMode), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 固定顶部区域 |
|||
Widget _buildFixedTopSection(bool isDarkMode) { |
|||
return Padding( |
|||
padding: EdgeInsets.only(top: 60.h, left: 24.w, right: 24.w), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
// 标题部分 |
|||
_buildHeader(isDarkMode), |
|||
SizedBox(height: 20.h), |
|||
|
|||
_buildDeviceAnimation(isDarkMode), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 动态内容区域 |
|||
Widget _buildDynamicContentSection(bool isDarkMode) { |
|||
return SingleChildScrollView( |
|||
physics: const BouncingScrollPhysics(), |
|||
child: Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 24.w), |
|||
child: Column( |
|||
mainAxisAlignment: MainAxisAlignment.start, |
|||
crossAxisAlignment: CrossAxisAlignment.center, |
|||
children: [ |
|||
SizedBox(height: 30.h), |
|||
|
|||
// 状态信息 |
|||
_buildStatusInfo(isDarkMode), |
|||
|
|||
SizedBox(height: 40.h), |
|||
|
|||
// 底部按钮 |
|||
_buildActionButtons(isDarkMode), |
|||
|
|||
SizedBox(height: 20.h), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 头部标题 |
|||
Widget _buildHeader(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
Text( |
|||
'Voitrans', |
|||
style: TextStyle( |
|||
fontSize: 30.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Text( |
|||
'homeSlogan'.tr, // 连接世界,超越声音 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.white60 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 设备动画 - 根据状态显示不同的动画效果 |
|||
Widget _buildDeviceAnimation(bool isDarkMode) { |
|||
return Obx(() { |
|||
final state = controller.currentState; |
|||
final isLoading = controller.isLoading.value; |
|||
// 根据状态决定动画颜色 |
|||
Color primaryColor = const Color(0xFF0066CC); |
|||
Color secondaryColor = const Color(0xFF5E5CE6); |
|||
|
|||
// 错误状态使用红色 |
|||
if (state == PairingState.error) { |
|||
primaryColor = Colors.red; |
|||
secondaryColor = Colors.redAccent; |
|||
} |
|||
// 成功状态使用绿色 |
|||
else if (state == PairingState.completed) { |
|||
primaryColor = Colors.green; |
|||
secondaryColor = Colors.greenAccent; |
|||
} |
|||
|
|||
// 待机状态的特殊处理 |
|||
if (state == PairingState.idle || state == PairingState.waitingClassic) { |
|||
return SizedBox( |
|||
width: 240.w, |
|||
height: 240.w, |
|||
child: Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
// 静态背景圆圈 |
|||
Container( |
|||
width: 200.w, |
|||
height: 200.w, |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [ |
|||
primaryColor.withValues(alpha: 0.1), |
|||
secondaryColor.withValues(alpha: 0.1), |
|||
], |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
// 设备图标 |
|||
Container( |
|||
width: 120.w, |
|||
height: 120.w, |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [primaryColor, secondaryColor], |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon( |
|||
Icons.headphones, |
|||
color: Colors.white, |
|||
size: 60.w, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
return SizedBox( |
|||
width: 240.w, |
|||
height: 240.w, |
|||
child: Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
// 连续脉搏动画 - 只在加载时显示 |
|||
if (isLoading) _buildPulseAnimation(primaryColor, secondaryColor), |
|||
|
|||
// 特殊的安全连接动画 - 只在建立安全连接时显示 |
|||
if (state == PairingState.waitingBle) |
|||
_buildSecureConnectionAnimation(primaryColor, secondaryColor), |
|||
|
|||
// 设备关联动画 - 只在关联设备时显示 |
|||
if (state == PairingState.associating) |
|||
_buildAssociatingAnimation(primaryColor, secondaryColor), |
|||
|
|||
// 旋转边框 - 只在加载时显示 |
|||
if (isLoading) _buildRotatingBorder(primaryColor), |
|||
|
|||
// 设备图标 |
|||
Container( |
|||
width: 120.w, |
|||
height: 120.w, |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [primaryColor, secondaryColor], |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon( |
|||
_getIconForState(state), |
|||
color: Colors.white, |
|||
size: 60.w, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
// 构建连续脉搏动画 |
|||
Widget _buildPulseAnimation(Color primaryColor, Color secondaryColor) { |
|||
return AnimatedBuilder( |
|||
animation: _pulseAnimation, |
|||
builder: (context, child) { |
|||
final value = _pulseAnimation.value; |
|||
final opacity = |
|||
0.4 - (_pulseController.value * 0.3); // 0.4 到 0.1 的透明度变化 |
|||
|
|||
return Container( |
|||
width: 200.w * value, |
|||
height: 200.w * value, |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [ |
|||
primaryColor.withValues(alpha: opacity), |
|||
secondaryColor.withValues(alpha: opacity), |
|||
], |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
// 构建旋转边框动画 |
|||
Widget _buildRotatingBorder(Color primaryColor) { |
|||
return AnimatedBuilder( |
|||
animation: _rotationAnimation, |
|||
builder: (context, child) { |
|||
return Transform.rotate( |
|||
angle: _rotationAnimation.value * 2 * 3.14159, // 完整旋转 |
|||
child: SizedBox( |
|||
width: 140.w, |
|||
height: 140.w, |
|||
child: CircularProgressIndicator( |
|||
strokeWidth: 3.w, |
|||
valueColor: AlwaysStoppedAnimation<Color>(primaryColor), |
|||
backgroundColor: Colors.grey.withValues(alpha: 0.2), |
|||
value: 0.7, // 显示70%的圆弧 |
|||
), |
|||
), |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
// 构建安全连接特殊动画 - 多层波纹效果 |
|||
Widget _buildSecureConnectionAnimation( |
|||
Color primaryColor, Color secondaryColor) { |
|||
return Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
// 第一层波纹 |
|||
AnimatedBuilder( |
|||
animation: _pulseAnimation, |
|||
builder: (context, child) { |
|||
final value = _pulseAnimation.value; |
|||
final opacity = 0.3 - (_pulseController.value * 0.2); |
|||
|
|||
return Container( |
|||
width: 180.w * value, |
|||
height: 180.w * value, |
|||
decoration: BoxDecoration( |
|||
border: Border.all( |
|||
color: primaryColor.withValues(alpha: opacity), |
|||
width: 2.w, |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
|
|||
// 第二层波纹 - 延迟动画 |
|||
AnimatedBuilder( |
|||
animation: _pulseAnimation, |
|||
builder: (context, child) { |
|||
final delayedValue = (_pulseController.value + 0.3) % 1.0; |
|||
final value = 0.8 + (delayedValue * 0.4); |
|||
final opacity = 0.25 - (delayedValue * 0.15); |
|||
|
|||
return Container( |
|||
width: 160.w * value, |
|||
height: 160.w * value, |
|||
decoration: BoxDecoration( |
|||
border: Border.all( |
|||
color: secondaryColor.withValues(alpha: opacity), |
|||
width: 1.5.w, |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
|
|||
// 第三层波纹 - 更大延迟 |
|||
AnimatedBuilder( |
|||
animation: _pulseAnimation, |
|||
builder: (context, child) { |
|||
final delayedValue = (_pulseController.value + 0.6) % 1.0; |
|||
final value = 0.8 + (delayedValue * 0.4); |
|||
final opacity = 0.2 - (delayedValue * 0.1); |
|||
|
|||
return Container( |
|||
width: 140.w * value, |
|||
height: 140.w * value, |
|||
decoration: BoxDecoration( |
|||
border: Border.all( |
|||
color: primaryColor.withValues(alpha: opacity), |
|||
width: 1.w, |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 根据状态获取图标 |
|||
IconData _getIconForState(PairingState state) { |
|||
switch (state) { |
|||
case PairingState.completed: |
|||
return Icons.check_circle; |
|||
case PairingState.associating: |
|||
return Icons.link; |
|||
case PairingState.error: |
|||
final errorType = controller.errorType; |
|||
switch (errorType) { |
|||
case ErrorType.bluetoothOff: |
|||
case ErrorType.noPermission: |
|||
return Icons.bluetooth_disabled; |
|||
case ErrorType.noClassicDevice: |
|||
case ErrorType.deviceNotMatch: |
|||
return Icons.headphones_outlined; |
|||
case ErrorType.associationFailed: |
|||
return Icons.link_off; |
|||
default: |
|||
return Icons.error_outline; |
|||
} |
|||
default: |
|||
return Icons.headphones; |
|||
} |
|||
} |
|||
|
|||
// 状态信息 |
|||
Widget _buildStatusInfo(bool isDarkMode) { |
|||
return Obx(() { |
|||
final status = controller.statusText.value; |
|||
final state = controller.currentState; |
|||
final device = controller.currentDevice; |
|||
|
|||
return Column( |
|||
children: [ |
|||
// 状态文本 - 更大更醒目 |
|||
Text( |
|||
status, |
|||
style: TextStyle( |
|||
fontSize: 20.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
|
|||
SizedBox(height: 10.h), |
|||
|
|||
// 子状态提示文本 - 根据状态显示不同提示 |
|||
Text( |
|||
_getSubtitleForState(state), |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white60 : Colors.grey[600], |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
|
|||
// 当前连接设备(有设备时才显示) |
|||
if (device != null && |
|||
(state == PairingState.verifying || |
|||
state == PairingState.binding || |
|||
state == PairingState.waitingBle || |
|||
state == PairingState.completed)) ...[ |
|||
SizedBox(height: 24.h), |
|||
_buildConnectedDevice(device, isDarkMode), |
|||
], |
|||
], |
|||
); |
|||
}); |
|||
} |
|||
|
|||
// 根据状态获取副标题 |
|||
String _getSubtitleForState(PairingState state) { |
|||
switch (state) { |
|||
case PairingState.idle: |
|||
return 'readyToPair'.tr; // 准备开始配对 |
|||
case PairingState.checking: |
|||
return 'preparingPairingEnvironment'.tr; // 正在准备配对环境... |
|||
case PairingState.waitingClassic: |
|||
return 'connectDeepSoundViaSystemBluetooth'.tr; // 请先通过系统蓝牙连接Voitrans设备 |
|||
case PairingState.verifying: |
|||
return 'verifyingDevice'.tr; // 正在验证设备... |
|||
case PairingState.binding: |
|||
return 'deviceBindingConfirm'.tr; // 设备绑定确认... |
|||
case PairingState.associating: |
|||
return 'establishingDeviceAssociation'.tr; // 正在建立设备关联... |
|||
case PairingState.waitingBle: |
|||
return 'waitingCompletePairing'.tr; // 请稍候,正在完成配对... |
|||
case PairingState.completed: |
|||
return 'pairingSuccessRedirecting'.tr; // 配对成功!即将跳转到主页... |
|||
case PairingState.error: |
|||
final errorType = controller.errorType; |
|||
switch (errorType) { |
|||
case ErrorType.bluetoothOff: |
|||
return 'connectDeepSoundViaSystemBluetooth'.tr; // 蓝牙已关闭,请打开蓝牙 |
|||
case ErrorType.noPermission: |
|||
return 'lackPermissions'.tr; // 缺少蓝牙权限和位置权限 |
|||
case ErrorType.noClassicDevice: |
|||
return 'connectDeepSoundViaSystemBluetooth' |
|||
.tr; // 请先通过系统蓝牙连接Voitrans设备 |
|||
case ErrorType.deviceNotMatch: |
|||
return 'ensureConnectedDeepSoundDevice'.tr.trArgs( |
|||
[controller.currentDevice?.name ?? '']); // 请确保连接的是Voitrans设备 |
|||
case ErrorType.deviceBound: |
|||
return 'deviceAlreadyBoundToOtherAccount'.tr; // 该设备已被其他账号绑定 |
|||
case ErrorType.associationFailed: |
|||
return 'deviceAssociationFailed'.tr; // 设备关联失败,请重试 |
|||
case ErrorType.bleTimeout: |
|||
return 'scanTimeoutEnsureDeviceNearby'.tr; // 扫描超时,请确保设备在附近 |
|||
case ErrorType.networkError: |
|||
return 'networkError'.tr; // 网络错误 |
|||
case ErrorType.authnFailed: |
|||
return 'authorizationFailedCheckCode'.tr; // 授权失败,请检查授权码是否正确 |
|||
default: |
|||
return 'pairingFailedRetry'.tr; // 配对失败,请重试 |
|||
} |
|||
default: |
|||
return ''; |
|||
} |
|||
} |
|||
|
|||
// 已连接设备信息 |
|||
Widget _buildConnectedDevice(device, bool isDarkMode) { |
|||
return SizedBox( |
|||
width: double.infinity, |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'currentlyConnected'.tr, // 当前已连接: |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
_buildDeviceItem(device, isDarkMode), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 设备项 |
|||
Widget _buildDeviceItem(device, bool isDarkMode) { |
|||
return Container( |
|||
margin: EdgeInsets.only(bottom: 8.h), |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.headphones, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
size: 24.w, |
|||
), |
|||
SizedBox(width: 12.w), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
device.name ?? 'unknown'.tr, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
device.address ?? '', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white54 : Colors.black45, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Icon( |
|||
Icons.bluetooth_connected, |
|||
color: Colors.green, |
|||
size: 20.w, |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 获取连接按钮图标 |
|||
IconData _getConnectButtonIcon() { |
|||
final buttonText = controller.connectButtonText.value; |
|||
if (buttonText.contains('bluetooth'.tr)) { |
|||
return Icons.bluetooth; |
|||
} else if (buttonText.contains('settings'.tr)) { |
|||
return Icons.settings; |
|||
} |
|||
return Icons.bluetooth_searching; |
|||
} |
|||
|
|||
// 操作按钮 |
|||
Widget _buildActionButtons(bool isDarkMode) { |
|||
return Obx(() { |
|||
final showConnectButton = controller.showConnectButton.value; |
|||
final showRetryButton = controller.showRetryButton.value; |
|||
final showBindButton = controller.showBindButton.value; |
|||
final showRetryChectButton = controller.showRetryChectButton.value; |
|||
final showSkipButton = controller.showSkipButton.value; |
|||
final showAutoCodeInput = controller.showAutoCodeInput.value; |
|||
final showSkipBindingButton = controller.showSkipBindingButton.value; |
|||
return SizedBox( |
|||
width: double.infinity, |
|||
child: Column( |
|||
children: [ |
|||
// 显示连接按钮 |
|||
if (showConnectButton) |
|||
ElevatedButton( |
|||
onPressed: () => controller.onConnectTap(), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: const Color(0xFF0066CC), |
|||
foregroundColor: Colors.white, |
|||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
elevation: 0, |
|||
minimumSize: Size(double.infinity, 56.h), |
|||
), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Icon(_getConnectButtonIcon(), size: 20.w), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
controller.connectButtonText.value, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
if (showBindButton) |
|||
ElevatedButton( |
|||
onPressed: () => controller.confirmBindDevice(), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.blue, |
|||
foregroundColor: Colors.white, |
|||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
elevation: 0, |
|||
minimumSize: Size(double.infinity, 56.h), |
|||
), |
|||
child: Text( |
|||
"bind".tr, // 绑定 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
), |
|||
), |
|||
), |
|||
SizedBox(height: 12.h), |
|||
// 显示重试按钮 |
|||
if (showRetryButton) |
|||
ElevatedButton( |
|||
onPressed: () => controller.onRetryTap(), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red, |
|||
foregroundColor: Colors.white, |
|||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
elevation: 0, |
|||
minimumSize: Size(double.infinity, 56.h), |
|||
), |
|||
child: Text( |
|||
controller.retryButtonText.value, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
), |
|||
), |
|||
), |
|||
if (showAutoCodeInput) |
|||
Column( |
|||
children: [ |
|||
// 输入框 |
|||
SizedBox( |
|||
width: double.infinity, |
|||
height: 48.h, |
|||
child: TextField( |
|||
controller: controller |
|||
.codeInputController, // 你可以在 Controller 中声明这个 |
|||
decoration: InputDecoration( |
|||
hintText: 'enterCode'.tr, // 请输入代码 |
|||
border: OutlineInputBorder( |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
contentPadding: EdgeInsets.symmetric(horizontal: 16.w), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(height: 12.h), |
|||
SizedBox( |
|||
width: double.infinity, |
|||
height: 48.h, |
|||
child: ElevatedButton( |
|||
onPressed: controller.onAutoCodeInputTap, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Theme.of(Get.context!).primaryColor, |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
elevation: 0, |
|||
), |
|||
child: Text( |
|||
'authorizeBind'.tr, // 授权绑定 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
// 自动输入代码按钮 |
|||
|
|||
// 跳过按钮 |
|||
if (showSkipButton) ...[ |
|||
SizedBox(height: 12.h), |
|||
TextButton( |
|||
onPressed: () => controller.onSkipTap(), |
|||
style: TextButton.styleFrom( |
|||
foregroundColor: const Color(0xFF0066CC), |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
minimumSize: Size(double.infinity, 48.h), |
|||
), |
|||
child: Text( |
|||
'talkLater'.tr, // 稍后再说 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
|
|||
// 跳过绑定按钮 |
|||
if (showSkipBindingButton) ...[ |
|||
SizedBox(height: 12.h), |
|||
TextButton( |
|||
onPressed: () => controller.onSkipBindingTap(), |
|||
style: TextButton.styleFrom( |
|||
foregroundColor: const Color(0xFF0066CC), |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
minimumSize: Size(double.infinity, 48.h), |
|||
), |
|||
child: Text( |
|||
'skipBinding'.tr, // 跳过绑定 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
//重新检测 |
|||
if (showRetryChectButton) ...[ |
|||
SizedBox(height: 12.h), |
|||
TextButton( |
|||
onPressed: () => controller.onRetryTap(), |
|||
style: TextButton.styleFrom( |
|||
foregroundColor: const Color(0xFF0066CC), |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
minimumSize: Size(double.infinity, 48.h), |
|||
), |
|||
child: Text( |
|||
'rescan'.tr, // 重新扫描 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
], |
|||
), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
// 构建设备关联动画 - 连接线条效果 |
|||
Widget _buildAssociatingAnimation(Color primaryColor, Color secondaryColor) { |
|||
return AnimatedBuilder( |
|||
animation: _rotationAnimation, |
|||
builder: (context, child) { |
|||
return Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
// 旋转的连接线条 |
|||
Transform.rotate( |
|||
angle: _rotationAnimation.value * 2 * 3.14159, |
|||
child: Container( |
|||
width: 160.w, |
|||
height: 160.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
border: Border.all( |
|||
color: primaryColor.withValues(alpha: 0.6), |
|||
width: 2.w, |
|||
), |
|||
), |
|||
child: Stack( |
|||
children: [ |
|||
// 连接点1 |
|||
Positioned( |
|||
top: 10.w, |
|||
left: 80.w - 4.w, |
|||
child: Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: primaryColor, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
), |
|||
// 连接点2 |
|||
Positioned( |
|||
bottom: 10.w, |
|||
left: 80.w - 4.w, |
|||
child: Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: secondaryColor, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
), |
|||
// 连接点3 |
|||
Positioned( |
|||
top: 80.w - 4.w, |
|||
left: 10.w, |
|||
child: Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: primaryColor, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
), |
|||
// 连接点4 |
|||
Positioned( |
|||
top: 80.w - 4.w, |
|||
right: 10.w, |
|||
child: Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: secondaryColor, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
} |
|||
@ -1,304 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/pairing_controller.dart'; |
|||
|
|||
/// 配对视图 - 简化版本 |
|||
class PairingView extends GetView<PairingController> { |
|||
const PairingView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
body: SafeArea( |
|||
child: Padding( |
|||
padding: EdgeInsets.all(24.w), |
|||
child: Column( |
|||
children: [ |
|||
// 顶部标题 |
|||
_buildHeader(isDarkMode), |
|||
|
|||
// 中间内容 |
|||
Expanded( |
|||
child: Center( |
|||
child: Obx(() => _buildContent(isDarkMode)), |
|||
), |
|||
), |
|||
|
|||
// 底部按钮 |
|||
Obx(() => _buildButtons(isDarkMode)), |
|||
|
|||
SizedBox(height: 20.h), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建标题 |
|||
Widget _buildHeader(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
Text( |
|||
'devicePairing'.tr, // 设备配对 |
|||
style: TextStyle( |
|||
fontSize: 24.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Text( |
|||
'connectYourDeepSoundHeadset'.tr, // 连接您的 Voitrans 耳机 |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
SizedBox(height: 40.h), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建主要内容 |
|||
Widget _buildContent(bool isDarkMode) { |
|||
final state = controller.currentState; |
|||
final isLoading = controller.isLoading.value; |
|||
|
|||
return Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
// 状态图标 |
|||
_buildStateIcon(state, isLoading, isDarkMode), |
|||
|
|||
SizedBox(height: 40.h), |
|||
|
|||
// 状态文本 |
|||
Text( |
|||
controller.statusText.value, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
|
|||
// 错误详情 |
|||
if (state == PairingState.error) ...[ |
|||
SizedBox(height: 16.h), |
|||
Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.red.withValues(alpha: 0.1), |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.error_outline, |
|||
color: Colors.red, |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 12.w), |
|||
Expanded( |
|||
child: Text( |
|||
controller.errorMessage, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: Colors.red, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建状态图标 |
|||
Widget _buildStateIcon(PairingState state, bool isLoading, bool isDarkMode) { |
|||
if (isLoading) { |
|||
return CircularProgressIndicator( |
|||
valueColor: AlwaysStoppedAnimation<Color>( |
|||
Theme.of(Get.context!).primaryColor, |
|||
), |
|||
); |
|||
} |
|||
|
|||
IconData iconData; |
|||
Color iconColor; |
|||
|
|||
switch (state) { |
|||
case PairingState.idle: |
|||
case PairingState.checking: |
|||
iconData = Icons.bluetooth_searching; |
|||
iconColor = Colors.blue; |
|||
break; |
|||
|
|||
case PairingState.waitingClassic: |
|||
iconData = Icons.bluetooth; |
|||
iconColor = Colors.grey; |
|||
break; |
|||
|
|||
case PairingState.verifying: |
|||
iconData = Icons.verified_user; |
|||
iconColor = Colors.orange; |
|||
break; |
|||
case PairingState.binding: |
|||
iconData = Icons.blind; |
|||
iconColor = Colors.blue; |
|||
break; |
|||
case PairingState.associating: |
|||
iconData = Icons.link; |
|||
iconColor = Colors.blue; |
|||
break; |
|||
|
|||
case PairingState.waitingBle: |
|||
iconData = Icons.sync; |
|||
iconColor = Colors.blue; |
|||
break; |
|||
case PairingState.manualbinding: |
|||
iconData = Icons.blind; |
|||
iconColor = Colors.blue; |
|||
break; |
|||
case PairingState.completed: |
|||
iconData = Icons.check_circle; |
|||
iconColor = Colors.green; |
|||
break; |
|||
|
|||
case PairingState.error: |
|||
iconData = Icons.error; |
|||
iconColor = Colors.red; |
|||
break; |
|||
} |
|||
|
|||
return Container( |
|||
width: 100.w, |
|||
height: 100.w, |
|||
decoration: BoxDecoration( |
|||
color: iconColor.withValues(alpha: 0.1), |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon( |
|||
iconData, |
|||
size: 60.sp, |
|||
color: iconColor, |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建按钮 |
|||
Widget _buildButtons(bool isDarkMode) { |
|||
final showConnectButton = controller.showConnectButton.value; |
|||
final showBindButton = controller.showBindButton.value; |
|||
final showRetryButton = controller.showRetryButton.value; |
|||
final showAutoCodeInput = controller.showAutoCodeInput.value; |
|||
final showSkipButton = controller.showSkipButton.value; |
|||
|
|||
return Column( |
|||
children: [ |
|||
// 主要操作按钮 |
|||
if (showConnectButton) |
|||
_buildPrimaryButton( |
|||
'connectDevice'.tr, // 连接设备 |
|||
controller.onConnectTap, |
|||
isDarkMode, |
|||
), |
|||
|
|||
// 绑定按钮 |
|||
if (showBindButton) |
|||
TextButton( |
|||
onPressed: controller.confirmBindDevice, |
|||
child: Text( |
|||
'bind'.tr, // 绑定 |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
), |
|||
|
|||
if (showRetryButton) |
|||
_buildPrimaryButton( |
|||
'retry'.tr, // 重试 |
|||
controller.onRetryTap, |
|||
isDarkMode, |
|||
), |
|||
|
|||
SizedBox(height: 12.h), |
|||
|
|||
if (showAutoCodeInput) |
|||
// 自动输入代码按钮 |
|||
SizedBox( |
|||
width: double.infinity, |
|||
height: 48.h, |
|||
child: ElevatedButton( |
|||
onPressed: controller.onAutoCodeInputTap, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Theme.of(Get.context!).primaryColor, |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
elevation: 0, |
|||
), |
|||
child: Text( |
|||
'autoInputCode'.tr, // 自动输入代码 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
|
|||
// 跳过按钮 |
|||
if (showSkipButton) |
|||
TextButton( |
|||
onPressed: controller.onSkipTap, |
|||
child: Text( |
|||
'skip'.tr, // 跳过 |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建主要按钮 |
|||
Widget _buildPrimaryButton( |
|||
String text, VoidCallback onPressed, bool isDarkMode) { |
|||
return SizedBox( |
|||
width: double.infinity, |
|||
height: 48.h, |
|||
child: ElevatedButton( |
|||
onPressed: onPressed, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Theme.of(Get.context!).primaryColor, |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
elevation: 0, |
|||
), |
|||
child: Text( |
|||
text, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -1,10 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/wakeup_voice_controller.dart'; |
|||
|
|||
class WakeupVoiceBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<WakeupVoiceController>(() => WakeupVoiceController()); |
|||
} |
|||
} |
|||
|
|||
@ -1,543 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import 'package:dio/dio.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
import '../../../data/services/ble_manager.dart'; |
|||
import '../../../data/services/network/api.dart'; |
|||
|
|||
class WakeupVoiceController extends GetxController { |
|||
// 是否启用唤醒词(原有本地词功能,可保留) |
|||
final RxBool enabled = true.obs; |
|||
|
|||
final BleManager bleManager = Get.find<BleManager>(); |
|||
// 本地自定义唤醒词(原有) |
|||
final RxList<String> wakeWords = <String>['你好,深声', 'Hey Voitrans'].obs; |
|||
final RxString inputText = ''.obs; |
|||
|
|||
// 服务端唤醒语音包列表 |
|||
final RxList<WakeupVoiceItem> voices = <WakeupVoiceItem>[].obs; |
|||
// 当前设备的唤醒语音 id 与版本(由外部设备信息设置) |
|||
final RxnInt currentDeviceVoiceId = RxnInt(53251); |
|||
final RxnString currentDeviceVoiceVersion = RxnString('1.0.0'); |
|||
// 当前页面选择的语音包 id |
|||
final RxnInt selectedVoiceId = RxnInt(); |
|||
|
|||
// 加载状态 |
|||
final RxBool isLoading = false.obs; |
|||
final RxString loadError = ''.obs; |
|||
|
|||
// 设备状态(用于页面头部展示) |
|||
final RxString deviceName = ''.obs; |
|||
final RxBool isConnected = false.obs; |
|||
final RxBool wakeupVersionMismatch = false.obs; |
|||
final RxnInt deviceWakeIdCode = RxnInt(); |
|||
final RxnInt deviceWakeVerCode = RxnInt(); |
|||
|
|||
// 预览/测试模式:跳过 onReady 自动拉取 |
|||
bool skipAutoFetch = false; |
|||
|
|||
@override |
|||
void onReady() { |
|||
super.onReady(); |
|||
_bindBleObservers(); |
|||
// 允许通过路由参数传入当前设备唤醒语音信息 |
|||
final args = Get.arguments; |
|||
if (args is Map) { |
|||
final id = args['wakeupVoiceId']; |
|||
final ver = args['wakeupVoiceVersion']; |
|||
if (id is int && ver is String) { |
|||
setCurrentDeviceWakeVoice(id: id, version: ver); |
|||
} |
|||
final name = args['deviceName']; |
|||
final connected = args['connected']; |
|||
if (name is String) deviceName.value = name; |
|||
if (connected is bool) isConnected.value = connected; |
|||
} |
|||
if (!skipAutoFetch) { |
|||
fetchVoices(); |
|||
} |
|||
} |
|||
|
|||
/// 绑定 BLE 相关的观察者 |
|||
/// 参数:无 |
|||
/// 返回:无 |
|||
/// 异常:发生监听绑定异常时捕获并记录日志,不抛出 |
|||
void _bindBleObservers() { |
|||
// 同步连接状态与设备名 |
|||
try { |
|||
isConnected.value = bleManager.isConnected; |
|||
final info = bleManager.deviceInfo.value; |
|||
if (info != null && info.name.isNotEmpty) { |
|||
deviceName.value = info.name; |
|||
_syncDeviceWakeVersions(info); |
|||
} |
|||
// 监听连接状态变化 |
|||
ever<int>(bleManager.connectionStateRx, (state) { |
|||
isConnected.value = bleManager.isConnected; |
|||
final info = bleManager.deviceInfo.value; |
|||
if (info != null && info.name.isNotEmpty) { |
|||
deviceName.value = info.name; |
|||
_syncDeviceWakeVersions(info); |
|||
} |
|||
}); |
|||
// 监听设备信息更新 |
|||
ever<DeviceInfo?>(bleManager.deviceInfo, (info) { |
|||
if (info != null && info.name.isNotEmpty) { |
|||
deviceName.value = info.name; |
|||
_syncDeviceWakeVersions(info); |
|||
} |
|||
}); |
|||
// 监听升级进度 |
|||
ever<int>(bleManager.wakeupVoiceUpgradeProgress, (p) { |
|||
upgradeProgress.value = p; |
|||
if (p >= 100 && upgradingVoiceId.value != null) { |
|||
final item = _findById(upgradingVoiceId.value); |
|||
if (item != null) { |
|||
currentDeviceVoiceId.value = item.id; |
|||
currentDeviceVoiceVersion.value = item.version; |
|||
selectedVoiceId.value = item.id; |
|||
} |
|||
upgradingVoiceId.value = null; |
|||
} |
|||
}); |
|||
// 监听升级状态:仅当状态为“成功(2)”或“失败(3)”时弹出提示弹窗;升级中(1)或其他状态不提示 |
|||
ever<int>(bleManager.wakeupVoiceUpgradeStatus, (s) { |
|||
// 升级状态字段未定义,暂存到日志 |
|||
Logger.info('BLE 升级状态码: $s'); |
|||
if (s == 2) { |
|||
// 升级成功 |
|||
Get.snackbar('成功', '唤醒语音升级成功'); |
|||
} else if (s == 3) { |
|||
// 升级失败 |
|||
Get.snackbar('错误', '唤醒语音升级失败'); |
|||
} |
|||
}); |
|||
} catch (e) { |
|||
Logger.warning('绑定BLE观察者失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 供外部设置当前设备语音包信息 |
|||
/// 设置当前设备的唤醒语音包信息 |
|||
/// 参数:`id` 唤醒词ID;`version` 版本字符串(如 `1.2.3.4`) |
|||
/// 返回:无 |
|||
/// 异常:无 |
|||
void setCurrentDeviceWakeVoice({required int id, required String version}) { |
|||
currentDeviceVoiceId.value = id; |
|||
currentDeviceVoiceVersion.value = version; |
|||
// 若已加载列表,默认选中当前设备项 |
|||
if (voices.isNotEmpty) { |
|||
selectedVoiceId.value = id; |
|||
} |
|||
} |
|||
|
|||
/// 拉取服务端唤醒语音包列表 |
|||
/// 参数:无 |
|||
/// 返回:`Future<void>`,完成后 `voices`、`selectedVoiceId` 等被更新 |
|||
/// 异常:网络或数据解析异常会写入 `loadError` 并弹出提示,不抛出 |
|||
Future<void> fetchVoices() async { |
|||
try { |
|||
isLoading.value = true; |
|||
loadError.value = ''; |
|||
final res = await Api.getwakeupvoices(); |
|||
Logger.info('获取唤醒语音列表成功 res: $res'); |
|||
// 仅按约定结构解析:{ voices: [...] } |
|||
if (res is! Map || res['voices'] is! List) { |
|||
loadError.value = '接口返回异常:缺少 voices 列表'; |
|||
voices.clear(); |
|||
return; |
|||
} |
|||
final list = List<Map<String, dynamic>>.from(res['voices'] as List); |
|||
|
|||
voices.assignAll(list |
|||
.map((e) => WakeupVoiceItem.fromJson(e as Map<String, dynamic>)) |
|||
.toList()); |
|||
|
|||
if (voices.isNotEmpty) { |
|||
selectedVoiceId.value = voices.first.id; |
|||
} |
|||
final devIdCode = deviceWakeIdCode.value; |
|||
if (devIdCode != null) { |
|||
final sameIdItems = voices.where((v) { |
|||
final p = _parseVersion(v.version); |
|||
return p != null && |
|||
p.length >= 2 && |
|||
((p[0] << 8) | p[1]) == devIdCode; |
|||
}).toList(); |
|||
if (sameIdItems.isNotEmpty) { |
|||
selectedVoiceId.value = sameIdItems.first.id; |
|||
currentDeviceVoiceId.value = devIdCode; |
|||
} |
|||
} |
|||
if (wakeupVersionMismatch.value) { |
|||
loadError.value = '设备异常:左右唤醒词版本不一致'; |
|||
} |
|||
} catch (e) { |
|||
loadError.value = '加载唤醒语音失败: $e'; |
|||
Get.snackbar('错误', loadError.value); |
|||
} finally { |
|||
isLoading.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 解析版本字符串为四段整型数组 |
|||
/// 参数:`v` 版本字符串,期望形如 `A.B.C.D`(可包含非数字分隔符) |
|||
/// 返回:`List<int>?` 长度为4的数组,若解析失败返回 `null` |
|||
/// 异常:无 |
|||
List<int>? _parseVersion(String v) { |
|||
final parts = |
|||
v.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList(); |
|||
if (parts.length < 4) return null; |
|||
final nums = parts.take(4).map((e) => int.tryParse(e) ?? 0).toList(); |
|||
return nums; |
|||
} |
|||
|
|||
/// 同步设备左右耳唤醒词版本信息,并计算设备唤醒词ID与版本编码 |
|||
/// 参数:`info` 设备信息对象,需包含 `wakeupLeftVersion` 与 `wakeupRightVersion` |
|||
/// 返回:无 |
|||
/// 异常:无;若版本缺失或解析失败则静默返回 |
|||
void _syncDeviceWakeVersions(DeviceInfo info) { |
|||
final left = info.wakeupLeftVersion; |
|||
final right = info.wakeupRightVersion; |
|||
wakeupVersionMismatch.value = |
|||
left.isNotEmpty && right.isNotEmpty && left != right; |
|||
final base = left.isNotEmpty ? left : right; |
|||
if (base.isEmpty) return; |
|||
final p = _parseVersion(base); |
|||
if (p == null || p.length < 4) return; |
|||
deviceWakeIdCode.value = (p[0] << 8) | p[1]; |
|||
deviceWakeVerCode.value = (p[2] << 8) | p[3]; |
|||
currentDeviceVoiceId.value = deviceWakeIdCode.value; |
|||
currentDeviceVoiceVersion.value = '${p[2]}.${p[3]}'; |
|||
} |
|||
|
|||
/// 判断候选项与设备的唤醒词ID是否一致 |
|||
/// 参数:`item` 唤醒词项,若为空返回 `false` |
|||
/// 返回:`bool`,一致返回 `true` |
|||
/// 异常:无 |
|||
bool _isSameWakeId(WakeupVoiceItem? item) { |
|||
if (item == null) return false; |
|||
final p = _parseVersion(item.version); |
|||
if (p == null || p.length < 2) return false; |
|||
final idCode = (p[0] << 8) | p[1]; |
|||
return deviceWakeIdCode.value != null && deviceWakeIdCode.value == idCode; |
|||
} |
|||
|
|||
/// 判断候选项与设备的唤醒词版本是否一致 |
|||
/// 参数:`item` 唤醒词项,若为空返回 `false` |
|||
/// 返回:`bool`,一致返回 `true` |
|||
/// 异常:无 |
|||
bool _isSameWakeVersion(WakeupVoiceItem? item) { |
|||
if (item == null) return false; |
|||
final p = _parseVersion(item.version); |
|||
if (p == null || p.length < 4) return false; |
|||
final verCode = (p[2] << 8) | p[3]; |
|||
return deviceWakeVerCode.value != null && |
|||
deviceWakeVerCode.value == verCode; |
|||
} |
|||
|
|||
// 选择语音包 |
|||
/// 选择语音包 |
|||
/// 参数:`id` 待选择的语音包ID |
|||
/// 返回:无 |
|||
/// 异常:无 |
|||
void selectVoice(int id) { |
|||
selectedVoiceId.value = id; |
|||
} |
|||
|
|||
// 动作按钮文案与状态 |
|||
/// 获取当前选择项对应的操作文案(基于设备与候选项版本关系) |
|||
/// 参数:无(使用 `selectedVoiceId`) |
|||
/// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新`、`处理` |
|||
/// 异常:无 |
|||
String get actionText { |
|||
final sel = selectedVoiceId.value; |
|||
final curId = currentDeviceVoiceId.value; |
|||
final curVer = currentDeviceVoiceVersion.value; |
|||
final selected = _findById(sel); |
|||
if (sel == null || selected == null) return '处理'; |
|||
if (wakeupVersionMismatch.value) return '设备异常'; |
|||
|
|||
if (_isSameWakeId(selected)) { |
|||
return _isSameWakeVersion(selected) ? '已是最新' : '升级'; |
|||
} |
|||
return '切换'; |
|||
} |
|||
|
|||
/// 获取当前选择项是否允许触发操作 |
|||
/// 参数:无(使用 `selectedVoiceId`) |
|||
/// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false` |
|||
/// 异常:无 |
|||
bool get actionEnabled { |
|||
final sel = selectedVoiceId.value; |
|||
final curId = currentDeviceVoiceId.value; |
|||
final curVer = currentDeviceVoiceVersion.value; |
|||
final selected = _findById(sel); |
|||
if (sel == null || selected == null) return false; |
|||
if (!isConnected.value) return false; |
|||
if (wakeupVersionMismatch.value) return false; |
|||
|
|||
if (_isSameWakeId(selected)) { |
|||
return !_isSameWakeVersion(selected); |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
/// 计算指定语音项的操作文案(不影响当前选择项) |
|||
/// 参数:`item` 候选语音项 |
|||
/// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新` |
|||
/// 异常:无 |
|||
String actionTextForItem(WakeupVoiceItem item) { |
|||
if (wakeupVersionMismatch.value) return '设备异常'; |
|||
if (_isSameWakeId(item)) { |
|||
return _isSameWakeVersion(item) ? '已是最新' : '升级'; |
|||
} |
|||
return '切换'; |
|||
} |
|||
|
|||
/// 计算指定语音项是否允许操作(不影响当前选择项) |
|||
/// 参数:`item` 候选语音项 |
|||
/// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false` |
|||
/// 异常:无 |
|||
bool actionEnabledForItem(WakeupVoiceItem item) { |
|||
if (!isConnected.value) return false; |
|||
if (wakeupVersionMismatch.value) return false; |
|||
if (_isSameWakeId(item)) { |
|||
return !_isSameWakeVersion(item); |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
/// 根据当前选择项触发操作 |
|||
/// 参数:无 |
|||
/// 返回:`Future<void>` |
|||
/// 异常:无;当不允许操作时直接返回 |
|||
Future<void> onActionPressed() async { |
|||
final label = actionText; |
|||
if (!actionEnabled) return; |
|||
|
|||
switch (label) { |
|||
case '升级': |
|||
await upgradeWakeVoice(); |
|||
break; |
|||
case '切换': |
|||
await switchWakeVoice(); |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 将当前设备升级到已选择项 |
|||
/// 参数:无(使用 `selectedVoiceId`) |
|||
/// 返回:`Future<void>` |
|||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|||
Future<void> upgradeWakeVoice() async { |
|||
final sel = _findById(selectedVoiceId.value); |
|||
if (sel == null) return; |
|||
await _startUpgradeByItem(sel); |
|||
} |
|||
|
|||
/// 将当前设备切换到已选择项 |
|||
/// 参数:无(使用 `selectedVoiceId`) |
|||
/// 返回:`Future<void>` |
|||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|||
Future<void> switchWakeVoice() async { |
|||
final sel = _findById(selectedVoiceId.value); |
|||
if (sel == null) return; |
|||
await _startUpgradeByItem(sel); |
|||
} |
|||
|
|||
// 直接切换到指定语音包(页面卡片按钮使用) |
|||
/// 直接切换到指定语音项 |
|||
/// 参数:`item` 目标语音项 |
|||
/// 返回:`Future<void>` |
|||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|||
Future<void> switchWakeVoiceTo(WakeupVoiceItem item) async { |
|||
selectedVoiceId.value = item.id; |
|||
await _startUpgradeByItem(item); |
|||
} |
|||
|
|||
// 直接升级到指定语音包(页面卡片按钮使用) |
|||
/// 直接升级到指定语音项 |
|||
/// 参数:`item` 目标语音项 |
|||
/// 返回:`Future<void>` |
|||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|||
Future<void> upgradeWakeVoiceTo(WakeupVoiceItem item) async { |
|||
selectedVoiceId.value = item.id; |
|||
await _startUpgradeByItem(item); |
|||
} |
|||
|
|||
// 升级相关状态 |
|||
final RxnInt upgradingVoiceId = RxnInt(); |
|||
final RxInt upgradeProgress = 0.obs; |
|||
|
|||
/// 按指定语音项发起升级流程(下载并写入设备) |
|||
/// 参数:`item` 目标语音项 |
|||
/// 返回:`Future<void>` |
|||
/// 异常:下载或BLE交互失败会通过提示与日志反馈,不抛出 |
|||
Future<void> _startUpgradeByItem(WakeupVoiceItem item) async { |
|||
try { |
|||
if (item.packagaddress.isEmpty) { |
|||
Get.snackbar('升级失败', '语音包地址为空'); |
|||
return; |
|||
} |
|||
upgradingVoiceId.value = item.id; |
|||
upgradeProgress.value = 0; |
|||
final url = item.packagaddress; |
|||
Logger.info('开始下载唤醒语音包: $url'); |
|||
final bytes = await _downloadVoiceBytes(url); |
|||
if (bytes.isEmpty) { |
|||
Get.snackbar('升级失败', '语音包下载为空'); |
|||
upgradingVoiceId.value = null; |
|||
return; |
|||
} |
|||
Logger.info('下载完成,字节数: ${bytes.length},开始升级'); |
|||
// 注意:MethodChannel 在 Android 端对 Uint8List 会编码为 byte[], |
|||
// 而当前原生插件期望的是 List。为避免 ClassCastException,传递 List<int>。 |
|||
final ok = await bleManager.startWakeupVoicesUpgrade(bytes.toList()); |
|||
if (!ok) { |
|||
Get.snackbar('升级失败', 'BLE升级启动失败'); |
|||
upgradingVoiceId.value = null; |
|||
return; |
|||
} |
|||
Get.snackbar('升级已启动', '正在写入设备,请稍候'); |
|||
} catch (e) { |
|||
upgradingVoiceId.value = null; |
|||
Logger.info('升级失败: $e'); |
|||
Get.snackbar('升级失败', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 下载语音包二进制数据 |
|||
/// 参数:`url` 语音包地址 |
|||
/// 返回:`Future<List<int>>` 字节列表;失败返回空列表 |
|||
/// 异常:网络异常会记录日志并返回空列表 |
|||
Future<List<int>> _downloadVoiceBytes(String url) async { |
|||
try { |
|||
final dio = Dio(); |
|||
final response = await dio.get<List<int>>( |
|||
url, |
|||
options: Options( |
|||
responseType: ResponseType.bytes, |
|||
receiveTimeout: const Duration(seconds: 60), |
|||
), |
|||
); |
|||
return response.data ?? []; |
|||
} catch (e) { |
|||
Logger.error('下载语音包失败: $url', e); |
|||
return []; |
|||
} |
|||
} |
|||
|
|||
/// 根据ID查找语音项 |
|||
/// 参数:`id` 语音包ID |
|||
/// 返回:`WakeupVoiceItem?` 找到返回对象,否则 `null` |
|||
/// 异常:无 |
|||
WakeupVoiceItem? _findById(int? id) { |
|||
if (id == null) return null; |
|||
for (final v in voices) { |
|||
if (v.id == id) return v; |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
// 以下保留原有本地词功能(开关、增删) |
|||
void toggleEnabled(bool value) { |
|||
enabled.value = value; |
|||
} |
|||
|
|||
void setInput(String value) { |
|||
inputText.value = value; |
|||
} |
|||
|
|||
String? validateWakeWord(String text) { |
|||
final t = text.trim(); |
|||
if (t.isEmpty) return '唤醒词不能为空'; |
|||
if (t.length > 20) return '唤醒词过长(≤20个字符)'; |
|||
if (wakeWords.contains(t)) return '重复的唤醒词'; |
|||
return null; |
|||
} |
|||
|
|||
bool addWakeWord(String text) { |
|||
final err = validateWakeWord(text); |
|||
if (err != null) { |
|||
Get.snackbar('提示', err); |
|||
return false; |
|||
} |
|||
if (wakeWords.length >= 5) { |
|||
Get.snackbar('提示', '最多添加5个唤醒词'); |
|||
return false; |
|||
} |
|||
wakeWords.add(text.trim()); |
|||
inputText.value = ''; |
|||
return true; |
|||
} |
|||
|
|||
void removeWakeWord(String text) { |
|||
wakeWords.remove(text); |
|||
} |
|||
|
|||
Future<void> saveChanges() async { |
|||
Get.snackbar('保存成功', '唤醒词设置已更新'); |
|||
} |
|||
} |
|||
|
|||
class WakeupVoiceItem { |
|||
final int id; |
|||
final bool enable; |
|||
final String name; |
|||
final String desc; |
|||
final String version; |
|||
final String packagaddress; |
|||
|
|||
WakeupVoiceItem({ |
|||
required this.id, |
|||
required this.enable, |
|||
required this.name, |
|||
required this.desc, |
|||
required this.version, |
|||
required this.packagaddress, |
|||
}); |
|||
|
|||
factory WakeupVoiceItem.fromJson(Map<String, dynamic> json) { |
|||
return WakeupVoiceItem( |
|||
id: (json['id'] ?? json['Id'] ?? 0) is String |
|||
? int.tryParse(json['id'] ?? json['Id']) ?? 0 |
|||
: (json['id'] ?? json['Id'] ?? 0) as int, |
|||
enable: (json['enable'] ?? json['Enable'] ?? false) == true || |
|||
(json['enable'] ?? 'false').toString() == 'true', |
|||
name: (json['name'] ?? '').toString(), |
|||
desc: (json['desc'] ?? '').toString(), |
|||
version: (json['version'] ?? '').toString(), |
|||
packagaddress: (json['packagaddress'] ?? '').toString(), |
|||
); |
|||
} |
|||
|
|||
// 从描述字段提取识别率与更新说明(供UI展示) |
|||
} |
|||
|
|||
extension WakeupVoiceItemExt on WakeupVoiceController { |
|||
String? extractAccuracy(WakeupVoiceItem item) { |
|||
final d = item.desc; |
|||
final reg = RegExp(r'(识别率|准确率)\s*([0-9]{1,3}(\.[0-9])?)%'); |
|||
final m = reg.firstMatch(d); |
|||
if (m != null) { |
|||
return '识别率 ${m.group(2)}%'; |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
List<String> extractUpdateNotes(WakeupVoiceItem item) { |
|||
final d = item.desc.trim(); |
|||
if (d.isEmpty) { |
|||
return const ['提升识别准确率至99.2%', '优化唤醒提示音的响应', '修复复杂性延迟问题']; |
|||
} |
|||
// 按常见分隔符切分 |
|||
final parts = d |
|||
.split(RegExp(r'[;;\n\r]')) |
|||
.map((e) => e.trim()) |
|||
.where((e) => e.isNotEmpty) |
|||
.toList(); |
|||
return parts; |
|||
} |
|||
} |
|||
@ -1,457 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/wakeup_voice_controller.dart'; |
|||
|
|||
class WakeupVoiceView extends GetView<WakeupVoiceController> { |
|||
const WakeupVoiceView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDark = Theme.of(context).brightness == Brightness.dark; |
|||
return Scaffold( |
|||
backgroundColor: isDark ? Colors.black : Colors.grey[50], |
|||
body: Obx(() { |
|||
// 顶部渐变头部 + 内容 |
|||
return CustomScrollView( |
|||
slivers: [ |
|||
SliverAppBar( |
|||
pinned: true, |
|||
elevation: 0, |
|||
centerTitle: true, |
|||
backgroundColor: Colors.transparent, |
|||
flexibleSpace: _buildHeader(isDark), |
|||
expandedHeight: 180, |
|||
leading: IconButton( |
|||
icon: const Icon(Icons.arrow_back_ios, color: Colors.white), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
title: const Text('唤醒词管理', |
|||
style: TextStyle(color: Colors.white, fontSize: 18)), |
|||
actions: const [ |
|||
Padding( |
|||
padding: EdgeInsets.only(right: 12), |
|||
child: Icon(Icons.more_vert, color: Colors.white), |
|||
) |
|||
], |
|||
), |
|||
SliverToBoxAdapter( |
|||
child: Padding( |
|||
padding: const EdgeInsets.all(16), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
_buildCurrentWakeWordCard(isDark), |
|||
const SizedBox(height: 16), |
|||
_buildAvailableWakeWords(isDark), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
}), |
|||
); |
|||
} |
|||
|
|||
// 顶部渐变头部 |
|||
/// 构建顶部设备状态头部 |
|||
/// 参数:`isDark` 是否深色模式 |
|||
/// 返回:`Widget` 构建好的头部组件 |
|||
/// 异常:无 |
|||
Widget _buildHeader(bool isDark) { |
|||
final connected = controller.isConnected.value; |
|||
final deviceName = controller.deviceName.value.isEmpty |
|||
? '未连接设备' |
|||
: controller.deviceName.value; |
|||
final mismatch = controller.wakeupVersionMismatch.value; |
|||
return Container( |
|||
decoration: const BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [Color(0xFF6A5AE0), Color(0xFF8A65E6)], |
|||
), |
|||
), |
|||
child: SafeArea( |
|||
bottom: false, |
|||
child: Padding( |
|||
padding: const EdgeInsets.fromLTRB(16, 56, 16, 16), |
|||
child: Row( |
|||
children: [ |
|||
Container( |
|||
width: 56, |
|||
height: 56, |
|||
decoration: BoxDecoration( |
|||
color: Colors.white.withOpacity(0.15), |
|||
borderRadius: BorderRadius.circular(12), |
|||
), |
|||
child: const Icon(Icons.headset, color: Colors.white, size: 28), |
|||
), |
|||
const SizedBox(width: 12), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Text(deviceName, |
|||
style: const TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 18, |
|||
fontWeight: FontWeight.w600)), |
|||
const SizedBox(height: 6), |
|||
Row( |
|||
children: [ |
|||
Container( |
|||
width: 8, |
|||
height: 8, |
|||
decoration: BoxDecoration( |
|||
color: connected ? Colors.greenAccent : Colors.red, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
const SizedBox(width: 6), |
|||
Text(connected ? '已连接' : '未连接', |
|||
style: const TextStyle( |
|||
color: Colors.white70, fontSize: 12)), |
|||
if (mismatch) ...[ |
|||
const SizedBox(width: 12), |
|||
Container( |
|||
padding: const EdgeInsets.symmetric( |
|||
horizontal: 8, vertical: 2), |
|||
decoration: BoxDecoration( |
|||
color: Colors.red.withOpacity(0.2), |
|||
borderRadius: BorderRadius.circular(6), |
|||
border: Border.all(color: Colors.redAccent), |
|||
), |
|||
child: const Text('设备异常', |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 11, |
|||
fontWeight: FontWeight.w500)), |
|||
), |
|||
] |
|||
], |
|||
) |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 当前唤醒词卡片 |
|||
/// 构建当前设备唤醒词卡片 |
|||
/// 参数:`isDark` 是否深色模式 |
|||
/// 返回:`Widget` 构建好的卡片 |
|||
/// 异常:无 |
|||
Widget _buildCurrentWakeWordCard(bool isDark) { |
|||
final curId = controller.currentDeviceVoiceId.value; |
|||
final curVer = controller.currentDeviceVoiceVersion.value; |
|||
WakeupVoiceItem? curItem; |
|||
for (final v in controller.voices) { |
|||
if (v.id == curId) { |
|||
curItem = v; |
|||
break; |
|||
} |
|||
} |
|||
final title = '当前唤醒词'; |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text(title, |
|||
style: |
|||
const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), |
|||
Text(curVer != null ? 'v$curVer' : '', |
|||
style: const TextStyle(color: Colors.grey, fontSize: 12)), |
|||
], |
|||
), |
|||
const SizedBox(height: 8), |
|||
if (curItem != null) |
|||
_buildVoiceCard( |
|||
isDark: isDark, |
|||
item: curItem, |
|||
isCurrent: true, |
|||
hasUpdate: curItem != null && |
|||
controller.actionTextForItem(curItem) == '升级', |
|||
), |
|||
// 版本一致的唤醒词放在“当前唤醒词”下面 |
|||
...controller.voices |
|||
.where((v) => controller.actionTextForItem(v) == '已是最新') |
|||
.where((v) => !identical(v, curItem)) |
|||
.map((v) => _buildVoiceCard( |
|||
isDark: isDark, |
|||
item: v, |
|||
isCurrent: true, |
|||
hasUpdate: false, |
|||
)), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 可用唤醒词列表 |
|||
/// 构建可用唤醒词列表 |
|||
/// 参数:`isDark` 是否深色模式 |
|||
/// 返回:`Widget` 构建好的列表 |
|||
/// 异常:无 |
|||
Widget _buildAvailableWakeWords(bool isDark) { |
|||
final curId = controller.currentDeviceVoiceId.value; |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
const Text('可用唤醒词', |
|||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), |
|||
const SizedBox(height: 12), |
|||
if (controller.loadError.value.isNotEmpty) |
|||
Text(controller.loadError.value, |
|||
style: const TextStyle(color: Colors.red)), |
|||
// 仅展示非“已是最新”的可用唤醒词(排除当前使用版本) |
|||
...controller.voices |
|||
.where((v) => controller.actionTextForItem(v) != '已是最新') |
|||
.map((v) => _buildVoiceCard( |
|||
isDark: isDark, |
|||
item: v, |
|||
isCurrent: false, |
|||
hasUpdate: controller.actionTextForItem(v) == '升级', |
|||
)), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 比较版本是否更高(三段比较) |
|||
/// 参数:`a` 候选版本;`b` 当前版本(可空) |
|||
/// 返回:`bool` 若 `a` 高于 `b` 返回 `true` |
|||
/// 异常:无 |
|||
bool _versionHigherThan(String a, String? b) { |
|||
if (b == null) return true; |
|||
// 简易版本比较:按点位分割逐段比较 |
|||
List<int> pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList(); |
|||
List<int> pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList(); |
|||
for (int i = 0; i < 3; i++) { |
|||
final va = i < pa.length ? pa[i] : 0; |
|||
final vb = i < pb.length ? pb[i] : 0; |
|||
if (va != vb) return va > vb; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
/// 判断当前卡片是否存在可升级版本 |
|||
/// 参数:`curItem` 当前项(可空) |
|||
/// 返回:`bool` 存在升级返回 `true` |
|||
/// 异常:无 |
|||
bool _hasUpdateForCurrent(WakeupVoiceItem? curItem) { |
|||
if (curItem == null) return false; |
|||
final curVer = controller.currentDeviceVoiceVersion.value; |
|||
return _versionHigherThan(curItem.version, curVer); |
|||
} |
|||
|
|||
/// 构建通用语音卡片 |
|||
/// 参数:`isDark` 是否深色模式;`item` 语音项;`isCurrent` 是否当前项;`hasUpdate` 是否升级卡片 |
|||
/// 返回:`Widget` 卡片组件 |
|||
/// 异常:无 |
|||
Widget _buildVoiceCard({ |
|||
required bool isDark, |
|||
required WakeupVoiceItem? item, |
|||
required bool isCurrent, |
|||
bool hasUpdate = false, |
|||
}) { |
|||
if (item == null) { |
|||
if (isCurrent) { |
|||
return Container( |
|||
padding: const EdgeInsets.all(16), |
|||
decoration: BoxDecoration( |
|||
color: isDark ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: const Text('未设置'), |
|||
); |
|||
} else { |
|||
return const SizedBox.shrink(); |
|||
} |
|||
} |
|||
|
|||
final accuracyText = controller.extractAccuracy(item) ?? '识别率 -'; |
|||
final showCheck = isCurrent && !hasUpdate; |
|||
final actionColor = |
|||
hasUpdate ? const Color(0xFFFF7A00) : const Color(0xFF6A5AE0); |
|||
final actionText = controller.actionTextForItem(item); |
|||
final actionEnabled = controller.actionEnabledForItem(item); |
|||
final isUpgrading = |
|||
item != null && controller.upgradingVoiceId.value == item.id; |
|||
final progress = controller.upgradeProgress.value; |
|||
|
|||
return Container( |
|||
margin: const EdgeInsets.only(bottom: 12), |
|||
padding: const EdgeInsets.all(16), |
|||
decoration: BoxDecoration( |
|||
color: isDark ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Container( |
|||
width: 40, |
|||
height: 40, |
|||
decoration: BoxDecoration( |
|||
color: (hasUpdate |
|||
? const Color(0xFFFFE8D5) |
|||
: const Color(0xFFE8E5FF)), |
|||
borderRadius: BorderRadius.circular(10), |
|||
), |
|||
child: Icon( |
|||
hasUpdate ? Icons.mic : Icons.auto_awesome, |
|||
color: hasUpdate |
|||
? const Color(0xFFFF7A00) |
|||
: const Color(0xFF6A5AE0), |
|||
), |
|||
), |
|||
const SizedBox(width: 10), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: Text(item.name, |
|||
style: const TextStyle( |
|||
fontSize: 15, fontWeight: FontWeight.w600)), |
|||
), |
|||
Text('v${item.version}', |
|||
style: const TextStyle( |
|||
fontSize: 12, color: Colors.grey)), |
|||
], |
|||
), |
|||
const SizedBox(height: 4), |
|||
Text(accuracyText, |
|||
style: |
|||
const TextStyle(fontSize: 12, color: Colors.grey)), |
|||
], |
|||
), |
|||
), |
|||
if (showCheck) ...[ |
|||
const SizedBox(width: 8), |
|||
const Icon(Icons.check_circle, color: Color(0xFF6A5AE0)), |
|||
] |
|||
], |
|||
), |
|||
if (hasUpdate) ...[ |
|||
const SizedBox(height: 12), |
|||
Container( |
|||
padding: const EdgeInsets.all(12), |
|||
decoration: BoxDecoration( |
|||
color: isDark ? Colors.grey[850] : const Color(0xFFFFF4E8), |
|||
borderRadius: BorderRadius.circular(8), |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: const [ |
|||
Icon(Icons.upgrade, color: Color(0xFFFF7A00), size: 18), |
|||
SizedBox(width: 6), |
|||
Text('更新内容', |
|||
style: TextStyle( |
|||
fontSize: 13, |
|||
fontWeight: FontWeight.w600, |
|||
color: Color(0xFFFF7A00))), |
|||
], |
|||
), |
|||
const SizedBox(height: 6), |
|||
...controller.extractUpdateNotes(item).map((note) => Padding( |
|||
padding: const EdgeInsets.symmetric(vertical: 2), |
|||
child: Row( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
const Text('• ', |
|||
style: TextStyle(color: Colors.black54)), |
|||
Expanded( |
|||
child: Text(note, |
|||
style: const TextStyle( |
|||
fontSize: 12, color: Colors.black87)), |
|||
) |
|||
], |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
const SizedBox(height: 12), |
|||
SizedBox( |
|||
width: double.infinity, |
|||
child: ElevatedButton( |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: actionColor, |
|||
padding: const EdgeInsets.symmetric(vertical: 12), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(10)), |
|||
), |
|||
onPressed: isUpgrading || !actionEnabled |
|||
? null |
|||
: () { |
|||
if (actionText == '升级') { |
|||
controller.upgradeWakeVoiceTo(item); |
|||
} else if (actionText == '切换') { |
|||
controller.switchWakeVoiceTo(item); |
|||
} |
|||
}, |
|||
child: Text(actionText), |
|||
), |
|||
), |
|||
if (isUpgrading) ...[ |
|||
const SizedBox(height: 12), |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
const Icon(Icons.sync, size: 16, color: Color(0xFF6A5AE0)), |
|||
const SizedBox(width: 6), |
|||
Text('升级中... $progress%', |
|||
style: const TextStyle( |
|||
fontSize: 12, color: Colors.black54)), |
|||
], |
|||
), |
|||
const SizedBox(height: 6), |
|||
ClipRRect( |
|||
borderRadius: BorderRadius.circular(6), |
|||
child: LinearProgressIndicator( |
|||
value: (progress.clamp(0, 100)) / 100.0, |
|||
minHeight: 8, |
|||
backgroundColor: |
|||
isDark ? Colors.grey[800] : Colors.grey[300], |
|||
color: const Color(0xFF6A5AE0), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
], |
|||
), |
|||
); |
|||
} |
|||
// 底部按钮已移除,改为每个卡片独立的操作按钮以贴合设计 |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
# Miscellaneous |
|||
*.class |
|||
*.log |
|||
*.pyc |
|||
*.swp |
|||
.DS_Store |
|||
.atom/ |
|||
.build/ |
|||
.buildlog/ |
|||
.history |
|||
.svn/ |
|||
.swiftpm/ |
|||
migrate_working_dir/ |
|||
|
|||
# IntelliJ related |
|||
*.iml |
|||
*.ipr |
|||
*.iws |
|||
.idea/ |
|||
|
|||
# The .vscode folder contains launch configuration and tasks you configure in |
|||
# VS Code which you may wish to be included in version control, so this line |
|||
# is commented out by default. |
|||
#.vscode/ |
|||
|
|||
# Flutter/Dart/Pub related |
|||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. |
|||
/pubspec.lock |
|||
**/doc/api/ |
|||
.dart_tool/ |
|||
.flutter-plugins |
|||
.flutter-plugins-dependencies |
|||
build/ |
|||
@ -0,0 +1,30 @@ |
|||
# This file tracks properties of this Flutter project. |
|||
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
|||
# |
|||
# This file should be version controlled and should not be manually edited. |
|||
|
|||
version: |
|||
revision: "30ade5e937e6afbd2d2c38ec53175b89b556447e" |
|||
channel: "stable" |
|||
|
|||
project_type: plugin |
|||
|
|||
# Tracks metadata for the flutter migrate command |
|||
migration: |
|||
platforms: |
|||
- platform: root |
|||
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
|||
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
|||
- platform: android |
|||
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
|||
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
|||
|
|||
# User provided section |
|||
|
|||
# List of Local paths (relative to this file) that should be |
|||
# ignored by the migrate tool. |
|||
# |
|||
# Files that are not part of the templates will be ignored by default. |
|||
unmanaged_files: |
|||
- 'lib/main.dart' |
|||
- 'ios/Runner.xcodeproj/project.pbxproj' |
|||
@ -0,0 +1,3 @@ |
|||
## 0.0.1 |
|||
|
|||
* TODO: Describe initial release. |
|||
@ -0,0 +1 @@ |
|||
TODO: Add your license here. |
|||
@ -0,0 +1,15 @@ |
|||
# jielihome |
|||
|
|||
JieLi Home SDK Flutter plugin |
|||
|
|||
## Getting Started |
|||
|
|||
This project is a starting point for a Flutter |
|||
[plug-in package](https://flutter.dev/to/develop-plugins), |
|||
a specialized package that includes platform-specific implementation code for |
|||
Android and/or iOS. |
|||
|
|||
For help getting started with Flutter development, view the |
|||
[online documentation](https://docs.flutter.dev), which offers tutorials, |
|||
samples, guidance on mobile development, and a full API reference. |
|||
|
|||
@ -0,0 +1,4 @@ |
|||
include: package:flutter_lints/flutter.yaml |
|||
|
|||
# Additional information about this file can be found at |
|||
# https://dart.dev/guides/language/analysis-options |
|||
@ -0,0 +1,9 @@ |
|||
*.iml |
|||
.gradle |
|||
/local.properties |
|||
/.idea/workspace.xml |
|||
/.idea/libraries |
|||
.DS_Store |
|||
/build |
|||
/captures |
|||
.cxx |
|||
@ -0,0 +1,80 @@ |
|||
group = "com.jielihome.jielihome" |
|||
version = "1.0-SNAPSHOT" |
|||
|
|||
buildscript { |
|||
ext.kotlin_version = "2.1.0" |
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
} |
|||
|
|||
dependencies { |
|||
classpath("com.android.tools.build:gradle:8.7.3") |
|||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") |
|||
} |
|||
} |
|||
|
|||
allprojects { |
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
flatDir { |
|||
dirs "libs" |
|||
} |
|||
} |
|||
} |
|||
|
|||
apply plugin: "com.android.library" |
|||
apply plugin: "kotlin-android" |
|||
|
|||
android { |
|||
namespace = "com.jielihome.jielihome" |
|||
|
|||
compileSdk = 36 |
|||
|
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_17 |
|||
targetCompatibility = JavaVersion.VERSION_17 |
|||
} |
|||
|
|||
kotlinOptions { |
|||
jvmTarget = JavaVersion.VERSION_17 |
|||
} |
|||
|
|||
sourceSets { |
|||
main.java.srcDirs += "src/main/kotlin" |
|||
test.java.srcDirs += "src/test/kotlin" |
|||
} |
|||
|
|||
defaultConfig { |
|||
minSdk = 21 |
|||
} |
|||
|
|||
dependencies { |
|||
// 杰理原生 SDK:library 模块只能 compileOnly(AGP 限制:library 不能直接 bundle 本地 aar)。 |
|||
// host app 需在 android/app/build.gradle.kts 里把同名 aar 用 implementation 引入作运行时依赖。 |
|||
compileOnly fileTree(dir: 'libs', include: ['*.aar', '*.jar']) |
|||
// device_plugin_interface 提供 DeviceCallTranslationPort 等通用接口, |
|||
// 让 JieliCallTranslationPort 可以无 vendor 耦合地被 translate_server 消费。 |
|||
implementation project(':device_plugin_interface') |
|||
implementation "com.squareup.okhttp3:okhttp:4.12.0" |
|||
implementation "com.google.code.gson:gson:2.10.1" |
|||
implementation "androidx.core:core-ktx:1.13.1" |
|||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" |
|||
|
|||
testImplementation("org.jetbrains.kotlin:kotlin-test") |
|||
testImplementation("org.mockito:mockito-core:5.0.0") |
|||
} |
|||
|
|||
testOptions { |
|||
unitTests.all { |
|||
useJUnitPlatform() |
|||
|
|||
testLogging { |
|||
events "passed", "skipped", "failed", "standardOut", "standardError" |
|||
outputs.upToDateWhen {false} |
|||
showStandardStreams = true |
|||
} |
|||
} |
|||
} |
|||
} |
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@ |
|||
rootProject.name = 'jielihome' |
|||
@ -0,0 +1,12 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<uses-permission android:name="android.permission.INTERNET" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> |
|||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
|||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
|||
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
|||
</manifest> |
|||
@ -0,0 +1,41 @@ |
|||
package com.jielihome.jielihome |
|||
|
|||
import com.jielihome.jielihome.bridge.MethodRouter |
|||
import com.jielihome.jielihome.core.JieliHomeServer |
|||
import com.jielihome.jielihome.integration.JieliNativeDevicePlugin |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.plugin.common.EventChannel |
|||
import io.flutter.plugin.common.MethodChannel |
|||
|
|||
/** |
|||
* Flutter 入口,仅负责注册 Channel 并把请求转交给 MethodRouter。 |
|||
* 全部业务逻辑在 [JieliHomeServer] 与 feature/event 子模块中。 |
|||
*/ |
|||
class JielihomePlugin : FlutterPlugin { |
|||
|
|||
private lateinit var methodChannel: MethodChannel |
|||
private lateinit var eventChannel: EventChannel |
|||
private lateinit var router: MethodRouter |
|||
|
|||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
val server = JieliHomeServer.get() |
|||
router = MethodRouter(binding.applicationContext, server) |
|||
|
|||
methodChannel = MethodChannel(binding.binaryMessenger, "device_jieli/method") |
|||
methodChannel.setMethodCallHandler(router) |
|||
|
|||
eventChannel = EventChannel(binding.binaryMessenger, "device_jieli/event") |
|||
eventChannel.setStreamHandler(server.dispatcher) |
|||
|
|||
// 把杰理 vendor 注册到 device_manager 的 NativeDevicePluginRegistry: |
|||
// 这样 app 走 device_manager 路径时能 listVendors / useVendor("jieli")。 |
|||
// SDK 直连路径(device_jieli/method 上面那条)保留不变;两条路径并存。 |
|||
JieliNativeDevicePlugin.register(binding.applicationContext) |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
eventChannel.setStreamHandler(null) |
|||
// 进程级资源不在这里 shutdown,保持后续 attach 仍可继续使用 |
|||
} |
|||
} |
|||
@ -0,0 +1,153 @@ |
|||
package com.jielihome.jielihome.api |
|||
|
|||
/** |
|||
* 原生层事件监听器。宿主 App 在 Activity/Service 里实现并注册到 |
|||
* [com.jielihome.jielihome.core.JieliHomeServer.addEventListener], |
|||
* 即可订阅插件所有事件而不依赖 Flutter EventChannel。 |
|||
* |
|||
* 所有回调里的 payload 都是 [Map],字段与 Dart 侧 EventChannel 推上去的完全一致, |
|||
* 因此原生订阅与 Dart 订阅可以并存(fan-out)。 |
|||
* |
|||
* 默认提供「按 type 路由」的便捷扩展,子类只覆盖关心的方法即可。 |
|||
*/ |
|||
interface JieliEventListener { |
|||
/** 任意事件,原始 payload。可以自己按 payload["type"] 路由。 */ |
|||
fun onEvent(payload: Map<String, Any?>) {} |
|||
} |
|||
|
|||
/** |
|||
* 按事件类型分发的便捷适配器。子类只覆盖关心的回调,未覆盖的事件会落到 [onUnknown]。 |
|||
*/ |
|||
abstract class JieliEventAdapter : JieliEventListener { |
|||
|
|||
override fun onEvent(payload: Map<String, Any?>) { |
|||
when (payload["type"] as? String) { |
|||
"adapterStatus" -> onAdapterStatus( |
|||
enabled = payload["enabled"] as? Boolean ?: false, |
|||
hasBle = payload["hasBle"] as? Boolean ?: false, |
|||
) |
|||
|
|||
"scanStatus" -> onScanStatus( |
|||
ble = payload["ble"] as? Boolean ?: false, |
|||
started = payload["started"] as? Boolean ?: false, |
|||
) |
|||
|
|||
"deviceFound" -> onDeviceFound(payload) |
|||
"bondStatus" -> onBondStatus( |
|||
address = payload["address"] as? String ?: "", |
|||
status = payload["status"] as? Int ?: 0, |
|||
) |
|||
"rcspInit" -> onRcspInit( |
|||
address = payload["address"] as? String ?: "", |
|||
code = payload["code"] as? Int ?: -1, |
|||
) |
|||
"connectionState" -> onConnectionState( |
|||
address = payload["address"] as? String ?: "", |
|||
state = payload["state"] as? Int ?: -1, |
|||
) |
|||
|
|||
"battery" -> onBattery( |
|||
address = payload["address"] as? String, |
|||
level = payload["level"] as? Int, |
|||
) |
|||
"phoneCallStatus" -> onPhoneCallStatus( |
|||
address = payload["address"] as? String, |
|||
status = payload["status"] as? Int ?: 0, |
|||
) |
|||
"voiceMode" -> onVoiceMode( |
|||
address = payload["address"] as? String, |
|||
modeId = payload["modeId"] as? Int, |
|||
) |
|||
|
|||
"volume" -> onVolume(payload) |
|||
"musicName" -> onMusicName( |
|||
address = payload["address"] as? String, |
|||
name = payload["name"] as? String, |
|||
) |
|||
"musicStatus" -> onMusicStatus(payload) |
|||
"playMode" -> onPlayMode( |
|||
address = payload["address"] as? String, |
|||
mode = payload["mode"] as? Int, |
|||
) |
|||
|
|||
"expandFunction" -> onExpandFunction(payload) |
|||
|
|||
"translationAudio" -> { |
|||
onTranslationAudio(payload) |
|||
onTranslationEvent(payload) |
|||
} |
|||
"translationResult" -> { |
|||
onTranslationResult(payload) |
|||
onTranslationEvent(payload) |
|||
} |
|||
"translationError" -> { |
|||
onTranslationError(payload) |
|||
onTranslationEvent(payload) |
|||
} |
|||
"translationLog" -> { |
|||
onTranslationLog(payload) |
|||
onTranslationEvent(payload) |
|||
} |
|||
|
|||
"speechStart" -> onSpeechStart(payload) |
|||
"speechAudio" -> onSpeechAudio(payload) |
|||
"speechEnd" -> onSpeechEnd(payload) |
|||
"speechError" -> onSpeechError(payload) |
|||
|
|||
"otaState" -> onOtaState(payload) |
|||
"otaError" -> onOtaError(payload) |
|||
|
|||
else -> onUnknown(payload) |
|||
} |
|||
} |
|||
|
|||
open fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) {} |
|||
open fun onScanStatus(ble: Boolean, started: Boolean) {} |
|||
open fun onDeviceFound(payload: Map<String, Any?>) {} |
|||
open fun onBondStatus(address: String, status: Int) {} |
|||
open fun onRcspInit(address: String, code: Int) {} |
|||
open fun onConnectionState(address: String, state: Int) {} |
|||
|
|||
open fun onBattery(address: String?, level: Int?) {} |
|||
open fun onPhoneCallStatus(address: String?, status: Int) {} |
|||
open fun onVoiceMode(address: String?, modeId: Int?) {} |
|||
|
|||
open fun onVolume(payload: Map<String, Any?>) {} |
|||
open fun onMusicName(address: String?, name: String?) {} |
|||
open fun onMusicStatus(payload: Map<String, Any?>) {} |
|||
open fun onPlayMode(address: String?, mode: Int?) {} |
|||
|
|||
open fun onExpandFunction(payload: Map<String, Any?>) {} |
|||
|
|||
/** 翻译事件统一入口(兜底,默认细分回调之后还会触发一次) */ |
|||
open fun onTranslationEvent(payload: Map<String, Any?>) {} |
|||
|
|||
/** payload["pcm"] 为 ByteArray;附带 streamId/sampleRate/seq/tsMs/final 字段 */ |
|||
open fun onTranslationAudio(payload: Map<String, Any?>) {} |
|||
|
|||
/** payload 含 srcLang/srcText/destLang/destText/requestId */ |
|||
open fun onTranslationResult(payload: Map<String, Any?>) {} |
|||
|
|||
/** payload 含 code/message */ |
|||
open fun onTranslationError(payload: Map<String, Any?>) {} |
|||
|
|||
open fun onTranslationLog(payload: Map<String, Any?>) {} |
|||
|
|||
/** 耳机检测到唤醒/语音助手触发;payload: voiceType, sampleRate, vadWay, address, tsMs */ |
|||
open fun onSpeechStart(payload: Map<String, Any?>) {} |
|||
|
|||
/** 语音助手音频帧;payload: pcm(ByteArray), encoding, sampleRate, channels, address */ |
|||
open fun onSpeechAudio(payload: Map<String, Any?>) {} |
|||
|
|||
/** 语音助手会话结束;payload: reason, message, address */ |
|||
open fun onSpeechEnd(payload: Map<String, Any?>) {} |
|||
|
|||
open fun onSpeechError(payload: Map<String, Any?>) {} |
|||
|
|||
/** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */ |
|||
open fun onOtaState(payload: Map<String, Any?>) {} |
|||
|
|||
open fun onOtaError(payload: Map<String, Any?>) {} |
|||
|
|||
open fun onUnknown(payload: Map<String, Any?>) {} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
package com.jielihome.jielihome.audio |
|||
|
|||
import android.media.AudioAttributes |
|||
import android.media.AudioFormat |
|||
import android.media.AudioManager |
|||
import android.media.AudioTrack |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
|
|||
/** |
|||
* 本地 PCM 播放(A2DP 路径)。用于音视频翻译模式:TTS 不回耳机,直接走 A2DP。 |
|||
*/ |
|||
class LocalPlayer( |
|||
private val sampleRate: Int = 16000, |
|||
private val channels: Int = 1, |
|||
) { |
|||
private var track: AudioTrack? = null |
|||
private val playing = AtomicBoolean(false) |
|||
|
|||
fun start() { |
|||
if (playing.get()) return |
|||
val cfg = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO |
|||
val minBuf = AudioTrack.getMinBufferSize(sampleRate, cfg, AudioFormat.ENCODING_PCM_16BIT) |
|||
|
|||
track = AudioTrack.Builder() |
|||
.setAudioAttributes( |
|||
AudioAttributes.Builder() |
|||
.setUsage(AudioAttributes.USAGE_MEDIA) |
|||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) |
|||
.build() |
|||
) |
|||
.setAudioFormat( |
|||
AudioFormat.Builder() |
|||
.setSampleRate(sampleRate) |
|||
.setChannelMask(cfg) |
|||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) |
|||
.build() |
|||
) |
|||
.setBufferSizeInBytes(maxOf(minBuf, sampleRate * 2 * channels)) // 1s |
|||
.setTransferMode(AudioTrack.MODE_STREAM) |
|||
.build() |
|||
track?.play() |
|||
playing.set(true) |
|||
} |
|||
|
|||
fun feed(pcm: ByteArray) { |
|||
if (!playing.get()) return |
|||
runCatching { track?.write(pcm, 0, pcm.size) } |
|||
} |
|||
|
|||
fun stop() { |
|||
if (!playing.compareAndSet(true, false)) return |
|||
runCatching { track?.stop() } |
|||
runCatching { track?.release() } |
|||
track = null |
|||
} |
|||
|
|||
@Suppress("unused") |
|||
fun setVolumeFollowingSystemMedia() { |
|||
// hint: 用 AudioManager.STREAM_MUSIC 已由 USAGE_MEDIA 隐式选择 |
|||
@Suppress("DEPRECATION") |
|||
AudioManager.STREAM_MUSIC // no-op |
|||
} |
|||
} |
|||
@ -0,0 +1,104 @@ |
|||
package com.jielihome.jielihome.audio |
|||
|
|||
import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback |
|||
import com.jieli.jl_audio_decode.callback.OnEncodeStreamCallback |
|||
import com.jieli.jl_audio_decode.opus.OpusManager |
|||
import com.jieli.jl_audio_decode.opus.model.OpusOption |
|||
|
|||
/** |
|||
* OPUS 流式解码器。 |
|||
* 输入:从耳机收到的 OPUS 编码帧 |
|||
* 输出:16kHz / mono PCM 流 |
|||
* |
|||
* 立体声场景把 [channel] 设 2 + [packetSize] 设 80。 |
|||
*/ |
|||
class OpusStreamDecoder( |
|||
private val channel: Int = 1, |
|||
private val packetSize: Int = 200, |
|||
private val sampleRate: Int = 16000, |
|||
private val hasHead: Boolean = false, |
|||
private val onPcm: (pcm: ByteArray) -> Unit, |
|||
private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, |
|||
) { |
|||
private val manager = OpusManager() |
|||
|
|||
@Volatile |
|||
private var started = false |
|||
|
|||
fun start() { |
|||
if (started) return |
|||
val option = OpusOption() |
|||
.setHasHead(hasHead) |
|||
.setChannel(channel) |
|||
.setPacketSize(packetSize) |
|||
.setSampleRate(sampleRate) |
|||
manager.startDecodeStream(option, object : OnDecodeStreamCallback { |
|||
override fun onDecodeStream(pcm: ByteArray) { runCatching { onPcm(pcm) } } |
|||
override fun onStart() {} |
|||
override fun onComplete(p0: String?) {} |
|||
override fun onError(code: Int, msg: String?) { |
|||
runCatching { onError(code, msg) } |
|||
} |
|||
}) |
|||
started = true |
|||
} |
|||
|
|||
fun feedEncoded(opus: ByteArray) { |
|||
if (!started) return |
|||
runCatching { manager.writeAudioStream(opus) } |
|||
} |
|||
|
|||
fun stop() { |
|||
if (!started) return |
|||
runCatching { manager.stopDecodeStream() } |
|||
runCatching { manager.release() } |
|||
started = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* OPUS 流式编码器。把 PCM 编码成 OPUS 帧后回调 [onOpus],由调用方包成 AudioData 回送给设备。 |
|||
*/ |
|||
class OpusStreamEncoder( |
|||
private val channel: Int = 1, |
|||
private val packetSize: Int = 200, |
|||
private val sampleRate: Int = 16000, |
|||
private val hasHead: Boolean = false, |
|||
private val onOpus: (opus: ByteArray) -> Unit, |
|||
private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, |
|||
) { |
|||
private val manager = OpusManager() |
|||
|
|||
@Volatile |
|||
private var started = false |
|||
|
|||
fun start() { |
|||
if (started) return |
|||
val option = OpusOption() |
|||
.setHasHead(hasHead) |
|||
.setChannel(channel) |
|||
.setPacketSize(packetSize) |
|||
.setSampleRate(sampleRate) |
|||
manager.startEncodeStream(option, object : OnEncodeStreamCallback { |
|||
override fun onEncodeStream(opus: ByteArray) { runCatching { onOpus(opus) } } |
|||
override fun onStart() {} |
|||
override fun onComplete(p0: String?) {} |
|||
override fun onError(code: Int, msg: String?) { |
|||
runCatching { onError(code, msg) } |
|||
} |
|||
}) |
|||
started = true |
|||
} |
|||
|
|||
fun feedPcm(pcm: ByteArray) { |
|||
if (!started) return |
|||
runCatching { manager.writeEncodeStream(pcm) } |
|||
} |
|||
|
|||
fun stop() { |
|||
if (!started) return |
|||
runCatching { manager.stopEncodeStream() } |
|||
runCatching { manager.release() } |
|||
started = false |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
package com.jielihome.jielihome.audio |
|||
|
|||
object PcmKit { |
|||
/** |
|||
* 把 16bit 立体声 PCM 拆为左/右两路单声道。 |
|||
* 入参字节流:L0_lo L0_hi R0_lo R0_hi L1_lo L1_hi R1_lo R1_hi ... |
|||
* 返回:Pair(leftMono, rightMono) |
|||
*/ |
|||
fun splitStereo16(pcm: ByteArray): Pair<ByteArray, ByteArray> { |
|||
val frames = pcm.size / 4 |
|||
val left = ByteArray(frames * 2) |
|||
val right = ByteArray(frames * 2) |
|||
var li = 0 |
|||
var ri = 0 |
|||
var i = 0 |
|||
while (i + 3 < pcm.size) { |
|||
left[li++] = pcm[i] |
|||
left[li++] = pcm[i + 1] |
|||
right[ri++] = pcm[i + 2] |
|||
right[ri++] = pcm[i + 3] |
|||
i += 4 |
|||
} |
|||
return left to right |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
package com.jielihome.jielihome.audio |
|||
|
|||
import android.Manifest |
|||
import android.annotation.SuppressLint |
|||
import android.content.Context |
|||
import android.content.pm.PackageManager |
|||
import android.media.AudioFormat |
|||
import android.media.AudioRecord |
|||
import android.media.MediaRecorder |
|||
import android.util.Log |
|||
import androidx.core.content.ContextCompat |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
import kotlin.concurrent.thread |
|||
|
|||
/** |
|||
* 手机麦克风采集,输出 16kHz / 16bit / mono PCM。 |
|||
* 每 20ms 切一帧(320 samples = 640 bytes)。 |
|||
* |
|||
* 用法: |
|||
* val mic = PhoneMicCapture(context) { pcm -> /* push to bridge */ } |
|||
* mic.start() |
|||
* ... |
|||
* mic.stop() |
|||
*/ |
|||
class PhoneMicCapture( |
|||
private val context: Context, |
|||
private val sampleRate: Int = 16000, |
|||
private val frameDurationMs: Int = 20, |
|||
private val onFrame: (pcm: ByteArray) -> Unit, |
|||
private val onError: (code: Int, msg: String) -> Unit = { _, _ -> }, |
|||
) { |
|||
private val running = AtomicBoolean(false) |
|||
private var recorder: AudioRecord? = null |
|||
private var worker: Thread? = null |
|||
|
|||
val frameSizeBytes: Int = sampleRate * frameDurationMs / 1000 * 2 /* 16bit */ |
|||
|
|||
@SuppressLint("MissingPermission") |
|||
fun start(): Boolean { |
|||
if (running.get()) return true |
|||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) |
|||
!= PackageManager.PERMISSION_GRANTED |
|||
) { |
|||
Log.w(TAG, "RECORD_AUDIO permission missing") |
|||
onError(-101, "RECORD_AUDIO permission missing") |
|||
return false |
|||
} |
|||
|
|||
val minBuf = AudioRecord.getMinBufferSize( |
|||
sampleRate, |
|||
AudioFormat.CHANNEL_IN_MONO, |
|||
AudioFormat.ENCODING_PCM_16BIT |
|||
) |
|||
val bufSize = maxOf(minBuf, frameSizeBytes * 4) |
|||
|
|||
val ar = AudioRecord( |
|||
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
|||
sampleRate, |
|||
AudioFormat.CHANNEL_IN_MONO, |
|||
AudioFormat.ENCODING_PCM_16BIT, |
|||
bufSize |
|||
) |
|||
if (ar.state != AudioRecord.STATE_INITIALIZED) { |
|||
ar.release() |
|||
Log.w(TAG, "AudioRecord init failed") |
|||
onError(-102, "AudioRecord init failed (state=${ar.state})") |
|||
return false |
|||
} |
|||
recorder = ar |
|||
running.set(true) |
|||
ar.startRecording() |
|||
|
|||
worker = thread(name = "device-jieli-mic", isDaemon = true) { |
|||
val frame = ByteArray(frameSizeBytes) |
|||
while (running.get()) { |
|||
val n = try { ar.read(frame, 0, frameSizeBytes) } catch (t: Throwable) { |
|||
onError(-103, "AudioRecord.read crashed: ${t.message}"); -1 |
|||
} |
|||
when { |
|||
n < 0 -> { onError(-104, "AudioRecord.read err code=$n"); break } |
|||
n == 0 -> continue |
|||
n == frameSizeBytes -> onFrame(frame.copyOf()) |
|||
else -> onFrame(frame.copyOf(n)) |
|||
} |
|||
} |
|||
} |
|||
return true |
|||
} |
|||
|
|||
fun stop() { |
|||
if (!running.compareAndSet(true, false)) return |
|||
runCatching { recorder?.stop() } |
|||
runCatching { recorder?.release() } |
|||
recorder = null |
|||
worker?.join(200) |
|||
worker = null |
|||
} |
|||
|
|||
companion object { |
|||
private const val TAG = "PhoneMicCapture" |
|||
} |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
package com.jielihome.jielihome.bridge |
|||
|
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import com.jielihome.jielihome.api.JieliEventListener |
|||
import io.flutter.plugin.common.EventChannel |
|||
import java.util.concurrent.CopyOnWriteArrayList |
|||
|
|||
/** |
|||
* 事件总线。所有 forwarder / feature 调用 [send],由本类做 fan-out: |
|||
* - Flutter 端:通过 EventChannel.EventSink 推到 Dart |
|||
* - 原生端:通过 [JieliEventListener] 推给宿主直接订阅 |
|||
* |
|||
* Flutter / 原生 / 多个原生监听器 可以同时存在;payload 形态完全一致。 |
|||
*/ |
|||
class EventDispatcher : EventChannel.StreamHandler { |
|||
|
|||
private val mainHandler = Handler(Looper.getMainLooper()) |
|||
|
|||
@Volatile |
|||
private var sink: EventChannel.EventSink? = null |
|||
|
|||
private val nativeListeners = CopyOnWriteArrayList<JieliEventListener>() |
|||
|
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
sink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
sink = null |
|||
} |
|||
|
|||
fun addNativeListener(listener: JieliEventListener) { |
|||
if (!nativeListeners.contains(listener)) nativeListeners.add(listener) |
|||
} |
|||
|
|||
fun removeNativeListener(listener: JieliEventListener) { |
|||
nativeListeners.remove(listener) |
|||
} |
|||
|
|||
fun send(payload: Map<String, Any?>) { |
|||
// 原生监听同步派发(在事件原始线程,不经主线程切换,方便高频音频帧) |
|||
for (l in nativeListeners) { |
|||
try { l.onEvent(payload) } catch (_: Throwable) { /* swallow listener errors */ } |
|||
} |
|||
// Flutter 端必须在主线程 |
|||
val s = sink ?: return |
|||
mainHandler.post { runCatching { s.success(payload) } } |
|||
} |
|||
} |
|||
@ -0,0 +1,227 @@ |
|||
package com.jielihome.jielihome.bridge |
|||
|
|||
import android.content.Context |
|||
import com.jielihome.jielihome.core.JieliHomeServer |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
|
|||
/** |
|||
* MethodChannel 路由层:把 Dart 调用映射到 server 内的 feature 模块。 |
|||
* Plugin 类不直接处理任何业务逻辑,只装配 router。 |
|||
*/ |
|||
class MethodRouter( |
|||
private val context: Context, |
|||
private val server: JieliHomeServer, |
|||
) : MethodChannel.MethodCallHandler { |
|||
|
|||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { |
|||
try { |
|||
when (call.method) { |
|||
"getPlatformVersion" -> |
|||
result.success("Android ${android.os.Build.VERSION.RELEASE}") |
|||
|
|||
"initialize" -> { |
|||
server.initialize( |
|||
context = context, |
|||
multiDevice = call.argument<Boolean>("multiDevice") ?: true, |
|||
skipNoNameDev = call.argument<Boolean>("skipNoNameDev") ?: false, |
|||
enableLog = call.argument<Boolean>("enableLog") ?: false, |
|||
) |
|||
result.success(true) |
|||
} |
|||
|
|||
"startScan" -> { |
|||
val timeout = call.argument<Int>("timeoutMs") ?: 30000 |
|||
server.scanFeature.startScan(timeout) |
|||
.fold({ result.success(true) }, |
|||
{ result.error("SCAN_FAILED", it.message, null) }) |
|||
} |
|||
|
|||
"stopScan" -> { server.scanFeature.stopScan(); result.success(true) } |
|||
|
|||
"isScanning" -> result.success(server.scanFeature.isScanning()) |
|||
|
|||
"connect" -> { |
|||
val mac = call.argument<String>("address") |
|||
?: return result.error("BAD_ARG", "address required", null) |
|||
server.connectFeature.connect( |
|||
bleAddress = mac, |
|||
edrAddress = call.argument<String>("edrAddr"), |
|||
deviceType = call.argument<Int>("deviceType") ?: -1, |
|||
connectWay = call.argument<Int>("connectWay") ?: 0, |
|||
).fold({ result.success(true) }, |
|||
{ result.error("CONNECT_FAILED", it.message, null) }) |
|||
} |
|||
|
|||
"disconnect" -> { |
|||
val mac = call.argument<String>("address") |
|||
?: return result.error("BAD_ARG", "address required", null) |
|||
server.connectFeature.disconnect(mac) |
|||
.fold({ result.success(true) }, |
|||
{ result.error("DISCONNECT_FAILED", it.message, null) }) |
|||
} |
|||
|
|||
"isConnected" -> { |
|||
val mac = call.argument<String>("address") |
|||
?: return result.error("BAD_ARG", "address required", null) |
|||
result.success(server.connectFeature.isConnected(mac)) |
|||
} |
|||
|
|||
"connectedDevice" -> { |
|||
val d = server.connectFeature.connectedDevice() |
|||
if (d == null) result.success(null) |
|||
else result.success( |
|||
mapOf( |
|||
"address" to d.address, |
|||
"name" to runCatching { d.name }.getOrNull() |
|||
) |
|||
) |
|||
} |
|||
|
|||
"deviceSnapshot" -> { |
|||
val mac = call.argument<String>("address") |
|||
?: return result.error("BAD_ARG", "address required", null) |
|||
result.success(server.deviceInfoFeature.snapshot(mac)) |
|||
} |
|||
|
|||
"queryTargetInfo" -> { |
|||
val mac = call.argument<String>("address") |
|||
?: return result.error("BAD_ARG", "address required", null) |
|||
val mask = call.argument<Int>("mask") ?: 0x0F |
|||
server.deviceInfoFeature.queryTargetInfo( |
|||
mac, mask, |
|||
onSuccess = { result.success(it) }, |
|||
onError = { c, m -> result.error("TARGET_INFO_ERR", m, c) } |
|||
) |
|||
} |
|||
|
|||
"sendCustomCmd" -> { |
|||
val mac = call.argument<String>("address") |
|||
?: return result.error("BAD_ARG", "address required", null) |
|||
val opCode = call.argument<Int>("opCode") |
|||
?: return result.error("BAD_ARG", "opCode required", null) |
|||
val payload = call.argument<ByteArray>("payload") ?: ByteArray(0) |
|||
server.customCmdFeature.send( |
|||
mac, opCode, payload, |
|||
onSuccess = { result.success(it) }, |
|||
onError = { c, m -> result.error("CUSTOM_CMD_ERR", m, c) } |
|||
) |
|||
} |
|||
|
|||
"startTranslation" -> { |
|||
val modeId = call.argument<Int>("modeId") |
|||
?: return result.error("BAD_ARG", "modeId required", null) |
|||
@Suppress("UNCHECKED_CAST") |
|||
val args = (call.argument<Map<String, Any?>>("args") ?: emptyMap()) |
|||
server.translationFeature.start(modeId, args) |
|||
.fold({ result.success(true) }, |
|||
{ result.error("TRANSLATION_ERR", it.message, null) }) |
|||
} |
|||
|
|||
"stopTranslation" -> { |
|||
server.translationFeature.stop(); result.success(true) |
|||
} |
|||
|
|||
"translationStatus" -> result.success( |
|||
mapOf( |
|||
"working" to server.translationFeature.isWorking(), |
|||
"modeId" to server.translationFeature.currentModeId(), |
|||
"inputStreams" to server.translationFeature.currentInputStreams(), |
|||
"outputStreams" to server.translationFeature.currentOutputStreams() |
|||
) |
|||
) |
|||
|
|||
"feedTranslatedAudio" -> { |
|||
val streamId = call.argument<String>("streamId") |
|||
?: return result.error("BAD_ARG", "streamId required", null) |
|||
val pcm = call.argument<ByteArray>("pcm") |
|||
?: return result.error("BAD_ARG", "pcm required", null) |
|||
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
|||
val channels = call.argument<Int>("channels") ?: 1 |
|||
val bits = call.argument<Int>("bitsPerSample") ?: 16 |
|||
val isFinal = call.argument<Boolean>("final") ?: false |
|||
val ok = server.translationFeature.feedTranslatedAudio( |
|||
streamId, pcm, |
|||
com.jielihome.jielihome.feature.translation.AudioFormat(sampleRate, channels, bits), |
|||
isFinal |
|||
) |
|||
result.success(ok) |
|||
} |
|||
|
|||
"feedTranslationResult" -> { |
|||
server.translationFeature.feedTranslationResult( |
|||
srcLang = call.argument<String>("srcLang"), |
|||
srcText = call.argument<String>("srcText"), |
|||
destLang = call.argument<String>("destLang"), |
|||
destText = call.argument<String>("destText"), |
|||
requestId = call.argument<String>("requestId"), |
|||
) |
|||
result.success(true) |
|||
} |
|||
|
|||
"isSupportCallTranslationWithStereo" -> { |
|||
val mac = call.argument<String>("address") |
|||
result.success(server.translationFeature.isSupportCallTranslationWithStereo(mac)) |
|||
} |
|||
|
|||
"feedAudioFilePcm" -> { |
|||
val pcm = call.argument<ByteArray>("pcm") |
|||
?: return result.error("BAD_ARG", "pcm required", null) |
|||
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
|||
result.success(server.translationFeature.feedAudioFilePcm(pcm, sampleRate)) |
|||
} |
|||
|
|||
"speechIsRecording" -> |
|||
result.success(server.speechFeature.isRecording(call.argument<String>("address"))) |
|||
|
|||
"speechStart" -> { |
|||
server.speechFeature.start( |
|||
address = call.argument<String>("address"), |
|||
voiceType = call.argument<Int>("voiceType") |
|||
?: com.jieli.bluetooth.bean.record.RecordParam.VOICE_TYPE_OPUS, |
|||
sampleRate = call.argument<Int>("sampleRate") |
|||
?: com.jieli.bluetooth.bean.record.RecordParam.SAMPLE_RATE_16K, |
|||
vadWay = call.argument<Int>("vadWay") |
|||
?: com.jieli.bluetooth.bean.record.RecordParam.VAD_WAY_DEVICE, |
|||
onResult = { ok, msg -> |
|||
if (ok) result.success(true) |
|||
else result.error("SPEECH_START_ERR", msg, null) |
|||
} |
|||
) |
|||
} |
|||
|
|||
"speechStop" -> { |
|||
server.speechFeature.stop( |
|||
address = call.argument<String>("address"), |
|||
reason = call.argument<Int>("reason") |
|||
?: com.jieli.bluetooth.bean.record.RecordState.REASON_NORMAL, |
|||
onResult = { ok, msg -> |
|||
if (ok) result.success(true) |
|||
else result.error("SPEECH_STOP_ERR", msg, null) |
|||
} |
|||
) |
|||
} |
|||
|
|||
"otaStart" -> { |
|||
val path = call.argument<String>("firmwareFilePath") |
|||
?: return result.error("BAD_ARG", "firmwareFilePath required", null) |
|||
server.otaFeature.start( |
|||
address = call.argument<String>("address"), |
|||
firmwareFilePath = path, |
|||
blockSize = call.argument<Int>("blockSize") ?: 512, |
|||
fileFlagBytes = call.argument<ByteArray>("fileFlag") ?: ByteArray(0), |
|||
) |
|||
result.success(true) |
|||
} |
|||
|
|||
"otaCancel" -> { server.otaFeature.cancel(); result.success(true) } |
|||
|
|||
"otaIsRunning" -> result.success(server.otaFeature.isRunning()) |
|||
|
|||
else -> result.notImplemented() |
|||
} |
|||
} catch (t: Throwable) { |
|||
result.error("PLUGIN_ERR", t.message, t.stackTraceToString()) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,195 @@ |
|||
package com.jielihome.jielihome.core |
|||
|
|||
import android.content.Context |
|||
import com.jieli.bluetooth.bean.BluetoothOption |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.impl.rcsp.RCSPController |
|||
import com.jieli.bluetooth.utils.JL_Log |
|||
import com.jielihome.jielihome.api.JieliEventListener |
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
import com.jielihome.jielihome.event.BluetoothEventForwarder |
|||
import com.jielihome.jielihome.event.CustomEventForwarder |
|||
import com.jielihome.jielihome.event.DeviceInfoEventForwarder |
|||
import com.jielihome.jielihome.event.MediaEventForwarder |
|||
import com.jielihome.jielihome.feature.ConnectFeature |
|||
import com.jielihome.jielihome.feature.CustomCmdFeature |
|||
import com.jielihome.jielihome.feature.DeviceInfoFeature |
|||
import com.jielihome.jielihome.feature.ScanFeature |
|||
import com.jielihome.jielihome.feature.ota.OtaFeature |
|||
import com.jielihome.jielihome.feature.translation.EventChannelAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationFeature |
|||
import com.jielihome.jielihome.feature.voice.SpeechFeature |
|||
|
|||
/** |
|||
* 全局单例。原生层和 Flutter 层共用同一个 server。 |
|||
* |
|||
* 原生用法: |
|||
* ``` |
|||
* val server = JieliHomeServer.get() |
|||
* server.initialize(context, multiDevice = true, skipNoNameDev = false, enableLog = true) |
|||
* server.addEventListener(myListener) // 订阅事件 |
|||
* server.scanFeature.startScan(30_000) |
|||
* server.translationFeature.start(MODE_CALL_TRANSLATION, mapOf("address" to mac)) |
|||
* server.translationFeature.feedTranslatedAudio("uplink", pcm, AudioFormat(), false) |
|||
* ``` |
|||
* |
|||
* Flutter 侧由 [com.jielihome.jielihome.JielihomePlugin] 自动绑定 EventChannel + MethodChannel。 |
|||
* 即使没有 Flutter,原生也能完整使用 server。 |
|||
*/ |
|||
class JieliHomeServer private constructor() { |
|||
|
|||
companion object { |
|||
@Volatile |
|||
private var instance: JieliHomeServer? = null |
|||
|
|||
@JvmStatic |
|||
fun get(): JieliHomeServer = instance ?: synchronized(this) { |
|||
instance ?: JieliHomeServer().also { instance = it } |
|||
} |
|||
} |
|||
|
|||
@Volatile |
|||
var initialized: Boolean = false |
|||
private set |
|||
|
|||
/** 事件总线,原生层通过 [addEventListener] 订阅;Flutter 自动注入 EventChannel sink */ |
|||
val dispatcher: EventDispatcher = EventDispatcher() |
|||
|
|||
private lateinit var btManager: JL_BluetoothManager |
|||
|
|||
private lateinit var bluetoothForwarder: BluetoothEventForwarder |
|||
private lateinit var deviceInfoForwarder: DeviceInfoEventForwarder |
|||
private lateinit var mediaForwarder: MediaEventForwarder |
|||
private lateinit var customForwarder: CustomEventForwarder |
|||
|
|||
lateinit var scanFeature: ScanFeature |
|||
private set |
|||
lateinit var connectFeature: ConnectFeature |
|||
private set |
|||
lateinit var deviceInfoFeature: DeviceInfoFeature |
|||
private set |
|||
lateinit var customCmdFeature: CustomCmdFeature |
|||
private set |
|||
lateinit var translationFeature: TranslationFeature |
|||
private set |
|||
lateinit var speechFeature: SpeechFeature |
|||
private set |
|||
lateinit var otaFeature: OtaFeature |
|||
private set |
|||
|
|||
/** 默认翻译音频桥(EventChannel 透传到 Dart);宿主 native 想完全自管时可调 |
|||
* [TranslationFeature.setBridge] 替换。 |
|||
* |
|||
* 暴露 getter 供 native 编排器(如 [JieliCallTranslationPort])在 enter/exit |
|||
* 时拿到原始引用做"装上自己的 capture bridge / 退出后还原"切换。 |
|||
*/ |
|||
lateinit var defaultBridge: EventChannelAudioBridge |
|||
private set |
|||
|
|||
fun initialize( |
|||
context: Context, |
|||
multiDevice: Boolean = true, |
|||
skipNoNameDev: Boolean = false, |
|||
enableLog: Boolean = false, |
|||
) { |
|||
if (initialized) return |
|||
|
|||
JL_Log.configureLog(context, enableLog, enableLog) |
|||
|
|||
val option = BluetoothOption.createDefaultOption().also { |
|||
it.isUseMultiDevice = multiDevice |
|||
it.isSkipNoNameDev = skipNoNameDev |
|||
// 清空杰理协议广播过滤头:默认值会让 SDK 只回调匹配杰理私有广播头的设备, |
|||
// 导致 onDiscovery 在普通 BLE 设备 / 测试场景下完全不触发。 |
|||
it.scanFilterData = "" |
|||
it.otaScanFilterData = "" |
|||
} |
|||
RCSPController.init(context, option) |
|||
|
|||
btManager = RCSPController.getInstance().bluetoothManager |
|||
?: error("RCSPController.bluetoothManager is null after init") |
|||
|
|||
// 事件转发层 |
|||
bluetoothForwarder = BluetoothEventForwarder(btManager, dispatcher).also { it.attach() } |
|||
deviceInfoForwarder = DeviceInfoEventForwarder(btManager, dispatcher).also { it.attach() } |
|||
mediaForwarder = MediaEventForwarder(btManager, dispatcher).also { it.attach() } |
|||
customForwarder = CustomEventForwarder(btManager, dispatcher).also { it.attach() } |
|||
|
|||
// 业务功能层 |
|||
scanFeature = ScanFeature(btManager) |
|||
connectFeature = ConnectFeature(btManager) |
|||
deviceInfoFeature = DeviceInfoFeature(btManager) |
|||
customCmdFeature = CustomCmdFeature(btManager) |
|||
translationFeature = TranslationFeature(context.applicationContext, btManager, connectFeature) |
|||
speechFeature = SpeechFeature(btManager, connectFeature, dispatcher).also { it.attach() } |
|||
otaFeature = OtaFeature(btManager, connectFeature, dispatcher) |
|||
|
|||
// 默认音频桥:EventChannel;injector 把 Dart 推过来的 PCM 路由回当前 ModeHandler |
|||
defaultBridge = EventChannelAudioBridge(dispatcher) { _, streamId, pcm, fmt, isFinal -> |
|||
translationFeature.feedTranslatedAudio(streamId, pcm, fmt, isFinal) |
|||
} |
|||
translationFeature.setBridge(defaultBridge) |
|||
|
|||
// 内部级联收尾:连接断开 / 来电状态变化时,自动停止当前翻译,避免脏状态 |
|||
dispatcher.addNativeListener(internalCleanupListener) |
|||
|
|||
initialized = true |
|||
} |
|||
|
|||
/** 监听若干生命周期事件并触发自动收尾 */ |
|||
private val internalCleanupListener = object : com.jielihome.jielihome.api.JieliEventListener { |
|||
override fun onEvent(payload: Map<String, Any?>) { |
|||
when (payload["type"] as? String) { |
|||
"connectionState" -> { |
|||
val state = payload["state"] as? Int ?: return |
|||
// StateCode.CONNECTION_DISCONNECT == 0 |
|||
if (state == 0 && translationFeature.isWorking()) { |
|||
translationFeature.stop() |
|||
} |
|||
} |
|||
"phoneCallStatus" -> { |
|||
// 来电/通话进行中 (status != 0) 且当前不是通话翻译,自动 exit |
|||
val status = payload["status"] as? Int ?: return |
|||
if (status == 0) return |
|||
val mid = translationFeature.currentModeId() ?: return |
|||
val isCallMode = mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION || |
|||
mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO |
|||
if (!isCallMode && translationFeature.isWorking()) { |
|||
translationFeature.stop() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** 原生事件订阅 */ |
|||
fun addEventListener(listener: JieliEventListener) = dispatcher.addNativeListener(listener) |
|||
fun removeEventListener(listener: JieliEventListener) = dispatcher.removeNativeListener(listener) |
|||
|
|||
/** 替换翻译音频桥(完全 native 直连翻译服务时使用) */ |
|||
fun setTranslationBridge(bridge: TranslationAudioBridge) { |
|||
translationFeature.setBridge(bridge) |
|||
} |
|||
|
|||
/** |
|||
* 通话翻译能力端口([com.aiagent.device_plugin_interface.DeviceCallTranslationPort] |
|||
* 的杰理实现)。延迟创建;翻译编排器(translate_server native)通过 |
|||
* [DeviceSession.invokeFeature("jieli.callTranslationPort")] 或类似入口拿到引用。 |
|||
*/ |
|||
val callTranslationPort: com.jielihome.jielihome.feature.translation.JieliCallTranslationPort by lazy { |
|||
com.jielihome.jielihome.feature.translation.JieliCallTranslationPort(this) |
|||
} |
|||
|
|||
fun shutdown() { |
|||
if (!initialized) return |
|||
runCatching { otaFeature.cancel() } |
|||
runCatching { translationFeature.stop() } |
|||
runCatching { speechFeature.detach() } |
|||
runCatching { bluetoothForwarder.detach() } |
|||
runCatching { deviceInfoForwarder.detach() } |
|||
runCatching { mediaForwarder.detach() } |
|||
runCatching { customForwarder.detach() } |
|||
initialized = false |
|||
} |
|||
} |
|||
@ -0,0 +1,114 @@ |
|||
package com.jielihome.jielihome.event |
|||
|
|||
import android.annotation.SuppressLint |
|||
import android.bluetooth.BluetoothDevice |
|||
import android.util.Log |
|||
import com.jieli.bluetooth.bean.BleScanMessage |
|||
import com.jieli.bluetooth.constant.BluetoothConstant |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.interfaces.bluetooth.BluetoothCallbackImpl |
|||
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspCallback |
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
|
|||
private const val TAG = "JLBtEvtFwd" |
|||
|
|||
/** |
|||
* 蓝牙基础事件:适配器开关、扫描状态、设备发现、配对、RCSP init、连接状态。 |
|||
* 与设备「业务数据」(电量、音乐等)解耦。 |
|||
*/ |
|||
class BluetoothEventForwarder( |
|||
private val btManager: JL_BluetoothManager, |
|||
private val dispatcher: EventDispatcher, |
|||
) { |
|||
private val btCallback = object : BluetoothCallbackImpl() { |
|||
override fun onAdapterStatus(bEnabled: Boolean, bHasBle: Boolean) { |
|||
Log.i(TAG, "onAdapterStatus enabled=$bEnabled hasBle=$bHasBle") |
|||
dispatcher.send( |
|||
mapOf("type" to "adapterStatus", "enabled" to bEnabled, "hasBle" to bHasBle) |
|||
) |
|||
} |
|||
|
|||
override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) { |
|||
Log.i(TAG, "onDiscoveryStatus ble=$bBle started=$bStart") |
|||
dispatcher.send( |
|||
mapOf("type" to "scanStatus", "ble" to bBle, "started" to bStart) |
|||
) |
|||
} |
|||
|
|||
@SuppressLint("MissingPermission") |
|||
override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) { |
|||
Log.i(TAG, "onDiscovery raw: device=$device msg=$msg") |
|||
if (device == null) { |
|||
Log.w(TAG, "onDiscovery 跳过 device==null") |
|||
return |
|||
} |
|||
// 只放行杰理协议设备(vid == JL_VID=1494)。 |
|||
// 其它厂商广播(Apple vid=76、Microsoft vid=6 等)跳过,避免列表里出来一堆不能连的设备。 |
|||
if (msg == null || msg.vid != BluetoothConstant.JL_VID) { |
|||
Log.d(TAG, "onDiscovery 跳过非杰理: addr=${device.address} vid=${msg?.vid}") |
|||
return |
|||
} |
|||
val name = try { device.name } catch (_: SecurityException) { null } ?: "" |
|||
Log.i( |
|||
TAG, |
|||
"onDiscovery deliver name='$name' addr=${device.address} " + |
|||
"edr=${msg?.edrAddr} type=${msg?.deviceType} way=${msg?.connectWay} rssi=${msg?.rssi}" |
|||
) |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "deviceFound", |
|||
"name" to name, |
|||
"address" to device.address, |
|||
"edrAddr" to (msg?.edrAddr ?: ""), |
|||
"deviceType" to (msg?.deviceType ?: 0), |
|||
"connectWay" to (msg?.connectWay ?: 0), |
|||
"rssi" to (msg?.rssi ?: 0), |
|||
// BleScanMessage 已经包含双耳/盒子电量及充电状态——直接透传, |
|||
// 上层就不需要等 RCSP 拉快照了。0 视为「该位置无数据」(如单耳设备的 rightDeviceQuantity) |
|||
"leftBattery" to (msg?.leftDeviceQuantity ?: 0), |
|||
"rightBattery" to (msg?.rightDeviceQuantity ?: 0), |
|||
"caseBattery" to (msg?.chargingBinQuantity ?: 0), |
|||
"leftCharging" to (msg?.isLeftCharging ?: false), |
|||
"rightCharging" to (msg?.isRightCharging ?: false), |
|||
"caseCharging" to (msg?.isDeviceCharging ?: false) |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onBondStatus(device: BluetoothDevice?, status: Int) { |
|||
if (device == null) return |
|||
dispatcher.send( |
|||
mapOf("type" to "bondStatus", "address" to device.address, "status" to status) |
|||
) |
|||
} |
|||
} |
|||
|
|||
private val rcspCallback = object : OnRcspCallback() { |
|||
override fun onRcspInit(device: BluetoothDevice?, code: Int) { |
|||
if (device == null) return |
|||
dispatcher.send( |
|||
mapOf("type" to "rcspInit", "address" to device.address, "code" to code) |
|||
) |
|||
if (code != 0) btManager.disconnect(device) |
|||
} |
|||
|
|||
override fun onConnectStateChange(device: BluetoothDevice?, status: Int) { |
|||
if (device == null) return |
|||
dispatcher.send( |
|||
mapOf("type" to "connectionState", "address" to device.address, "state" to status) |
|||
) |
|||
} |
|||
} |
|||
|
|||
fun attach() { |
|||
Log.i(TAG, "attach: 注册 btCallback / rcspCallback") |
|||
btManager.addEventListener(btCallback) |
|||
btManager.registerOnRcspCallback(rcspCallback) |
|||
} |
|||
|
|||
fun detach() { |
|||
Log.i(TAG, "detach: 注销 btCallback / rcspCallback") |
|||
btManager.removeEventListener(btCallback) |
|||
btManager.unregisterOnRcspCallback(rcspCallback) |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
package com.jielihome.jielihome.event |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import android.util.Base64 |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener |
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
|
|||
/** |
|||
* 厂商私有协议入口。 |
|||
* SDK 将所有未在内置协议表中匹配的「设备主动推」事件统一从 onExpandFunction 抛出, |
|||
* 这里把它原样透传到 Dart(payload 走 base64),由业务层自行解析。 |
|||
* |
|||
* 适用:自定义指令的设备->APP 反向通知,例如「检测到唤醒词」「自定义按键事件」。 |
|||
*/ |
|||
class CustomEventForwarder( |
|||
private val btManager: JL_BluetoothManager, |
|||
private val dispatcher: EventDispatcher, |
|||
) { |
|||
private val listener = object : OnRcspEventListener() { |
|||
override fun onExpandFunction(device: BluetoothDevice?, opCode: Int, payload: ByteArray?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "expandFunction", |
|||
"address" to device?.address, |
|||
"opCode" to opCode, |
|||
"payloadBase64" to payload?.let { Base64.encodeToString(it, Base64.NO_WRAP) } |
|||
) |
|||
) |
|||
} |
|||
} |
|||
|
|||
fun attach() { |
|||
btManager.registerOnRcspEventListener(listener) |
|||
} |
|||
|
|||
fun detach() { |
|||
btManager.unregisterOnRcspEventListener(listener) |
|||
} |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.jielihome.jielihome.event |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.bean.base.VoiceMode |
|||
import com.jieli.bluetooth.bean.device.status.BatteryInfo |
|||
import com.jieli.bluetooth.bean.device.voice.VoiceFunc |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener |
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
|
|||
/** |
|||
* 设备信息类事件(设备主动推送):电量、来电状态、语音模式/功能、双连状态。 |
|||
* |
|||
* 实现思路:注册一个 OnRcspEventListener 实例,按主题分配到不同 forwarder。 |
|||
* 这里只负责「设备级状态」,多媒体放 MediaEventForwarder,私有协议放 CustomEventForwarder。 |
|||
* |
|||
* 多个 forwarder 各注册一个独立 listener,SDK 会全部派发,互不干扰。 |
|||
*/ |
|||
class DeviceInfoEventForwarder( |
|||
private val btManager: JL_BluetoothManager, |
|||
private val dispatcher: EventDispatcher, |
|||
) { |
|||
private val listener = object : OnRcspEventListener() { |
|||
|
|||
override fun onBatteryChange(device: BluetoothDevice?, info: BatteryInfo?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "battery", |
|||
"address" to device?.address, |
|||
"level" to info?.battery |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onPhoneCallStatusChange(device: BluetoothDevice?, status: Int) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "phoneCallStatus", |
|||
"address" to device?.address, |
|||
"status" to status |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onCurrentVoiceMode(device: BluetoothDevice?, mode: VoiceMode?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "voiceMode", |
|||
"address" to device?.address, |
|||
"modeId" to mode?.mode |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onVoiceFunctionChange(device: BluetoothDevice?, func: VoiceFunc?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "voiceFunction", |
|||
"address" to device?.address, |
|||
"function" to func?.toString() |
|||
) |
|||
) |
|||
} |
|||
} |
|||
|
|||
fun attach() { |
|||
btManager.registerOnRcspEventListener(listener) |
|||
} |
|||
|
|||
fun detach() { |
|||
btManager.unregisterOnRcspEventListener(listener) |
|||
} |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
package com.jielihome.jielihome.event |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.bean.device.eq.EqInfo |
|||
import com.jieli.bluetooth.bean.device.music.MusicNameInfo |
|||
import com.jieli.bluetooth.bean.device.music.MusicStatusInfo |
|||
import com.jieli.bluetooth.bean.device.music.PlayModeInfo |
|||
import com.jieli.bluetooth.bean.device.voice.VolumeInfo |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener |
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
|
|||
/** |
|||
* 多媒体事件:音乐名/状态/播放模式、EQ、音量。 |
|||
*/ |
|||
class MediaEventForwarder( |
|||
private val btManager: JL_BluetoothManager, |
|||
private val dispatcher: EventDispatcher, |
|||
) { |
|||
private val listener = object : OnRcspEventListener() { |
|||
|
|||
override fun onVolumeChange(device: BluetoothDevice?, info: VolumeInfo?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "volume", |
|||
"address" to device?.address, |
|||
"current" to info?.volume, |
|||
"max" to info?.maxVol |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onEqChange(device: BluetoothDevice?, info: EqInfo?) { |
|||
dispatcher.send( |
|||
mapOf("type" to "eq", "address" to device?.address, "info" to info?.toString()) |
|||
) |
|||
} |
|||
|
|||
override fun onMusicNameChange(device: BluetoothDevice?, info: MusicNameInfo?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "musicName", |
|||
"address" to device?.address, |
|||
"name" to info?.name |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onMusicStatusChange(device: BluetoothDevice?, info: MusicStatusInfo?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "musicStatus", |
|||
"address" to device?.address, |
|||
"playing" to info?.isPlay, |
|||
"currentTime" to info?.currentTime, |
|||
"totalTime" to info?.totalTime |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onPlayModeChange(device: BluetoothDevice?, info: PlayModeInfo?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "playMode", |
|||
"address" to device?.address, |
|||
"mode" to info?.playMode |
|||
) |
|||
) |
|||
} |
|||
} |
|||
|
|||
fun attach() { |
|||
btManager.registerOnRcspEventListener(listener) |
|||
} |
|||
|
|||
fun detach() { |
|||
btManager.unregisterOnRcspEventListener(listener) |
|||
} |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
package com.jielihome.jielihome.feature |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.constant.BluetoothConstant |
|||
import com.jieli.bluetooth.constant.JL_DeviceType |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.utils.BluetoothUtil |
|||
|
|||
class ConnectFeature(private val btManager: JL_BluetoothManager) { |
|||
|
|||
fun connect( |
|||
bleAddress: String, |
|||
edrAddress: String?, |
|||
deviceType: Int, |
|||
connectWay: Int, |
|||
): Result<Unit> { |
|||
val target = resolveTarget(bleAddress, edrAddress, deviceType, connectWay) |
|||
?: return Result.failure(IllegalStateException("remote device not found")) |
|||
btManager.connect(target.first, target.second) |
|||
return Result.success(Unit) |
|||
} |
|||
|
|||
fun disconnect(address: String): Result<Unit> { |
|||
val device = BluetoothUtil.getRemoteDevice(address) |
|||
?: return Result.failure(IllegalStateException("remote device not found")) |
|||
btManager.disconnect(device) |
|||
return Result.success(Unit) |
|||
} |
|||
|
|||
fun isConnected(address: String): Boolean { |
|||
val device = BluetoothUtil.getRemoteDevice(address) ?: return false |
|||
return btManager.isConnectedBtDevice(device) |
|||
} |
|||
|
|||
fun connectedDevice(): BluetoothDevice? = btManager.connectedDevice |
|||
|
|||
fun deviceByAddress(address: String): BluetoothDevice? = |
|||
BluetoothUtil.getRemoteDevice(address) |
|||
|
|||
private fun resolveTarget( |
|||
bleAddress: String, |
|||
edrAddress: String?, |
|||
deviceType: Int, |
|||
connectWay: Int, |
|||
): Pair<BluetoothDevice, Int>? { |
|||
val ble = BluetoothUtil.getRemoteDevice(bleAddress) ?: return null |
|||
if (deviceType != JL_DeviceType.JL_DEVICE_TYPE_WATCH && |
|||
!edrAddress.isNullOrEmpty() && |
|||
(connectWay == BluetoothConstant.PROTOCOL_TYPE_SPP || |
|||
connectWay == BluetoothConstant.PROTOCOL_TYPE_GATT_OVER_BR_EDR) |
|||
) { |
|||
BluetoothUtil.getRemoteDevice(edrAddress)?.let { return it to connectWay } |
|||
} |
|||
return ble to BluetoothConstant.PROTOCOL_TYPE_BLE |
|||
} |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
package com.jielihome.jielihome.feature |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.bean.base.BaseError |
|||
import com.jieli.bluetooth.bean.base.CommandBase |
|||
import com.jieli.bluetooth.bean.command.custom.CustomCmd |
|||
import com.jieli.bluetooth.bean.parameter.CustomParam |
|||
import com.jieli.bluetooth.bean.response.CustomResponse |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback |
|||
import com.jieli.bluetooth.utils.BluetoothUtil |
|||
|
|||
/** |
|||
* 厂商扩展指令通道。 |
|||
* 用法:业务方约定一个 opCode + payload 字节流,从这里下发;设备应答的 payload 透传回 Dart。 |
|||
* 设备「主动推」的扩展事件由 CustomEventForwarder.onExpandFunction 接收。 |
|||
*/ |
|||
class CustomCmdFeature(private val btManager: JL_BluetoothManager) { |
|||
|
|||
fun send( |
|||
address: String, |
|||
opCode: Int, |
|||
payload: ByteArray, |
|||
onSuccess: (ByteArray?) -> Unit, |
|||
onError: (Int, String?) -> Unit, |
|||
) { |
|||
val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) |
|||
?: return onError(-1, "remote device not found") |
|||
|
|||
val cmd = CustomCmd(opCode, CustomParam(payload)) |
|||
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
|||
override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { |
|||
val r = resp?.response as? CustomResponse |
|||
onSuccess(r?.data) |
|||
} |
|||
|
|||
override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { |
|||
onError(err?.code ?: -2, err?.message) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.jielihome.jielihome.feature |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.bean.base.BaseError |
|||
import com.jieli.bluetooth.bean.base.CommandBase |
|||
import com.jieli.bluetooth.bean.command.GetTargetInfoCmd |
|||
import com.jieli.bluetooth.bean.parameter.GetTargetInfoParam |
|||
import com.jieli.bluetooth.bean.response.TargetInfoResponse |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback |
|||
import com.jieli.bluetooth.utils.BluetoothUtil |
|||
|
|||
/** |
|||
* 查询电量、版本号等设备静态信息(拉取式)。 |
|||
* 设备主动推的电量变化由 DeviceInfoEventForwarder 处理。 |
|||
*/ |
|||
class DeviceInfoFeature(private val btManager: JL_BluetoothManager) { |
|||
|
|||
/** 直接读 SDK 缓存(连接成功后已自动拉取过一次) */ |
|||
fun snapshot(address: String): Map<String, Any?>? { |
|||
val device = BluetoothUtil.getRemoteDevice(address) ?: return null |
|||
val info = btManager.getDeviceInfo(device) ?: return null |
|||
return mapOf( |
|||
"address" to device.address, |
|||
"battery" to info.quantity, |
|||
"volume" to info.volume, |
|||
"maxVolume" to info.maxVol, |
|||
"versionCode" to info.versionCode, |
|||
"versionName" to info.versionName, |
|||
"name" to info.name, |
|||
"vid" to info.vid, |
|||
"pid" to info.pid, |
|||
"uid" to info.uid, |
|||
"edrAddr" to info.edrAddr |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 主动向设备发指令拉取最新 TargetInfo。 |
|||
* @param mask 设备字段位掩码,常见 0x0F 拉常用字段;具体 mask 见杰理协议文档 |
|||
*/ |
|||
fun queryTargetInfo( |
|||
address: String, |
|||
mask: Int, |
|||
onSuccess: (Map<String, Any?>) -> Unit, |
|||
onError: (Int, String?) -> Unit, |
|||
) { |
|||
val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) |
|||
?: return onError(-1, "remote device not found") |
|||
|
|||
val cmd = GetTargetInfoCmd(GetTargetInfoParam(mask)) |
|||
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
|||
override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { |
|||
val r = resp?.response as? TargetInfoResponse |
|||
?: return onError(-2, "no response") |
|||
onSuccess( |
|||
mapOf( |
|||
"address" to (dev?.address ?: address), |
|||
"battery" to r.quantity, |
|||
"volume" to r.volume, |
|||
"versionCode" to r.versionCode, |
|||
"versionName" to r.versionName, |
|||
"name" to r.name |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { |
|||
onError(err?.code ?: -3, err?.message) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package com.jielihome.jielihome.feature |
|||
|
|||
import com.jieli.bluetooth.constant.ErrorCode |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
|
|||
class ScanFeature(private val btManager: JL_BluetoothManager) { |
|||
|
|||
fun startScan(timeoutMs: Int): Result<Unit> { |
|||
val code = btManager.scan(timeoutMs) |
|||
return if (code == ErrorCode.ERR_NONE) Result.success(Unit) |
|||
else Result.failure(IllegalStateException("scan returned $code")) |
|||
} |
|||
|
|||
fun stopScan() { |
|||
btManager.stopScan() |
|||
} |
|||
|
|||
fun isScanning(): Boolean = btManager.bluetoothOperation.isScanning |
|||
} |
|||
@ -0,0 +1,264 @@ |
|||
package com.jielihome.jielihome.feature.ota |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.bean.base.BaseError |
|||
import com.jieli.bluetooth.bean.base.CommandBase |
|||
import com.jieli.bluetooth.bean.command.ota.EnterUpdateModeCmd |
|||
import com.jieli.bluetooth.bean.command.ota.ExitUpdateModeCmd |
|||
import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateBlockCmd |
|||
import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateStatusCmd |
|||
import com.jieli.bluetooth.bean.command.ota.GetUpdateFileOffsetCmd |
|||
import com.jieli.bluetooth.bean.command.ota.InquireUpdateCmd |
|||
import com.jieli.bluetooth.bean.command.ota.NotifyUpdateContentSizeCmd |
|||
import com.jieli.bluetooth.bean.command.ota.RebootDeviceCmd |
|||
import com.jieli.bluetooth.bean.parameter.FirmwareUpdateBlockParam |
|||
import com.jieli.bluetooth.bean.parameter.InquireUpdateParam |
|||
import com.jieli.bluetooth.bean.parameter.NotifyUpdateContentSizeParam |
|||
import com.jieli.bluetooth.bean.parameter.RebootDeviceParam |
|||
import com.jieli.bluetooth.bean.response.EnterUpdateModeResponse |
|||
import com.jieli.bluetooth.bean.response.FirmwareUpdateBlockResponse |
|||
import com.jieli.bluetooth.bean.response.FirmwareUpdateStatusResponse |
|||
import com.jieli.bluetooth.bean.response.InquireUpdateResponse |
|||
import com.jieli.bluetooth.bean.response.UpdateFileOffsetResponse |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback |
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
import com.jielihome.jielihome.feature.ConnectFeature |
|||
import kotlinx.coroutines.CancellationException |
|||
import kotlinx.coroutines.CoroutineScope |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.Job |
|||
import kotlinx.coroutines.SupervisorJob |
|||
import kotlinx.coroutines.delay |
|||
import kotlinx.coroutines.launch |
|||
import kotlinx.coroutines.suspendCancellableCoroutine |
|||
import java.io.File |
|||
import java.io.RandomAccessFile |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
import kotlin.coroutines.resume |
|||
import kotlin.coroutines.resumeWithException |
|||
|
|||
/** |
|||
* OTA 升级。 |
|||
* |
|||
* # 重要说明 |
|||
* **当前 SDK 包(jl_bluetooth_rcsp)只提供 OTA 协议的原始 Cmd,没有高层 OTA 管理器。** |
|||
* 完整 OTA 流(含 CRC 校验、断点续传、双备份切换)通常在杰理另发的 `jl_otalib_*.aar` |
|||
* 里实现。本实现是基于公开 cmd 的「最小可工作版」,**生产前需在真机验证**。 |
|||
* |
|||
* # 状态机 |
|||
* IDLE → INQUIRING → NOTIFYING_SIZE → ENTERING → TRANSFERRING(progress) → VERIFYING |
|||
* → REBOOTING → DONE / FAILED / CANCELLED |
|||
* |
|||
* # 数据流(推测的协议,需真机验证) |
|||
* 1. InquireUpdate(file flag) → device 决定能否升级 |
|||
* 2. NotifyUpdateContentSize(totalSize) → 告知固件包大小 |
|||
* 3. EnterUpdateMode → device 进入升级态 |
|||
* 4. Loop: |
|||
* GetUpdateFileOffset → device 告知它需要从哪个 offset 开始 |
|||
* FirmwareUpdateBlock(offset, len) → APP 发块(数据塞在响应里) |
|||
* until offset >= totalSize |
|||
* 5. FirmwareUpdateStatus → device 验证结果 |
|||
* 6. ExitUpdateMode + RebootDevice |
|||
* |
|||
* # 调用方式 |
|||
* server.otaFeature.start(address, "/sdcard/firmware.ufw") |
|||
* server.otaFeature.cancel() |
|||
*/ |
|||
class OtaFeature( |
|||
private val btManager: JL_BluetoothManager, |
|||
private val connectFeature: ConnectFeature, |
|||
private val dispatcher: EventDispatcher, |
|||
) { |
|||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) |
|||
private val running = AtomicBoolean(false) |
|||
private var job: Job? = null |
|||
|
|||
fun isRunning(): Boolean = running.get() |
|||
|
|||
fun start( |
|||
address: String? = null, |
|||
firmwareFilePath: String, |
|||
/** 块大小(设备可能在响应里给出建议值;这里只是默认) */ |
|||
blockSize: Int = 512, |
|||
/** 升级文件标记(杰理协议要求;常见空数组或自定义校验) */ |
|||
fileFlagBytes: ByteArray = ByteArray(0), |
|||
) { |
|||
if (!running.compareAndSet(false, true)) { |
|||
emitError(-1, "ota already running"); return |
|||
} |
|||
val device = address?.let { connectFeature.deviceByAddress(it) } |
|||
?: connectFeature.connectedDevice() |
|||
if (device == null) { |
|||
emitError(-2, "no connected device") |
|||
running.set(false); return |
|||
} |
|||
val file = File(firmwareFilePath) |
|||
if (!file.isFile || file.length() <= 0) { |
|||
emitError(-3, "firmware file invalid: $firmwareFilePath") |
|||
running.set(false); return |
|||
} |
|||
emitState(State.INQUIRING, 0, file.length()) |
|||
job = scope.launch { runFlow(device, file, blockSize, fileFlagBytes) } |
|||
} |
|||
|
|||
fun cancel() { |
|||
job?.cancel() |
|||
job = null |
|||
running.set(false) |
|||
emitState(State.CANCELLED, -1, 0) |
|||
} |
|||
|
|||
// ───── 内部主流程 ───── |
|||
|
|||
private suspend fun runFlow( |
|||
device: BluetoothDevice, |
|||
file: File, |
|||
blockSize: Int, |
|||
fileFlagBytes: ByteArray, |
|||
) { |
|||
try { |
|||
val totalSize = file.length() |
|||
|
|||
// 1. Inquire |
|||
val inquire: InquireUpdateResponse = sendCmd( |
|||
device, InquireUpdateCmd(InquireUpdateParam(fileFlagBytes)) |
|||
) |
|||
if (inquire.canUpdateFlag != 0) { |
|||
throw IllegalStateException("device refused update, flag=${inquire.canUpdateFlag}") |
|||
} |
|||
|
|||
// 2. NotifyContentSize |
|||
emitState(State.NOTIFYING_SIZE, 0, totalSize) |
|||
sendCmd<com.jieli.bluetooth.bean.base.CommonResponse>( |
|||
device, |
|||
NotifyUpdateContentSizeCmd(NotifyUpdateContentSizeParam(totalSize.toInt())) |
|||
) |
|||
|
|||
// 3. Enter update mode |
|||
emitState(State.ENTERING, 0, totalSize) |
|||
val enter: EnterUpdateModeResponse = sendCmd(device, EnterUpdateModeCmd()) |
|||
if (enter.canUpdateFlag != 0) { |
|||
throw IllegalStateException("enter update mode failed, flag=${enter.canUpdateFlag}") |
|||
} |
|||
|
|||
// 4. Block transfer loop |
|||
emitState(State.TRANSFERRING, 0, totalSize) |
|||
RandomAccessFile(file, "r").use { raf -> |
|||
var safety = 0 |
|||
while (running.get()) { |
|||
val ofs: UpdateFileOffsetResponse = sendCmd(device, GetUpdateFileOffsetCmd()) |
|||
val nextOffset = ofs.updateFileFlagOffset.toLong() |
|||
val askLen = ofs.updateFileFlagLen.takeIf { it > 0 } ?: blockSize |
|||
if (nextOffset >= totalSize) break |
|||
|
|||
val len = minOf(askLen.toLong(), totalSize - nextOffset).toInt() |
|||
val data = ByteArray(len) |
|||
raf.seek(nextOffset) |
|||
raf.readFully(data) |
|||
|
|||
val blockCmd = FirmwareUpdateBlockCmd( |
|||
FirmwareUpdateBlockParam(nextOffset.toInt(), len) |
|||
) |
|||
// 在响应里塞数据:杰理协议要求 APP 把块数据放在 response 里, |
|||
// 而不是 param 里(FirmwareUpdateBlockParam 没有 data 字段)。 |
|||
// 这里我们走 sendRcspResponse 模式:先把 cmd 当作"已收到的 device 请求" |
|||
// 处理,然后回响应。需要 SDK 内部支持;如果直接 sendRcspCommand 不行, |
|||
// 真机调试时切到 device-initiated 流(见类注释)。 |
|||
val resp = FirmwareUpdateBlockResponse().setFirmwareUpdateBlockData(data) |
|||
blockCmd.response = resp |
|||
sendBlock(device, blockCmd) |
|||
|
|||
val written = nextOffset + len |
|||
emitState(State.TRANSFERRING, written, totalSize) |
|||
safety++ |
|||
if (safety > 200_000) throw IllegalStateException("too many block iterations") |
|||
delay(0L) // yield |
|||
} |
|||
if (!running.get()) throw CancellationException() |
|||
} |
|||
|
|||
// 5. Verify |
|||
emitState(State.VERIFYING, totalSize, totalSize) |
|||
val status: FirmwareUpdateStatusResponse = sendCmd(device, FirmwareUpdateStatusCmd()) |
|||
if (status.result != 0) throw IllegalStateException("verify failed result=${status.result}") |
|||
|
|||
// 6. Exit + Reboot |
|||
emitState(State.REBOOTING, totalSize, totalSize) |
|||
runCatching { sendCmd<com.jieli.bluetooth.bean.response.ExitUpdateModeResponse>(device, ExitUpdateModeCmd()) } |
|||
runCatching { sendCmd<com.jieli.bluetooth.bean.response.RebootDeviceResponse>(device, RebootDeviceCmd(RebootDeviceParam(0))) } |
|||
|
|||
emitState(State.DONE, totalSize, totalSize) |
|||
} catch (ce: CancellationException) { |
|||
emitState(State.CANCELLED, -1, file.length()) |
|||
} catch (t: Throwable) { |
|||
emitError(-100, "ota failed: ${t.message}") |
|||
emitState(State.FAILED, -1, file.length()) |
|||
} finally { |
|||
running.set(false) |
|||
} |
|||
} |
|||
|
|||
/** 普通 OTA 控制命令:APP 发,等设备响应 */ |
|||
private suspend inline fun <reified R> sendCmd( |
|||
device: BluetoothDevice, |
|||
cmd: CommandBase<*, *>, |
|||
): R = suspendCancellableCoroutine { cont -> |
|||
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
|||
override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { |
|||
@Suppress("UNCHECKED_CAST") |
|||
val r = resp?.response as? R |
|||
if (r != null) cont.resume(r) |
|||
else cont.resumeWithException(IllegalStateException("unexpected response")) |
|||
} |
|||
|
|||
override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { |
|||
cont.resumeWithException(IllegalStateException("err ${err?.code}: ${err?.message}")) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** 块响应:把已经填好 response 的 cmd 通过 sendRcspResponse 回设备 */ |
|||
private suspend fun sendBlock( |
|||
device: BluetoothDevice, |
|||
cmd: FirmwareUpdateBlockCmd, |
|||
) = suspendCancellableCoroutine<Unit> { cont -> |
|||
try { |
|||
// 先尝试 sendRcspResponse(device-initiated 模式) |
|||
btManager.sendRcspResponse(device, cmd) |
|||
cont.resume(Unit) |
|||
} catch (t: Throwable) { |
|||
// 兜底:sendRcspCommand |
|||
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
|||
override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { cont.resume(Unit) } |
|||
override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { |
|||
cont.resumeWithException(IllegalStateException("block err ${err?.code}: ${err?.message}")) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// ───── 事件 ───── |
|||
|
|||
enum class State { IDLE, INQUIRING, NOTIFYING_SIZE, ENTERING, TRANSFERRING, VERIFYING, REBOOTING, DONE, FAILED, CANCELLED } |
|||
|
|||
private fun emitState(state: State, sent: Long, total: Long) { |
|||
val percent = if (total > 0 && sent in 0..total) (sent * 100.0 / total).toInt() else -1 |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "otaState", |
|||
"state" to state.name, |
|||
"sent" to sent, |
|||
"total" to total, |
|||
"percent" to percent, |
|||
"tsMs" to System.currentTimeMillis(), |
|||
) |
|||
) |
|||
} |
|||
|
|||
private fun emitError(code: Int, msg: String?) { |
|||
dispatcher.send( |
|||
mapOf("type" to "otaError", "code" to code, "message" to msg) |
|||
) |
|||
} |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
package com.jielihome.jielihome.feature.translation |
|||
|
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
|
|||
/** |
|||
* 默认桥实现:采集帧通过 EventChannel 推到 Dart(base64 PCM), |
|||
* Dart 把 PCM 喂给外部翻译服务,再通过 MethodChannel 回调 [TranslationFeature.feedTranslatedAudio]。 |
|||
* |
|||
* 16kHz / 16bit / 单声道 PCM 流量约 32KB/s,base64 后 ~43KB/s,跨通道开销可控。 |
|||
* 若宿主想完全 native 直连翻译服务,可实现自己的 [TranslationAudioBridge] 并通过 |
|||
* [TranslationFeature.setBridge] 替换。 |
|||
*/ |
|||
class EventChannelAudioBridge( |
|||
private val dispatcher: EventDispatcher, |
|||
/** 由 TranslationFeature 注入:把 PCM 真正塞回当前 ModeHandler */ |
|||
var injector: TranslationAudioInjector? = null, |
|||
) : TranslationAudioBridge { |
|||
|
|||
override fun emitAudioFrame( |
|||
modeId: Int, |
|||
streamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
seq: Long, |
|||
tsMs: Long, |
|||
isFinal: Boolean, |
|||
) { |
|||
// EventChannel 原生支持 byte[] → Uint8List,直接传字节流,避免 base64 33% 体积膨胀。 |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "translationAudio", |
|||
"modeId" to modeId, |
|||
"streamId" to streamId, |
|||
"sampleRate" to format.sampleRate, |
|||
"channels" to format.channels, |
|||
"bitsPerSample" to format.bitsPerSample, |
|||
"seq" to seq, |
|||
"tsMs" to tsMs, |
|||
"final" to isFinal, |
|||
"pcm" to pcm, |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun emitTranslationResult( |
|||
modeId: Int, |
|||
srcLang: String?, |
|||
srcText: String?, |
|||
destLang: String?, |
|||
destText: String?, |
|||
requestId: String?, |
|||
) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "translationResult", |
|||
"modeId" to modeId, |
|||
"srcLang" to srcLang, |
|||
"srcText" to srcText, |
|||
"destLang" to destLang, |
|||
"destText" to destText, |
|||
"requestId" to requestId, |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun emitLog(modeId: Int, content: String) { |
|||
dispatcher.send( |
|||
mapOf("type" to "translationLog", "modeId" to modeId, "content" to content) |
|||
) |
|||
} |
|||
|
|||
override fun emitError(modeId: Int, code: Int, message: String?) { |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "translationError", |
|||
"modeId" to modeId, |
|||
"code" to code, |
|||
"message" to message |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun feedTranslatedAudio( |
|||
modeId: Int, |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
return injector?.inject(modeId, outputStreamId, pcm, format, isFinal) ?: false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 内部接口:TranslationFeature 决定当前 ModeHandler 是谁,由它消费 PCM。 |
|||
*/ |
|||
fun interface TranslationAudioInjector { |
|||
fun inject( |
|||
modeId: Int, |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean |
|||
} |
|||
@ -0,0 +1,210 @@ |
|||
package com.jielihome.jielihome.feature.translation |
|||
|
|||
import android.util.Log |
|||
import com.aiagent.device_plugin_interface.CallAudioCodec |
|||
import com.aiagent.device_plugin_interface.CallAudioFormat |
|||
import com.aiagent.device_plugin_interface.CallAudioFrame |
|||
import com.aiagent.device_plugin_interface.CallTranslationError |
|||
import com.aiagent.device_plugin_interface.CallTranslationLeg |
|||
import com.aiagent.device_plugin_interface.DeviceCallTranslationPort |
|||
import com.aiagent.device_plugin_interface.TranslatedAudioFrame |
|||
import com.jielihome.jielihome.core.JieliHomeServer |
|||
import kotlinx.coroutines.channels.BufferOverflow |
|||
import kotlinx.coroutines.flow.Flow |
|||
import kotlinx.coroutines.flow.MutableSharedFlow |
|||
import kotlinx.coroutines.flow.asSharedFlow |
|||
|
|||
/** |
|||
* `DeviceCallTranslationPort` 的杰理实现。 |
|||
* |
|||
* 当前阶段策略:**只支持 PCM_S16LE / 16kHz / mono / 20ms**。 |
|||
* 新版 SDK 已经在 native 内部把耳机端 OPUS 解码成 PCM 上推(见 [TranslationFeature] |
|||
* 的 `mode` handlers),上层零编解码即可贯通。OPUS 直通模式后续按需补。 |
|||
* |
|||
* # enter / exit 与 SDK bridge 的关系 |
|||
* - [enter] 时把 [JieliHomeServer.defaultBridge](EventChannel → Dart 的桥)替换为 |
|||
* 本实现内的 capture bridge:拦截 `IN_UPLINK` / `IN_DOWNLINK` 上推的 PCM 帧, |
|||
* 翻译成 [CallAudioFrame] 写入 [audioFrames] 流;同时把 `emitError` 接到 |
|||
* [errors] 流。 |
|||
* - 期间 Dart 的 `device_jieli/event` 不会再收到翻译音频事件——这正是"完全 native |
|||
* 直连翻译服务"的预期路径,与 `translate_server` native 编排器配套。 |
|||
* - [exit] 时还原默认桥,Dart 端的 EventChannel 恢复正常使用。 |
|||
* |
|||
* # TTS 回灌 |
|||
* 编排器把翻译完成的 PCM 通过 [reportTranslated] 回写: |
|||
* - `CallTranslationLeg.UPLINK` → SDK 的 `OUT_UPLINK`(对方耳机听) |
|||
* - `CallTranslationLeg.DOWNLINK` → SDK 的 `OUT_DOWNLINK`(本机用户耳机听) |
|||
* |
|||
* # 线程安全 |
|||
* `enter`/`exit`/`reportTranslated` 通过 `synchronized(this)` 互斥; |
|||
* SharedFlow `tryEmit` 自身线程安全,capture bridge 可被任意线程回调。 |
|||
*/ |
|||
class JieliCallTranslationPort( |
|||
private val server: JieliHomeServer, |
|||
) : DeviceCallTranslationPort { |
|||
|
|||
companion object { |
|||
private const val TAG = "JieliCallTransPort" |
|||
private val PCM_16K_MONO_20MS = CallAudioFormat.PCM_S16LE_16K_MONO_20MS |
|||
} |
|||
|
|||
private val _audioFrames = MutableSharedFlow<CallAudioFrame>( |
|||
replay = 0, |
|||
extraBufferCapacity = 128, |
|||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|||
) |
|||
private val _errors = MutableSharedFlow<CallTranslationError>( |
|||
replay = 0, |
|||
extraBufferCapacity = 16, |
|||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|||
) |
|||
|
|||
@Volatile private var entered = false |
|||
@Volatile private var savedBridge: TranslationAudioBridge? = null |
|||
|
|||
override fun supportedSourceFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS) |
|||
override fun supportedSinkFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS) |
|||
|
|||
override val audioFrames: Flow<CallAudioFrame> = _audioFrames.asSharedFlow() |
|||
override val errors: Flow<CallTranslationError> = _errors.asSharedFlow() |
|||
|
|||
@Synchronized |
|||
override fun enter(sourceFormat: CallAudioFormat) { |
|||
require(sourceFormat == PCM_16K_MONO_20MS) { |
|||
"JieliCallTranslationPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)" |
|||
} |
|||
check(!entered) { "device.call_translation.busy" } |
|||
|
|||
val translationFeature = server.translationFeature |
|||
|
|||
// 调试:统计 SDK 给我们推了多少帧、按 leg 分桶;每秒打一次。 |
|||
val countByStream = java.util.concurrent.ConcurrentHashMap<String, Long>() |
|||
var lastReportMs = System.currentTimeMillis() |
|||
|
|||
val captureBridge = object : TranslationAudioBridge { |
|||
override fun emitAudioFrame( |
|||
modeId: Int, |
|||
streamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
seq: Long, |
|||
tsMs: Long, |
|||
isFinal: Boolean, |
|||
) { |
|||
// 调试用:周期性打印 SDK 上推帧统计,确认设备端有没有真出 PCM。 |
|||
countByStream.merge(streamId, 1L) { old, _ -> old + 1L } |
|||
val now = System.currentTimeMillis() |
|||
if (now - lastReportMs >= 1000L) { |
|||
Log.d(TAG, "SDK frame stats (last 1s): $countByStream fmt=${format.sampleRate}Hz/${format.channels}ch") |
|||
countByStream.clear() |
|||
lastReportMs = now |
|||
} |
|||
val leg = when (streamId) { |
|||
TranslationStreams.IN_UPLINK -> CallTranslationLeg.UPLINK |
|||
TranslationStreams.IN_DOWNLINK -> CallTranslationLeg.DOWNLINK |
|||
else -> return // ignore non-call streams in this mode |
|||
} |
|||
val frame = CallAudioFrame( |
|||
leg = leg, |
|||
codec = CallAudioCodec.PCM_S16LE, |
|||
sampleRate = format.sampleRate, |
|||
channels = format.channels, |
|||
bytes = pcm, |
|||
sequence = seq, |
|||
timestampUs = tsMs * 1000L, |
|||
) |
|||
if (!_audioFrames.tryEmit(frame)) { |
|||
Log.w(TAG, "audioFrames buffer overflow; dropped seq=$seq leg=$leg") |
|||
} |
|||
} |
|||
|
|||
override fun emitTranslationResult( |
|||
modeId: Int, |
|||
srcLang: String?, srcText: String?, |
|||
destLang: String?, destText: String?, |
|||
requestId: String?, |
|||
) { |
|||
// 字幕在 native 编排器里由 agent 直接出,不依赖这个回调;忽略。 |
|||
} |
|||
|
|||
override fun emitLog(modeId: Int, content: String) { |
|||
Log.d(TAG, "translation log: modeId=$modeId $content") |
|||
} |
|||
|
|||
override fun emitError(modeId: Int, code: Int, message: String?) { |
|||
_errors.tryEmit( |
|||
CallTranslationError( |
|||
code = "device.feature_failed", |
|||
message = "code=$code msg=${message ?: ""}", |
|||
) |
|||
) |
|||
} |
|||
|
|||
override fun feedTranslatedAudio( |
|||
modeId: Int, |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
// capture bridge 不负责回灌——编排器走 [reportTranslated] 直接调 |
|||
// server.translationFeature.feedTranslatedAudio。 |
|||
return false |
|||
} |
|||
} |
|||
|
|||
savedBridge = server.defaultBridge |
|||
server.setTranslationBridge(captureBridge) |
|||
|
|||
val result = translationFeature.start( |
|||
TranslationModeIds.MODE_CALL_TRANSLATION, |
|||
emptyMap(), |
|||
) |
|||
if (result.isFailure) { |
|||
// 启动失败:还原桥并抛出 |
|||
savedBridge?.let { runCatching { server.setTranslationBridge(it) } } |
|||
savedBridge = null |
|||
throw IllegalStateException( |
|||
"failed to enter MODE_CALL_TRANSLATION: ${result.exceptionOrNull()?.message}", |
|||
result.exceptionOrNull(), |
|||
) |
|||
} |
|||
entered = true |
|||
Log.d(TAG, "entered MODE_CALL_TRANSLATION") |
|||
} |
|||
|
|||
@Synchronized |
|||
override fun reportTranslated(frame: TranslatedAudioFrame) { |
|||
check(entered) { "device.call_translation.not_active" } |
|||
require(frame.codec == CallAudioCodec.PCM_S16LE) { |
|||
"JieliCallTranslationPort only accepts PCM_S16LE for sink (got ${frame.codec})" |
|||
} |
|||
val streamId = when (frame.leg) { |
|||
CallTranslationLeg.UPLINK -> TranslationStreams.OUT_UPLINK |
|||
CallTranslationLeg.DOWNLINK -> TranslationStreams.OUT_DOWNLINK |
|||
} |
|||
val ok = server.translationFeature.feedTranslatedAudio( |
|||
outputStreamId = streamId, |
|||
pcm = frame.bytes, |
|||
format = AudioFormat( |
|||
sampleRate = frame.sampleRate, |
|||
channels = frame.channels, |
|||
bitsPerSample = 16, |
|||
), |
|||
isFinal = frame.isFinal, |
|||
) |
|||
if (!ok) { |
|||
Log.w(TAG, "feedTranslatedAudio rejected: streamId=$streamId leg=${frame.leg}") |
|||
} |
|||
} |
|||
|
|||
@Synchronized |
|||
override fun exit() { |
|||
if (!entered) return |
|||
entered = false |
|||
runCatching { server.translationFeature.stop() } |
|||
savedBridge?.let { runCatching { server.setTranslationBridge(it) } } |
|||
savedBridge = null |
|||
Log.d(TAG, "exited call translation") |
|||
} |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
package com.jielihome.jielihome.feature.translation |
|||
|
|||
/** |
|||
* 翻译音频桥:插件 ↔ 外部翻译服务 的双向接口。 |
|||
* |
|||
* - 「采集面」:ModeHandler 调用 [emitAudioFrame] 把解码后的 PCM 帧推出去。 |
|||
* 默认实现走 EventChannel 推到 Dart,由 Dart 转发到外部翻译服务。 |
|||
* 也可以由宿主原生代码替换成自定义实现(比如直接走 gRPC)。 |
|||
* |
|||
* - 「回放面」:[feedTranslatedAudio] 是入口,外部翻译服务调用,把 TTS PCM 注入进来。 |
|||
* 交给当前 ModeHandler 编码并回送到耳机/A2DP。 |
|||
* |
|||
* - 文本结果(字幕)走 [emitTranslationResult],纯展示用,不影响音频路径。 |
|||
*/ |
|||
interface TranslationAudioBridge { |
|||
|
|||
/** 插件→外部 :推一帧采集到的 PCM 音频 */ |
|||
fun emitAudioFrame( |
|||
modeId: Int, |
|||
streamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
seq: Long, |
|||
tsMs: Long, |
|||
isFinal: Boolean = false, |
|||
) |
|||
|
|||
/** 插件→外部 :推翻译文本(可选)。requestId 由调用方贯穿同一段翻译,便于字幕拼接 */ |
|||
fun emitTranslationResult( |
|||
modeId: Int, |
|||
srcLang: String?, |
|||
srcText: String?, |
|||
destLang: String?, |
|||
destText: String?, |
|||
requestId: String? = null, |
|||
) |
|||
|
|||
/** 插件→外部 :日志 */ |
|||
fun emitLog(modeId: Int, content: String) |
|||
|
|||
/** 插件→外部 :错误 */ |
|||
fun emitError(modeId: Int, code: Int, message: String?) |
|||
|
|||
/** 外部→插件 :注入翻译完成的 TTS PCM;插件负责回送到 outputStreamId 对应的物理通道 */ |
|||
fun feedTranslatedAudio( |
|||
modeId: Int, |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean |
|||
} |
|||
@ -0,0 +1,151 @@ |
|||
package com.jielihome.jielihome.feature.translation |
|||
|
|||
import android.content.Context |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jielihome.jielihome.feature.ConnectFeature |
|||
import com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.mode.CallTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.mode.FaceToFaceTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.mode.RecordModeHandler |
|||
import com.jielihome.jielihome.feature.translation.mode.RecordingTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.mode.StereoCallTranslationModeHandler |
|||
|
|||
/** |
|||
* 翻译特性入口。 |
|||
* |
|||
* # TTS PCM 回灌的两条路径 |
|||
* 宿主把外部翻译服务回送的 PCM 注入回插件时,**两条路径同时存在、宿主只需选一条**: |
|||
* |
|||
* 1) Dart / 远端进程 → MethodChannel `feedTranslatedAudio` → MethodRouter → |
|||
* `TranslationFeature.feedTranslatedAudio` → 当前 [TranslationModeHandler.onTranslatedAudio] |
|||
* 场景:Flutter UI 调用,或宿主原生代码使用通用入口(不区分桥实现时)。 |
|||
* |
|||
* 2) 自定义 native bridge → `TranslationAudioBridge.feedTranslatedAudio` → |
|||
* (桥内部决定如何下发;默认实现 [EventChannelAudioBridge] 通过 injector 转回 1)。 |
|||
* 场景:宿主用 [setBridge] 替换桥,希望把音频生命周期完全留在 native 层管理。 |
|||
* |
|||
* 推荐:除非有性能/隔离强需求,**统一走路径 1**(更简单、字幕和音频走同一条命令通道)。 |
|||
* |
|||
* # 立体声通话翻译 |
|||
* `MODE_CALL_TRANSLATION` 永远是双流单声道;`MODE_CALL_TRANSLATION_WITH_STEREO` |
|||
* 永远走立体声软件分声道方案。是否切到后者由调用方自行判断,可先调 |
|||
* [isSupportCallTranslationWithStereo] 探测设备能力。 |
|||
*/ |
|||
class TranslationFeature( |
|||
private val context: Context, |
|||
private val btManager: JL_BluetoothManager, |
|||
private val connectFeature: ConnectFeature, |
|||
) { |
|||
@Volatile |
|||
private var bridge: TranslationAudioBridge? = null |
|||
|
|||
private var handlers: Map<Int, TranslationModeHandler> = emptyMap() |
|||
|
|||
@Volatile |
|||
private var current: TranslationModeHandler? = null |
|||
|
|||
/** 由 JieliHomeServer 在 init 时调一次;后续宿主可调 [setBridge] 替换 */ |
|||
fun setBridge(bridge: TranslationAudioBridge) { |
|||
this.bridge = bridge |
|||
// 重建 handlers,把新桥 + 上下文注入下去 |
|||
handlers = mapOf( |
|||
TranslationModeIds.MODE_RECORD to RecordModeHandler(context, btManager, bridge), |
|||
TranslationModeIds.MODE_RECORDING_TRANSLATION to RecordingTranslationModeHandler(context, btManager, bridge), |
|||
TranslationModeIds.MODE_CALL_TRANSLATION to CallTranslationModeHandler(context, btManager, connectFeature, bridge), |
|||
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO to StereoCallTranslationModeHandler(context, btManager, connectFeature, bridge), |
|||
TranslationModeIds.MODE_AUDIO_TRANSLATION to AudioTranslationModeHandler(btManager, bridge), |
|||
TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION to FaceToFaceTranslationModeHandler(context, btManager, bridge), |
|||
) |
|||
} |
|||
|
|||
fun start(modeId: Int, args: Map<String, Any?>): Result<Unit> { |
|||
// 与官方 demo (TranslationViewModel.enterMode) 对齐:MODE_CALL_TRANSLATION |
|||
// + OPUS + 设备支持立体声方案时,自动升级到 MODE_CALL_TRANSLATION_WITH_STEREO |
|||
// (channel=2)。很多固件版本下 mono CALL_TRANSLATION 通路实际上不出帧, |
|||
// 真正出数据的是立体声方案——上下行 mix 成一路立体声 PCM 上推。 |
|||
val effectiveModeId = if ( |
|||
modeId == TranslationModeIds.MODE_CALL_TRANSLATION && |
|||
(args["audioType"] == null || |
|||
args["audioType"] == com.jieli.bluetooth.constant.Constants.AUDIO_TYPE_OPUS || |
|||
(args["audioType"] as? String)?.lowercase() == "opus") && |
|||
isSupportCallTranslationWithStereo(args["address"] as? String) |
|||
) { |
|||
android.util.Log.d( |
|||
"TranslationFeature", |
|||
"MODE_CALL_TRANSLATION + OPUS + stereo-supported → upgrade to MODE_CALL_TRANSLATION_WITH_STEREO", |
|||
) |
|||
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO |
|||
} else modeId |
|||
|
|||
val handler = handlers[effectiveModeId] |
|||
?: return Result.failure(IllegalArgumentException("unknown modeId=$effectiveModeId")) |
|||
current?.takeIf { it.isWorking }?.stop() |
|||
handler.start(args) |
|||
current = handler |
|||
return Result.success(Unit) |
|||
} |
|||
|
|||
fun stop() { |
|||
current?.takeIf { it.isWorking }?.stop() |
|||
current = null |
|||
} |
|||
|
|||
fun isWorking(): Boolean = current?.isWorking == true |
|||
fun currentModeId(): Int? = current?.modeId |
|||
fun currentInputStreams(): List<String> = current?.inputStreams ?: emptyList() |
|||
fun currentOutputStreams(): List<String> = current?.outputStreams ?: emptyList() |
|||
|
|||
/** |
|||
* 翻译服务回送 TTS PCM 的统一入口。 |
|||
* 路由到当前活动 ModeHandler 的 onTranslatedAudio。 |
|||
*/ |
|||
fun feedTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
val h = current ?: return false |
|||
if (!h.isWorking) return false |
|||
if (outputStreamId !in h.outputStreams) return false |
|||
return h.onTranslatedAudio(outputStreamId, pcm, format, isFinal) |
|||
} |
|||
|
|||
/** 透传文本结果(字幕);requestId 由调用方贯穿同一段翻译,便于聚合 */ |
|||
fun feedTranslationResult( |
|||
srcLang: String?, srcText: String?, |
|||
destLang: String?, destText: String?, |
|||
requestId: String? = null, |
|||
) { |
|||
val mid = current?.modeId ?: return |
|||
bridge?.emitTranslationResult(mid, srcLang, srcText, destLang, destText, requestId) |
|||
} |
|||
|
|||
/** |
|||
* 探测当前已连耳机是否支持立体声通话翻译方案。 |
|||
* 内部根据 SDK [com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl.isSupportCallTranslationWithStereo] |
|||
* 判断;若设备未连或未握手 RCSP 则返回 false。 |
|||
*/ |
|||
fun isSupportCallTranslationWithStereo(connectedDeviceAddress: String?): Boolean { |
|||
val device = connectedDeviceAddress?.let { |
|||
com.jieli.bluetooth.utils.BluetoothUtil.getRemoteDevice(it) |
|||
} ?: btManager.connectedDevice ?: return false |
|||
val tmp = com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl( |
|||
btManager, |
|||
com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi(), |
|||
device, |
|||
) |
|||
return runCatching { tmp.isSupportCallTranslationWithStereo }.getOrDefault(false) |
|||
.also { runCatching { tmp.destroy() } } |
|||
} |
|||
|
|||
/** |
|||
* 把外部音频文件解码后的 PCM 灌入「音视频翻译」模式(仅在该模式生效)。 |
|||
*/ |
|||
fun feedAudioFilePcm(pcm: ByteArray, sampleRate: Int = 16000): Boolean { |
|||
val h = current as? com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler |
|||
?: return false |
|||
h.feedFilePcm(pcm, sampleRate) |
|||
return true |
|||
} |
|||
} |
|||
@ -0,0 +1,67 @@ |
|||
package com.jielihome.jielihome.feature.translation |
|||
|
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
|
|||
/** |
|||
* 翻译模式处理器统一接口。每种模式独立实现: |
|||
* - 声明自己消费哪些「输入流」(采集面)和产出哪些「输出流」(回放面) |
|||
* - 实现 start:启动 SDK 端音频流(进入翻译模式 / 起手机麦),把解码后的 PCM 通过 bridge 推出 |
|||
* - 实现 stop:关闭音频流,释放编解码器 |
|||
* - 实现 onTranslatedAudio:接到外部翻译服务回送的 PCM,按 outputStreamId 路由 |
|||
*/ |
|||
interface TranslationModeHandler { |
|||
val modeId: Int |
|||
val inputStreams: List<String> |
|||
val outputStreams: List<String> |
|||
|
|||
fun start(args: Map<String, Any?>) |
|||
fun stop() |
|||
val isWorking: Boolean |
|||
|
|||
/** 外部翻译服务回送的 PCM 注入点,由 TranslationFeature 路由到当前活动 handler */ |
|||
fun onTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean |
|||
} |
|||
|
|||
abstract class BaseTranslationModeHandler( |
|||
protected val btManager: JL_BluetoothManager, |
|||
protected val bridge: TranslationAudioBridge, |
|||
) : TranslationModeHandler { |
|||
|
|||
@Volatile |
|||
protected var working: Boolean = false |
|||
|
|||
override val isWorking: Boolean get() = working |
|||
|
|||
/** 把采集到的一帧 PCM 推给外部翻译服务 */ |
|||
protected fun pushFrame( |
|||
streamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat = AudioFormat(), |
|||
seq: Long = System.nanoTime(), |
|||
isFinal: Boolean = false, |
|||
) { |
|||
bridge.emitAudioFrame(modeId, streamId, pcm, format, seq, System.currentTimeMillis(), isFinal) |
|||
} |
|||
|
|||
protected fun emitLog(content: String) = bridge.emitLog(modeId, content) |
|||
protected fun emitError(code: Int, msg: String?) = bridge.emitError(modeId, code, msg) |
|||
} |
|||
|
|||
/** |
|||
* 与 SDK [com.jieli.bluetooth.bean.translation.TranslationMode] 完全一致的取值, |
|||
* 方便直接拿插件 modeId 去构造 SDK 的 TranslationMode。 |
|||
*/ |
|||
object TranslationModeIds { |
|||
const val MODE_IDLE = 0 |
|||
const val MODE_RECORD = 1 |
|||
const val MODE_RECORDING_TRANSLATION = 2 |
|||
const val MODE_CALL_TRANSLATION = 3 |
|||
const val MODE_AUDIO_TRANSLATION = 4 |
|||
const val MODE_FACE_TO_FACE_TRANSLATION = 5 |
|||
const val MODE_CALL_TRANSLATION_WITH_STEREO = 6 |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
package com.jielihome.jielihome.feature.translation |
|||
|
|||
/** |
|||
* 翻译流逻辑 ID 常量。 |
|||
* 「输入流」= 插件解码后推给翻译服务(设备麦克风、eSCO 上行/下行、本地音频文件等)。 |
|||
* 「输出流」= 翻译服务返回 TTS PCM 后,插件回送的目标(耳机扬声器、A2DP 本地、对端通话上行等)。 |
|||
*/ |
|||
object TranslationStreams { |
|||
// 输入流:插件解码后推给翻译服务的音频 |
|||
const val IN_MIC = "in.mic" |
|||
const val IN_UPLINK = "in.uplink" // 通话上行:本机用户 |
|||
const val IN_DOWNLINK = "in.downlink" // 通话下行:对端 |
|||
const val IN_AUDIO_FILE = "in.audioFile" // 本地音频文件 |
|||
|
|||
// 输出流:翻译服务回送 TTS 后插件应送达的目的地 |
|||
const val OUT_SPEAKER = "out.speaker" |
|||
const val OUT_UPLINK = "out.uplink" // 译文给对端听 |
|||
const val OUT_DOWNLINK = "out.downlink" // 译文给本机用户听 |
|||
const val OUT_LOCAL_PLAYBACK = "out.localPlayback" |
|||
} |
|||
|
|||
/** 一帧 PCM 音频规格 */ |
|||
data class AudioFormat( |
|||
val sampleRate: Int = 16000, |
|||
val channels: Int = 1, |
|||
val bitsPerSample: Int = 16, |
|||
) |
|||
@ -0,0 +1,61 @@ |
|||
package com.jielihome.jielihome.feature.translation.mode |
|||
|
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jielihome.jielihome.audio.LocalPlayer |
|||
import com.jielihome.jielihome.feature.translation.AudioFormat |
|||
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
|
|||
/** |
|||
* MODE_AUDIO_TRANSLATION —— 音视频翻译。 |
|||
* 输入:本地音频文件(由调用方自行解码并通过 [feedFilePcm] 灌进来,避免插件依赖各种封装格式解析) |
|||
* 输出:本机扬声器(A2DP),不回耳机 |
|||
*/ |
|||
class AudioTranslationModeHandler( |
|||
btManager: JL_BluetoothManager, |
|||
bridge: TranslationAudioBridge, |
|||
) : BaseTranslationModeHandler(btManager, bridge) { |
|||
|
|||
override val modeId = TranslationModeIds.MODE_AUDIO_TRANSLATION |
|||
override val inputStreams = listOf(TranslationStreams.IN_AUDIO_FILE) |
|||
override val outputStreams = listOf(TranslationStreams.OUT_LOCAL_PLAYBACK) |
|||
|
|||
private var player: LocalPlayer? = null |
|||
|
|||
override fun start(args: Map<String, Any?>) { |
|||
if (working) return |
|||
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
|||
val channels = (args["channels"] as? Int) ?: 1 |
|||
player = LocalPlayer(sampleRate, channels).also { it.start() } |
|||
working = true |
|||
emitLog("AudioTranslation start sampleRate=$sampleRate channels=$channels") |
|||
} |
|||
|
|||
/** 调用方把解码出的 PCM 灌进来推给翻译服务 */ |
|||
@Suppress("unused") |
|||
fun feedFilePcm(pcm: ByteArray, sampleRate: Int = 16000) { |
|||
if (!working) return |
|||
pushFrame(TranslationStreams.IN_AUDIO_FILE, pcm, AudioFormat(sampleRate, 1, 16)) |
|||
} |
|||
|
|||
override fun stop() { |
|||
if (!working) return |
|||
player?.stop() |
|||
player = null |
|||
working = false |
|||
emitLog("AudioTranslation stop") |
|||
} |
|||
|
|||
override fun onTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
if (outputStreamId != TranslationStreams.OUT_LOCAL_PLAYBACK) return false |
|||
player?.feed(pcm) |
|||
return true |
|||
} |
|||
} |
|||
@ -0,0 +1,138 @@ |
|||
package com.jielihome.jielihome.feature.translation.mode |
|||
|
|||
import android.content.Context |
|||
import com.jieli.bluetooth.bean.translation.AudioData |
|||
import com.jieli.bluetooth.bean.translation.TranslationMode |
|||
import com.jieli.bluetooth.constant.Constants |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jielihome.jielihome.feature.ConnectFeature |
|||
import com.jielihome.jielihome.feature.translation.AudioFormat |
|||
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime |
|||
import java.io.File |
|||
|
|||
/** |
|||
* MODE_CALL_TRANSLATION —— 通话翻译,eSCO 上下行各一路单声道。 |
|||
* 输入:uplink(本机用户)+ downlink(对端) |
|||
* 输出:uplink(TTS 给对端听)+ downlink(TTS 给本机用户听) |
|||
* |
|||
* 实现:通过 [RcspTranslationRuntime] 让耳机进入 SDK 翻译模式, |
|||
* OPUS 流式解码后按 source 分流推到 bridge; |
|||
* 回送 TTS PCM 时编码 OPUS 后通过同一 runtime 写回耳机。 |
|||
*/ |
|||
class CallTranslationModeHandler( |
|||
private val context: Context, |
|||
btManager: JL_BluetoothManager, |
|||
private val connectFeature: ConnectFeature, |
|||
bridge: TranslationAudioBridge, |
|||
) : BaseTranslationModeHandler(btManager, bridge) { |
|||
|
|||
override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION |
|||
override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) |
|||
override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) |
|||
|
|||
private var runtime: RcspTranslationRuntime? = null |
|||
|
|||
override fun start(args: Map<String, Any?>) { |
|||
if (working) return |
|||
val address = args["address"] as? String |
|||
?: connectFeature.connectedDevice()?.address |
|||
val device = address?.let { connectFeature.deviceByAddress(it) } |
|||
?: connectFeature.connectedDevice() |
|||
if (device == null) { |
|||
emitError(-200, "no connected device; pass args.address or connect first") |
|||
return |
|||
} |
|||
|
|||
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
|||
val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS |
|||
// Recording strategy 选择: |
|||
// STRATEGY_DEVICE_AUTO_RECORDING(2) —— 耳机自动检测真实 SCO 通话才开始录, |
|||
// 手机端没在打电话时耳机不会上推 PCM,本场景(双向 AI 通话翻译)行不通。 |
|||
// STRATEGY_DEVICE_ALWAYS_RECORDING(1) —— 一进入翻译模式就持续上推双声道 |
|||
// PCM,正是 AI 通话翻译需要的"始终开麦"语义。 |
|||
// 允许通过 args["strategy"] 覆盖(数值或字符串 "auto"/"always"/"custom")。 |
|||
val strategy = parseStrategy(args["strategy"]) |
|||
?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
|||
val sdkMode = TranslationMode( |
|||
TranslationMode.MODE_CALL_TRANSLATION, |
|||
audioType, |
|||
1, // 单声道 |
|||
sampleRate, |
|||
).setRecordingStrategy(strategy) |
|||
|
|||
val rt = RcspTranslationRuntime( |
|||
btManager = btManager, |
|||
device = device, |
|||
mode = sdkMode, |
|||
tempDir = File(context.cacheDir, "jieli_translation_tts"), |
|||
onPcm = { source, pcm -> |
|||
val streamId = when (source) { |
|||
AudioData.SOURCE_E_SCO_UP_LINK -> TranslationStreams.IN_UPLINK |
|||
AudioData.SOURCE_E_SCO_DOWN_LINK -> TranslationStreams.IN_DOWNLINK |
|||
else -> return@RcspTranslationRuntime |
|||
} |
|||
pushFrame(streamId, pcm, AudioFormat(sampleRate, 1, 16)) |
|||
}, |
|||
onError = { code, msg -> emitError(code, msg) } |
|||
) |
|||
rt.start().fold( |
|||
{ |
|||
runtime = rt |
|||
working = true |
|||
emitLog("CallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") |
|||
}, |
|||
{ emitError(-201, it.message); rt.stop() }, |
|||
) |
|||
} |
|||
|
|||
private fun parseStrategy(raw: Any?): Int? = when (raw) { |
|||
null -> null |
|||
is Int -> raw |
|||
is Number -> raw.toInt() |
|||
is String -> when (raw.lowercase()) { |
|||
"custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING |
|||
"always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
|||
"auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING |
|||
else -> raw.toIntOrNull() |
|||
} |
|||
else -> null |
|||
} |
|||
|
|||
private fun parseAudioType(raw: Any?): Int? = when (raw) { |
|||
null -> null |
|||
is Int -> raw |
|||
is Number -> raw.toInt() |
|||
is String -> when (raw.lowercase()) { |
|||
"opus" -> Constants.AUDIO_TYPE_OPUS |
|||
"pcm" -> Constants.AUDIO_TYPE_PCM |
|||
"speex" -> Constants.AUDIO_TYPE_SPEEX |
|||
"msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC |
|||
"jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 |
|||
else -> raw.toIntOrNull() |
|||
} |
|||
else -> null |
|||
} |
|||
|
|||
override fun stop() { |
|||
if (!working) return |
|||
runtime?.stop() |
|||
runtime = null |
|||
working = false |
|||
emitLog("CallTranslation stop") |
|||
} |
|||
|
|||
override fun onTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
if (outputStreamId != TranslationStreams.OUT_UPLINK && |
|||
outputStreamId != TranslationStreams.OUT_DOWNLINK) return false |
|||
return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false |
|||
} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
package com.jielihome.jielihome.feature.translation.mode |
|||
|
|||
import android.content.Context |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jielihome.jielihome.audio.LocalPlayer |
|||
import com.jielihome.jielihome.audio.PhoneMicCapture |
|||
import com.jielihome.jielihome.feature.translation.AudioFormat |
|||
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
|
|||
/** |
|||
* MODE_FACE_TO_FACE_TRANSLATION —— 面对面翻译。 |
|||
* 输入:手机麦 |
|||
* 输出:本机扬声器(自带的 LocalPlayer,A2DP/外放都行) |
|||
*/ |
|||
class FaceToFaceTranslationModeHandler( |
|||
private val context: Context, |
|||
btManager: JL_BluetoothManager, |
|||
bridge: TranslationAudioBridge, |
|||
) : BaseTranslationModeHandler(btManager, bridge) { |
|||
|
|||
override val modeId = TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION |
|||
override val inputStreams = listOf(TranslationStreams.IN_MIC) |
|||
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) |
|||
|
|||
private var mic: PhoneMicCapture? = null |
|||
private var player: LocalPlayer? = null |
|||
|
|||
override fun start(args: Map<String, Any?>) { |
|||
if (working) return |
|||
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
|||
mic = PhoneMicCapture( |
|||
context, sampleRate, |
|||
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, |
|||
onError = { code, msg -> emitError(code, msg) }, |
|||
) |
|||
if (mic?.start() != true) return |
|||
player = LocalPlayer(sampleRate, 1).also { it.start() } |
|||
working = true |
|||
emitLog("FaceToFaceTranslation start") |
|||
} |
|||
|
|||
override fun stop() { |
|||
if (!working) return |
|||
mic?.stop(); mic = null |
|||
player?.stop(); player = null |
|||
working = false |
|||
emitLog("FaceToFaceTranslation stop") |
|||
} |
|||
|
|||
override fun onTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false |
|||
player?.feed(pcm) |
|||
return true |
|||
} |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
package com.jielihome.jielihome.feature.translation.mode |
|||
|
|||
import android.content.Context |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jielihome.jielihome.audio.PhoneMicCapture |
|||
import com.jielihome.jielihome.feature.translation.AudioFormat |
|||
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
|
|||
/** |
|||
* MODE_RECORD —— 单向录音翻译。 |
|||
* 输入:手机麦 → 推到外部 |
|||
* 输出:speaker ← 外部回送 TTS PCM;当前不依赖耳机回送,直接交给宿主自行播放(可选)。 |
|||
* |
|||
* 注:本模式完全不动 RCSP,只用手机麦。耳机端不需要进入翻译模式。 |
|||
*/ |
|||
class RecordModeHandler( |
|||
private val context: Context, |
|||
btManager: JL_BluetoothManager, |
|||
bridge: TranslationAudioBridge, |
|||
) : BaseTranslationModeHandler(btManager, bridge) { |
|||
|
|||
override val modeId = TranslationModeIds.MODE_RECORD |
|||
override val inputStreams = listOf(TranslationStreams.IN_MIC) |
|||
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) |
|||
|
|||
private var mic: PhoneMicCapture? = null |
|||
|
|||
override fun start(args: Map<String, Any?>) { |
|||
if (working) return |
|||
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
|||
val frameMs = (args["frameDurationMs"] as? Int) ?: 20 |
|||
mic = PhoneMicCapture( |
|||
context, sampleRate, frameMs, |
|||
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, |
|||
onError = { code, msg -> emitError(code, msg) }, |
|||
) |
|||
if (mic?.start() != true) return |
|||
working = true |
|||
emitLog("RecordMode start sampleRate=$sampleRate") |
|||
} |
|||
|
|||
override fun stop() { |
|||
if (!working) return |
|||
mic?.stop() |
|||
mic = null |
|||
working = false |
|||
emitLog("RecordMode stop") |
|||
} |
|||
|
|||
override fun onTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false |
|||
// RECORD 模式下耳机不参与回送;如果宿主想播给用户听,自己用 AudioTrack 播即可。 |
|||
emitLog("recv tts pcm=${pcm.size}B final=$isFinal (host-side playback)") |
|||
return true |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
package com.jielihome.jielihome.feature.translation.mode |
|||
|
|||
import android.content.Context |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jielihome.jielihome.audio.PhoneMicCapture |
|||
import com.jielihome.jielihome.feature.translation.AudioFormat |
|||
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
|
|||
/** |
|||
* MODE_RECORDING_TRANSLATION —— 流式录音翻译。等同于 RecordMode 的实时版,差别只在语义: |
|||
* 调用方实现实时 STT/翻译流水线、不缓存整段录音再发。 |
|||
*/ |
|||
class RecordingTranslationModeHandler( |
|||
private val context: Context, |
|||
btManager: JL_BluetoothManager, |
|||
bridge: TranslationAudioBridge, |
|||
) : BaseTranslationModeHandler(btManager, bridge) { |
|||
|
|||
override val modeId = TranslationModeIds.MODE_RECORDING_TRANSLATION |
|||
override val inputStreams = listOf(TranslationStreams.IN_MIC) |
|||
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) |
|||
|
|||
private var mic: PhoneMicCapture? = null |
|||
|
|||
override fun start(args: Map<String, Any?>) { |
|||
if (working) return |
|||
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
|||
mic = PhoneMicCapture( |
|||
context, sampleRate, |
|||
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, |
|||
onError = { code, msg -> emitError(code, msg) }, |
|||
) |
|||
if (mic?.start() != true) return |
|||
working = true |
|||
emitLog("RecordingTranslation start sampleRate=$sampleRate") |
|||
} |
|||
|
|||
override fun stop() { |
|||
if (!working) return |
|||
mic?.stop() |
|||
mic = null |
|||
working = false |
|||
emitLog("RecordingTranslation stop") |
|||
} |
|||
|
|||
override fun onTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false |
|||
emitLog("recv tts ${pcm.size}B final=$isFinal") |
|||
return true |
|||
} |
|||
} |
|||
@ -0,0 +1,128 @@ |
|||
package com.jielihome.jielihome.feature.translation.mode |
|||
|
|||
import android.content.Context |
|||
import com.jieli.bluetooth.bean.translation.AudioData |
|||
import com.jieli.bluetooth.bean.translation.TranslationMode |
|||
import com.jieli.bluetooth.constant.Constants |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jielihome.jielihome.audio.PcmKit |
|||
import com.jielihome.jielihome.feature.ConnectFeature |
|||
import com.jielihome.jielihome.feature.translation.AudioFormat |
|||
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
|||
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
|||
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime |
|||
import java.io.File |
|||
|
|||
/** |
|||
* MODE_CALL_TRANSLATION_WITH_STEREO —— 立体声通话翻译。 |
|||
* SDK 上来一路立体声 PCM(左=上行,右=下行),软件分离后等价于普通通话翻译两路。 |
|||
*/ |
|||
class StereoCallTranslationModeHandler( |
|||
private val context: Context, |
|||
btManager: JL_BluetoothManager, |
|||
private val connectFeature: ConnectFeature, |
|||
bridge: TranslationAudioBridge, |
|||
) : BaseTranslationModeHandler(btManager, bridge) { |
|||
|
|||
override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO |
|||
override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) |
|||
override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) |
|||
|
|||
private var runtime: RcspTranslationRuntime? = null |
|||
|
|||
override fun start(args: Map<String, Any?>) { |
|||
if (working) return |
|||
val address = args["address"] as? String |
|||
?: connectFeature.connectedDevice()?.address |
|||
val device = address?.let { connectFeature.deviceByAddress(it) } |
|||
?: connectFeature.connectedDevice() |
|||
if (device == null) { |
|||
emitError(-200, "no connected device") |
|||
return |
|||
} |
|||
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
|||
val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS |
|||
// 与 demo 对齐:默认 ALWAYS_RECORDING,进 mode 即持续上推 PCM; |
|||
// AUTO 会在没有真实通话事件时静默不出帧。 |
|||
val strategy = parseStrategy(args["strategy"]) |
|||
?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
|||
val sdkMode = TranslationMode( |
|||
TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO, |
|||
audioType, |
|||
2, // 立体声 |
|||
sampleRate, |
|||
).setRecordingStrategy(strategy) |
|||
|
|||
val rt = RcspTranslationRuntime( |
|||
btManager = btManager, |
|||
device = device, |
|||
mode = sdkMode, |
|||
tempDir = File(context.cacheDir, "jieli_translation_tts"), |
|||
onPcm = { source, stereoPcm -> |
|||
if (source != AudioData.SOURCE_E_SCO_MIX) return@RcspTranslationRuntime |
|||
val (left, right) = PcmKit.splitStereo16(stereoPcm) |
|||
val fmt = AudioFormat(sampleRate, 1, 16) |
|||
pushFrame(TranslationStreams.IN_UPLINK, left, fmt) |
|||
pushFrame(TranslationStreams.IN_DOWNLINK, right, fmt) |
|||
}, |
|||
onError = { code, msg -> emitError(code, msg) } |
|||
) |
|||
rt.start().fold( |
|||
{ |
|||
runtime = rt |
|||
working = true |
|||
emitLog("StereoCallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") |
|||
}, |
|||
{ emitError(-201, it.message); rt.stop() }, |
|||
) |
|||
} |
|||
|
|||
private fun parseStrategy(raw: Any?): Int? = when (raw) { |
|||
null -> null |
|||
is Int -> raw |
|||
is Number -> raw.toInt() |
|||
is String -> when (raw.lowercase()) { |
|||
"custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING |
|||
"always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
|||
"auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING |
|||
else -> raw.toIntOrNull() |
|||
} |
|||
else -> null |
|||
} |
|||
|
|||
private fun parseAudioType(raw: Any?): Int? = when (raw) { |
|||
null -> null |
|||
is Int -> raw |
|||
is Number -> raw.toInt() |
|||
is String -> when (raw.lowercase()) { |
|||
"opus" -> Constants.AUDIO_TYPE_OPUS |
|||
"pcm" -> Constants.AUDIO_TYPE_PCM |
|||
"speex" -> Constants.AUDIO_TYPE_SPEEX |
|||
"msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC |
|||
"jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 |
|||
else -> raw.toIntOrNull() |
|||
} |
|||
else -> null |
|||
} |
|||
|
|||
override fun stop() { |
|||
if (!working) return |
|||
runtime?.stop() |
|||
runtime = null |
|||
working = false |
|||
emitLog("StereoCallTranslation stop") |
|||
} |
|||
|
|||
override fun onTranslatedAudio( |
|||
outputStreamId: String, |
|||
pcm: ByteArray, |
|||
format: AudioFormat, |
|||
isFinal: Boolean, |
|||
): Boolean { |
|||
if (outputStreamId != TranslationStreams.OUT_UPLINK && |
|||
outputStreamId != TranslationStreams.OUT_DOWNLINK) return false |
|||
return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
package com.jielihome.jielihome.feature.translation.runtime |
|||
|
|||
import com.jieli.bluetooth.bean.translation.AudioData |
|||
import com.jieli.bluetooth.bean.translation.TranslationMode |
|||
import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback |
|||
import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi |
|||
|
|||
/** |
|||
* SDK 的 [TranslationImpl] 构造函数要求一个 [IAITranslationApi]。 |
|||
* 我们走的是「外部翻译服务」路线,SDK 自带的 AI 流程不启用,所以这里给个空实现。 |
|||
*/ |
|||
internal class NoOpAITranslationApi : IAITranslationApi { |
|||
override fun isWorking(): Boolean = false |
|||
override fun startTranslating(mode: TranslationMode, cb: AITranslationCallback) { /* no-op */ } |
|||
override fun stopTranslating() { /* no-op */ } |
|||
override fun writeAudio(data: AudioData) { /* no-op */ } |
|||
} |
|||
@ -0,0 +1,470 @@ |
|||
package com.jielihome.jielihome.feature.translation.runtime |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.bean.translation.AudioData |
|||
import com.jieli.bluetooth.bean.translation.TranslationMode |
|||
import com.jieli.bluetooth.constant.Constants |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl |
|||
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback |
|||
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback |
|||
import android.util.Log |
|||
import com.jielihome.jielihome.audio.OpusStreamDecoder |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
import com.jieli.jl_audio_decode.callback.OnStateCallback |
|||
import com.jieli.jl_audio_decode.opus.OpusManager |
|||
import java.io.ByteArrayOutputStream |
|||
import java.io.File |
|||
import java.util.ArrayDeque |
|||
import java.util.concurrent.Executors |
|||
import java.util.concurrent.ScheduledExecutorService |
|||
import java.util.concurrent.TimeUnit |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
|
|||
/** |
|||
* RCSP 翻译模式运行时。 |
|||
* |
|||
* audioType: |
|||
* - [Constants.AUDIO_TYPE_OPUS](默认):上行 OPUS → 解码 PCM;下行 PCM → **整段** 编码 OPUS 写回 |
|||
* - [Constants.AUDIO_TYPE_PCM]:上下行直接走 PCM,不经编解码 |
|||
* |
|||
* # TTS 回灌策略(与官方 demo `MachineTranslation.tryToTTS` / `OpusHelper.encodeFile` 对齐) |
|||
* |
|||
* 之前是流式 [com.jielihome.jielihome.audio.OpusStreamEncoder]:每出一帧 OPUS 立即包成 |
|||
* AudioData 调 [TranslationImpl.writeAudioData],导致耳机端 RCSP 把每帧当成"独立的一段 |
|||
* TTS 起点",解码器频繁 reset → 杂音 / 断续。 |
|||
* |
|||
* 现在按 demo:[feedTtsPcm] 把 PCM 累积到 per-leg 缓冲;上层在每段 utterance 末尾把 |
|||
* `isFinal=true` 透传过来;runtime 这一刻: |
|||
* 1. 把累积 PCM 写到临时 .pcm 文件; |
|||
* 2. `OpusManager.encodeFile(pcmFile, opusFile, callback)` 离线整段编码(带 head 的默认 OpusOption); |
|||
* 3. 整段读出 → **一个** [AudioData] → [WriteScheduler] 单次入队下发。 |
|||
* |
|||
* # 文件命名 |
|||
* 临时文件位于 [tempDir],按 `tts_<source>_<seq>.<pcm|opus>` 命名;编码完成异步删除。 |
|||
* |
|||
* # source 字段写回方向 |
|||
* - 通话翻译给「对端听」 → AudioData.source = SOURCE_E_SCO_UP_LINK |
|||
* - 通话翻译给「本机听」 → AudioData.source = SOURCE_E_SCO_DOWN_LINK |
|||
* - 录音/音视频/面对面 → AudioData.source = SOURCE_PHONE_MIC(SDK 按 mode 自分发) |
|||
*/ |
|||
class RcspTranslationRuntime( |
|||
private val btManager: JL_BluetoothManager, |
|||
private val device: BluetoothDevice, |
|||
private val mode: TranslationMode, |
|||
private val tempDir: File, |
|||
/** 解码(或直传 PCM)后的音频上行;source 为 SDK 原值 */ |
|||
private val onPcm: (source: Int, pcm: ByteArray) -> Unit, |
|||
private val onError: (code: Int, msg: String?) -> Unit, |
|||
private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200, |
|||
) { |
|||
|
|||
private val translationImpl = TranslationImpl(btManager, NoOpAITranslationApi(), device) |
|||
private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM |
|||
private val sampleRateHz = mode.sampleRate.takeIf { it > 0 } ?: 16000 |
|||
|
|||
/** 入栈解码器:仅 OPUS 模式下创建 */ |
|||
private val upDecoder: OpusStreamDecoder? = if (isPcmMode) null else OpusStreamDecoder( |
|||
channel = 1, |
|||
packetSize = opusPacketSize, |
|||
sampleRate = sampleRateHz, |
|||
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_UP_LINK, pcm) }, |
|||
onError = { c, m -> onError(c, "upDecoder: $m") }, |
|||
) |
|||
|
|||
private val downDecoder: OpusStreamDecoder? = |
|||
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION) OpusStreamDecoder( |
|||
channel = 1, |
|||
packetSize = opusPacketSize, |
|||
sampleRate = sampleRateHz, |
|||
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_DOWN_LINK, pcm) }, |
|||
onError = { c, m -> onError(c, "downDecoder: $m") }, |
|||
) else null |
|||
|
|||
private val stereoDecoder: OpusStreamDecoder? = |
|||
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO) OpusStreamDecoder( |
|||
channel = 2, packetSize = 80, |
|||
sampleRate = sampleRateHz, |
|||
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_MIX, pcm) }, |
|||
onError = { c, m -> onError(c, "stereoDecoder: $m") }, |
|||
) else null |
|||
|
|||
/** 调试统计:SDK onReceiveAudioData 被回调多少次、按 (source,type) 分桶 */ |
|||
@Volatile private var rxCount = 0L |
|||
@Volatile private var rxTypeMismatch = 0L |
|||
@Volatile private var rxNullPayload = 0L |
|||
@Volatile private var rxLastReportMs = 0L |
|||
|
|||
private val translationCallback = object : TranslationCallback { |
|||
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { |
|||
Log.d(TAG, "TranslationCallback.onModeChange: mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel}") |
|||
} |
|||
|
|||
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { |
|||
rxCount++ |
|||
val now = System.currentTimeMillis() |
|||
if (now - rxLastReportMs >= 1000L) { |
|||
Log.d(TAG, "RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=${data.audioData?.size ?: 0}") |
|||
rxCount = 0; rxTypeMismatch = 0; rxNullPayload = 0; rxLastReportMs = now |
|||
} |
|||
if (data.type != mode.audioType) { |
|||
rxTypeMismatch++ |
|||
return |
|||
} |
|||
val payload = data.audioData |
|||
if (payload == null) { |
|||
rxNullPayload++ |
|||
return |
|||
} |
|||
if (isPcmMode) { |
|||
onPcm(data.source, payload) |
|||
return |
|||
} |
|||
when (data.source) { |
|||
AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload) |
|||
AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload) |
|||
AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload) |
|||
} |
|||
} |
|||
|
|||
override fun onError(d: BluetoothDevice, code: Int, msg: String) { |
|||
Log.e(TAG, "TranslationCallback.onError code=$code msg=$msg") |
|||
this@RcspTranslationRuntime.onError(code, msg) |
|||
} |
|||
} |
|||
|
|||
companion object { |
|||
private const val TAG = "RcspTranslationRuntime" |
|||
/** 每腿 writeAudioData 缓冲队列上限。整段下发后队列里几乎不会堆积,留 8 个槽足够。 */ |
|||
private const val WRITE_QUEUE_LIMIT = 8 |
|||
/** 单段 PCM 上限:≈ 60s 16k mono 16bit = 1.92MB,正常 utterance 远低于此值。 */ |
|||
private const val PCM_BUFFER_HARD_LIMIT_BYTES = 2 * 1024 * 1024 |
|||
/** |
|||
* 周期性 flush 间隔:每隔此毫秒数巡检一次 buffer,**只要非空就 flush**。 |
|||
* |
|||
* 实测:低于 1s(试过 200ms)耳机端 RCSP 解码器会被频繁 reset 直至死机, |
|||
* 因此周期上限只能保持 1s。降低段间延迟改走"首段优先 flush"路线(B)。 |
|||
*/ |
|||
private const val PERIODIC_FLUSH_MS = 1_000L |
|||
/** |
|||
* 首段优先 flush 阈值(路线 B):每个 utterance 的**首段**达到此毫秒数对应字节量 |
|||
* 就立即 flush,不等周期。让用户开口到对方听到的延迟尽可能小(< 200ms)。 |
|||
* |
|||
* 触发后转入周期模式直到 isFinal=true 重置。火山不发 isFinal 的会话里, |
|||
* 只有第一句享受首段优先;后续句统一走 [PERIODIC_FLUSH_MS] 周期。这是为了 |
|||
* 避免连续讲话被反复识别成"新 utterance"导致每 100ms 切一段、reset 频率失控。 |
|||
*/ |
|||
private const val FIRST_SEGMENT_FLUSH_MS = 100L |
|||
} |
|||
|
|||
/** |
|||
* 串行化的 writeAudioData 调度器(每个 source 一个)。 |
|||
* |
|||
* RCSP/SPP 写入要求:上一帧 [writeAudioData] 的回调返回(成功或失败)之前不要再发, |
|||
* 否则 SDK 会拒收新请求并报 "Operation in progress"。这里维护一个 in-flight ≤ 1 |
|||
* 的 FIFO 队列,溢出时丢最老的帧(保实时性,不堆积延迟)。 |
|||
*/ |
|||
private class WriteScheduler( |
|||
private val tag: String, |
|||
private val send: (AudioData, OnRcspActionCallback<Boolean>) -> Unit, |
|||
) { |
|||
private val queue = ArrayDeque<AudioData>(WRITE_QUEUE_LIMIT) |
|||
private val inFlight = AtomicBoolean(false) |
|||
private val lock = Any() |
|||
@Volatile private var droppedSinceReport = 0L |
|||
@Volatile private var lastReportMs = 0L |
|||
|
|||
fun enqueue(data: AudioData) { |
|||
val toSend: AudioData? = synchronized(lock) { |
|||
if (inFlight.compareAndSet(false, true)) { |
|||
data |
|||
} else { |
|||
if (queue.size >= WRITE_QUEUE_LIMIT) { |
|||
queue.pollFirst() // 丢最老 |
|||
droppedSinceReport++ |
|||
val now = System.currentTimeMillis() |
|||
if (now - lastReportMs >= 1000L) { |
|||
Log.w(TAG, "writeAudioData[$tag] queue full, dropped=$droppedSinceReport (last 1s)") |
|||
droppedSinceReport = 0 |
|||
lastReportMs = now |
|||
} |
|||
} |
|||
queue.offerLast(data) |
|||
null |
|||
} |
|||
} |
|||
if (toSend != null) dispatch(toSend) |
|||
} |
|||
|
|||
private fun dispatch(data: AudioData) { |
|||
send(data, object : OnRcspActionCallback<Boolean> { |
|||
override fun onSuccess(d: BluetoothDevice?, ok: Boolean?) = onComplete() |
|||
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { |
|||
val msg = err?.message.orEmpty() |
|||
if (!msg.contains("Operation in progress", ignoreCase = true)) { |
|||
Log.w(TAG, "writeAudioData[$tag] error: code=${err?.code} msg=$msg") |
|||
} |
|||
onComplete() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun onComplete() { |
|||
val next: AudioData? = synchronized(lock) { |
|||
val n = queue.pollFirst() |
|||
if (n == null) inFlight.set(false) |
|||
n |
|||
} |
|||
if (next != null) dispatch(next) |
|||
} |
|||
|
|||
fun clear() { |
|||
synchronized(lock) { |
|||
queue.clear() |
|||
inFlight.set(false) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private val uplinkWriter = WriteScheduler("uplink") { data, cb -> |
|||
translationImpl.writeAudioData(data, cb) |
|||
} |
|||
private val downlinkWriter = WriteScheduler("downlink") { data, cb -> |
|||
translationImpl.writeAudioData(data, cb) |
|||
} |
|||
private val phoneMicWriter = WriteScheduler("phoneMic") { data, cb -> |
|||
translationImpl.writeAudioData(data, cb) |
|||
} |
|||
/** 兜底:PCM 直传 / 未识别 source 走这条;和 OPUS 编码器互不抢 in-flight 槽位。 */ |
|||
private val rawWriter = WriteScheduler("raw") { data, cb -> |
|||
translationImpl.writeAudioData(data, cb) |
|||
} |
|||
|
|||
/** |
|||
* Per-leg PCM 缓冲。`feedTtsPcm` 累积写入,`isFinal=true` 时整段编码下发后清空。 |
|||
* |
|||
* key = outputStreamId(OUT_UPLINK / OUT_DOWNLINK / OUT_SPEAKER 等) |
|||
*/ |
|||
private val pcmBuffers = mutableMapOf<String, ByteArrayOutputStream>() |
|||
/** 每条 leg 是否仍处于"等待首段触发"状态(路线 B 首段优先 flush 用)。 */ |
|||
private val firstSegmentPending = mutableMapOf<String, Boolean>() |
|||
private val bufferLock = Any() |
|||
private var encodeSeq = 0L |
|||
|
|||
/** 周期性 flush 巡检线程;start() 启动,stop() 关闭。 */ |
|||
private var flushWatcher: ScheduledExecutorService? = null |
|||
|
|||
/** 启动前置校验 + 进入翻译模式 */ |
|||
fun start(): Result<Unit> { |
|||
if (!translationImpl.isInit) { |
|||
return Result.failure(IllegalStateException("RCSP not init for ${device.address}")) |
|||
} |
|||
if (!translationImpl.isSupportTranslation) { |
|||
return Result.failure(IllegalStateException("device does not support translation")) |
|||
} |
|||
if (mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO && |
|||
!translationImpl.isSupportCallTranslationWithStereo |
|||
) { |
|||
return Result.failure(IllegalStateException("device does not support stereo call translation")) |
|||
} |
|||
if (!tempDir.exists()) tempDir.mkdirs() |
|||
|
|||
upDecoder?.start() |
|||
downDecoder?.start() |
|||
stereoDecoder?.start() |
|||
|
|||
translationImpl.addTranslationCallback(translationCallback) |
|||
Log.d(TAG, "enterMode: mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} (waiting for onModeChange/onReceiveAudioData)") |
|||
translationImpl.enterMode(mode, translationCallback) |
|||
|
|||
// OPUS 模式启动周期性 flush 巡检:每 [PERIODIC_FLUSH_MS] 切一次缓冲。 |
|||
if (!isPcmMode) startPeriodicFlushWatcher() |
|||
return Result.success(Unit) |
|||
} |
|||
|
|||
private fun startPeriodicFlushWatcher() { |
|||
flushWatcher?.shutdownNow() |
|||
val exec = Executors.newSingleThreadScheduledExecutor { r -> |
|||
Thread(r, "rcsp-tts-flush").apply { isDaemon = true } |
|||
} |
|||
flushWatcher = exec |
|||
exec.scheduleAtFixedRate( |
|||
{ runCatching { periodicFlush() } }, |
|||
PERIODIC_FLUSH_MS, PERIODIC_FLUSH_MS, TimeUnit.MILLISECONDS, |
|||
) |
|||
} |
|||
|
|||
/** 每秒巡检:每条 leg 的 buffer 只要非空就立即切走 + 编码 + 下发(不依赖任何外部信号)。 */ |
|||
private fun periodicFlush() { |
|||
val toEncode = mutableListOf<Triple<Int, String, ByteArray>>() |
|||
synchronized(bufferLock) { |
|||
for ((leg, buf) in pcmBuffers) { |
|||
if (buf.size() == 0) continue |
|||
val source = when (leg) { |
|||
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK |
|||
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK |
|||
else -> AudioData.SOURCE_PHONE_MIC |
|||
} |
|||
val out = buf.toByteArray() |
|||
buf.reset() |
|||
Log.d(TAG, "periodicFlush: leg=$leg bytes=${out.size}") |
|||
toEncode.add(Triple(source, leg, out)) |
|||
} |
|||
} |
|||
// encode 走 async,必须释放锁后做。 |
|||
for ((source, leg, pcm) in toEncode) { |
|||
encodeAndDispatchAsync(source, leg, pcm) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 把外部翻译服务回送的 PCM 注入回耳机。 |
|||
* |
|||
* 策略: |
|||
* - PCM 模式:每次调用直接当作一段 AudioData 下发(SDK 内部按 blockMtu 分片)。 |
|||
* - OPUS 模式:纯时间驱动 flush —— 火山等端到端服务的段尾事件不可靠, |
|||
* 不能依赖 isFinal 或字节量阈值。改为: |
|||
* a) feedTtsPcm 仅追加 buffer,不主动 flush(除非 isFinal=true); |
|||
* b) [PERIODIC_FLUSH_MS] (1s) 巡检线程每秒切走 buffer 并整段编码下发; |
|||
* c) `isFinal=true` 仍短路立即 flush(兼容主动信号,不再依赖)。 |
|||
* 最大听感延迟 ≤ 1s,且短句 / 没有段尾事件的服务都不会卡死。 |
|||
* |
|||
* @param outputStreamId 决定 source: |
|||
* [TranslationStreams.OUT_UPLINK] / [TranslationStreams.OUT_DOWNLINK] 用于通话翻译; |
|||
* 其它(speaker/localPlayback)由 ModeHandler 自己处理,不会落到这里。 |
|||
* @param isFinal 本帧是否为当前 utterance 的最后一帧;触发 buffer 残余立即 flush。 |
|||
*/ |
|||
fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean { |
|||
val source = when (outputStreamId) { |
|||
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK |
|||
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK |
|||
else -> AudioData.SOURCE_PHONE_MIC |
|||
} |
|||
if (isPcmMode) { |
|||
// PCM 直传:每次调用一段 AudioData。`isFinal` 在该模式下不影响下发节奏。 |
|||
writeBack(AudioData(source, Constants.AUDIO_TYPE_PCM, pcm)) |
|||
return true |
|||
} |
|||
// OPUS 模式:累积到 buffer,三条 flush 触发链(按优先级): |
|||
// 1. isFinal=true → 立即 flush,并把首段优先重置回 true(下一句新算) |
|||
// 2. 首段优先(路线 B) → 当前 leg 处于 firstSegmentPending=true 且 buffer |
|||
// 累积到 FIRST_SEGMENT_FLUSH_MS 字节量时立即 flush, |
|||
// 让"开口到对方听见"的延迟最小化(< 200ms) |
|||
// 3. 周期 flush(路线 A) → 由 [PERIODIC_FLUSH_MS] 巡检线程接管 |
|||
val pendingFlush: ByteArray? = synchronized(bufferLock) { |
|||
val buf = pcmBuffers.getOrPut(outputStreamId) { ByteArrayOutputStream() } |
|||
if (pcm.isNotEmpty()) { |
|||
if (buf.size() + pcm.size > PCM_BUFFER_HARD_LIMIT_BYTES) { |
|||
Log.w(TAG, "feedTtsPcm: leg=$outputStreamId pcm buffer hit hard limit ${PCM_BUFFER_HARD_LIMIT_BYTES}, dropping segment") |
|||
buf.reset() |
|||
return false |
|||
} |
|||
buf.write(pcm) |
|||
} |
|||
// 1) 段尾信号:立即下发尾段并把首段标志重置回 true。 |
|||
if (isFinal) { |
|||
firstSegmentPending[outputStreamId] = true |
|||
val out = buf.toByteArray() |
|||
buf.reset() |
|||
return@synchronized if (out.isEmpty()) null else out |
|||
} |
|||
// 2) 首段优先:仅当 leg 处于 pending 状态(首次 utterance 起始或 isFinal 重置后)。 |
|||
val pending = firstSegmentPending.getOrDefault(outputStreamId, true) |
|||
if (pending) { |
|||
val firstFlushBytes = (sampleRateHz * 2 * FIRST_SEGMENT_FLUSH_MS / 1000L).toInt() |
|||
if (buf.size() >= firstFlushBytes) { |
|||
firstSegmentPending[outputStreamId] = false |
|||
val out = buf.toByteArray() |
|||
buf.reset() |
|||
Log.d(TAG, "firstSegmentFlush: leg=$outputStreamId bytes=${out.size}") |
|||
return@synchronized if (out.isEmpty()) null else out |
|||
} |
|||
} |
|||
null |
|||
} |
|||
if (pendingFlush != null) { |
|||
encodeAndDispatchAsync(source, outputStreamId, pendingFlush) |
|||
} |
|||
return true |
|||
} |
|||
|
|||
private fun encodeAndDispatchAsync(source: Int, leg: String, pcmBytes: ByteArray) { |
|||
val seq = synchronized(bufferLock) { ++encodeSeq } |
|||
val pcmFile = File(tempDir, "tts_${source}_$seq.pcm") |
|||
val opusFile = File(tempDir, "tts_${source}_$seq.opus") |
|||
try { |
|||
pcmFile.writeBytes(pcmBytes) |
|||
} catch (e: Throwable) { |
|||
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq write pcm file failed: ${e.message}") |
|||
return |
|||
} |
|||
// OpusManager.encodeFile(in, out, callback) 默认 OpusOption(带 head),与 demo 一致。 |
|||
val encoder = OpusManager() |
|||
encoder.encodeFile(pcmFile.absolutePath, opusFile.absolutePath, object : OnStateCallback { |
|||
override fun onStart() {} |
|||
override fun onComplete(path: String?) { |
|||
try { |
|||
val opusBytes = if (opusFile.exists()) opusFile.readBytes() else null |
|||
if (opusBytes == null || opusBytes.isEmpty()) { |
|||
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq: empty opus output") |
|||
} else { |
|||
Log.d(TAG, "encodeAndDispatch leg=$leg seq=$seq pcm=${pcmBytes.size}B opus=${opusBytes.size}B") |
|||
writeBack(AudioData(source, Constants.AUDIO_TYPE_OPUS, opusBytes)) |
|||
} |
|||
} finally { |
|||
runCatching { encoder.release() } |
|||
runCatching { pcmFile.delete() } |
|||
runCatching { opusFile.delete() } |
|||
} |
|||
} |
|||
override fun onError(code: Int, message: String?) { |
|||
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq encodeFile error code=$code msg=$message") |
|||
runCatching { encoder.release() } |
|||
runCatching { pcmFile.delete() } |
|||
runCatching { opusFile.delete() } |
|||
onError(code, "encodeFile $leg: $message") |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun stop() { |
|||
runCatching { flushWatcher?.shutdownNow() } |
|||
flushWatcher = null |
|||
runCatching { |
|||
translationImpl.exitMode(object : OnRcspActionCallback<Int> { |
|||
override fun onSuccess(d: BluetoothDevice?, t: Int?) {} |
|||
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {} |
|||
}) |
|||
} |
|||
runCatching { translationImpl.removeTranslationCallback(translationCallback) } |
|||
runCatching { translationImpl.destroy() } |
|||
runCatching { upDecoder?.stop() } |
|||
runCatching { downDecoder?.stop() } |
|||
runCatching { stereoDecoder?.stop() } |
|||
runCatching { |
|||
uplinkWriter.clear() |
|||
downlinkWriter.clear() |
|||
phoneMicWriter.clear() |
|||
rawWriter.clear() |
|||
} |
|||
synchronized(bufferLock) { |
|||
pcmBuffers.values.forEach { it.reset() } |
|||
pcmBuffers.clear() |
|||
firstSegmentPending.clear() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 把 OPUS / PCM 帧入队到对应 source 的 [WriteScheduler],由调度器串行发起 RCSP |
|||
* writeAudioData,避免并发写入触发 "Operation in progress"。 |
|||
*/ |
|||
private fun writeBack(data: AudioData) { |
|||
val scheduler = when (data.source) { |
|||
AudioData.SOURCE_E_SCO_UP_LINK -> uplinkWriter |
|||
AudioData.SOURCE_E_SCO_DOWN_LINK -> downlinkWriter |
|||
AudioData.SOURCE_PHONE_MIC -> phoneMicWriter |
|||
else -> rawWriter |
|||
} |
|||
scheduler.enqueue(data) |
|||
} |
|||
} |
|||
@ -0,0 +1,196 @@ |
|||
package com.jielihome.jielihome.feature.voice |
|||
|
|||
import android.bluetooth.BluetoothDevice |
|||
import com.jieli.bluetooth.bean.base.BaseError |
|||
import com.jieli.bluetooth.bean.record.RecordParam |
|||
import com.jieli.bluetooth.bean.record.RecordState |
|||
import com.jieli.bluetooth.impl.JL_BluetoothManager |
|||
import com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl |
|||
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback |
|||
import com.jieli.bluetooth.interfaces.rcsp.record.OnRecordStateCallback |
|||
import com.jielihome.jielihome.audio.OpusStreamDecoder |
|||
import com.jielihome.jielihome.bridge.EventDispatcher |
|||
import com.jielihome.jielihome.feature.ConnectFeature |
|||
|
|||
/** |
|||
* 语音助手 / 唤醒功能。 |
|||
* |
|||
* # 工作模型(重要) |
|||
* 唤醒是「**耳机端主动检测 + 主动推**」模式。零等待: |
|||
* |
|||
* 1. 插件初始化时立即注册一个常驻 [OnRecordStateCallback]([attach]) |
|||
* 2. 耳机本地检测到唤醒词 / 用户按下语音键 |
|||
* → SDK 直接回调 onStateChange(state=START),**不需要 APP 先发任何指令** |
|||
* → 紧接着持续回调 state=WORKING + voiceData(PCM 或 OPUS 帧) |
|||
* → 通话结束/取消时回调 state=IDLE |
|||
* 3. 插件解码后通过 EventDispatcher 推三类事件给宿主: |
|||
* `speechStart` / `speechAudio` / `speechEnd` |
|||
* |
|||
* # 主动控制(可选) |
|||
* - [start]:APP 主动触发语音助手(用户点 UI 的"按住说话"按钮场景) |
|||
* - [stop]:APP 主动结束(用户松手 / 超时取消) |
|||
* - 这两个调用内部封装了 SDK 的 StartSpeechCmd/StopSpeechCmd。 |
|||
* 大部分场景下你不需要调它们——耳机会自动管理。 |
|||
*/ |
|||
class SpeechFeature( |
|||
private val btManager: JL_BluetoothManager, |
|||
private val connectFeature: ConnectFeature, |
|||
private val dispatcher: EventDispatcher, |
|||
) { |
|||
private val recordOp: RecordOpImpl = RecordOpImpl.getInstance(btManager) |
|||
|
|||
@Volatile |
|||
private var decoder: OpusStreamDecoder? = null |
|||
|
|||
@Volatile |
|||
private var currentParam: RecordParam? = null |
|||
|
|||
private val recordCallback = OnRecordStateCallback { device, state -> |
|||
if (device == null || state == null) return@OnRecordStateCallback |
|||
when (state.state) { |
|||
RecordState.RECORD_STATE_START -> onStart(device, state) |
|||
RecordState.RECORD_STATE_WORKING -> onWorking(device, state) |
|||
RecordState.RECORD_STATE_IDLE -> onEnd(device, state) |
|||
} |
|||
} |
|||
|
|||
fun attach() { |
|||
recordOp.addOnRecordStateCallback(recordCallback) |
|||
} |
|||
|
|||
fun detach() { |
|||
recordOp.removeOnRecordStateCallback(recordCallback) |
|||
releaseDecoder() |
|||
} |
|||
|
|||
fun isRecording(address: String?): Boolean { |
|||
val d = address?.let { connectFeature.deviceByAddress(it) } |
|||
?: connectFeature.connectedDevice() ?: return false |
|||
return recordOp.isRecording(d) |
|||
} |
|||
|
|||
/** |
|||
* APP 主动启动语音助手;通常无需调用(耳机会自动推)。 |
|||
* @param voiceType RecordParam.VOICE_TYPE_PCM / SPEEX / OPUS |
|||
* @param sampleRate RecordParam.SAMPLE_RATE_8K / 16K(注意取值是 8/16,不是 8000/16000) |
|||
* @param vadWay RecordParam.VAD_WAY_DEVICE / SDK |
|||
*/ |
|||
fun start( |
|||
address: String?, |
|||
voiceType: Int = RecordParam.VOICE_TYPE_OPUS, |
|||
sampleRate: Int = RecordParam.SAMPLE_RATE_16K, |
|||
vadWay: Int = RecordParam.VAD_WAY_DEVICE, |
|||
onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, |
|||
) { |
|||
val device = address?.let { connectFeature.deviceByAddress(it) } |
|||
?: connectFeature.connectedDevice() |
|||
?: return onResult(false, "no connected device") |
|||
val param = RecordParam(voiceType, sampleRate, vadWay) |
|||
recordOp.startRecord(device, param, object : OnRcspActionCallback<Boolean> { |
|||
override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } |
|||
override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } |
|||
}) |
|||
} |
|||
|
|||
fun stop( |
|||
address: String?, |
|||
reason: Int = RecordState.REASON_NORMAL, |
|||
onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, |
|||
) { |
|||
val device = address?.let { connectFeature.deviceByAddress(it) } |
|||
?: connectFeature.connectedDevice() |
|||
?: return onResult(false, "no connected device") |
|||
recordOp.stopRecord(device, reason, object : OnRcspActionCallback<Boolean> { |
|||
override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } |
|||
override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } |
|||
}) |
|||
} |
|||
|
|||
// ───── 内部回调处理 ───── |
|||
|
|||
private fun onStart(device: BluetoothDevice, state: RecordState) { |
|||
currentParam = state.recordParam |
|||
ensureDecoder(state.recordParam) |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "speechStart", |
|||
"address" to device.address, |
|||
"voiceType" to state.recordParam?.voiceType, |
|||
"sampleRate" to (state.recordParam?.sampleRate ?: 16) * 1000, // 8/16 → 8000/16000 |
|||
"vadWay" to state.recordParam?.vadWay, |
|||
"tsMs" to System.currentTimeMillis(), |
|||
) |
|||
) |
|||
} |
|||
|
|||
private fun onWorking(device: BluetoothDevice, state: RecordState) { |
|||
val payload = state.voiceDataBlock ?: state.voiceData ?: return |
|||
val type = (state.recordParam ?: currentParam)?.voiceType ?: RecordParam.VOICE_TYPE_OPUS |
|||
when (type) { |
|||
RecordParam.VOICE_TYPE_OPUS -> decoder?.feedEncoded(payload) ?: emitFrame(device, payload) |
|||
RecordParam.VOICE_TYPE_PCM -> emitFrame(device, payload) |
|||
RecordParam.VOICE_TYPE_SPEEX -> { |
|||
// 当前未挂 speex 解码器;原样透出,由宿主自己解(极少使用) |
|||
emitFrame(device, payload, encoding = "speex") |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun onEnd(device: BluetoothDevice, state: RecordState) { |
|||
releaseDecoder() |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "speechEnd", |
|||
"address" to device.address, |
|||
"reason" to state.reason, |
|||
"message" to state.message, |
|||
"tsMs" to System.currentTimeMillis(), |
|||
) |
|||
) |
|||
currentParam = null |
|||
} |
|||
|
|||
private fun ensureDecoder(param: RecordParam?) { |
|||
if (decoder != null) return |
|||
if (param?.voiceType != RecordParam.VOICE_TYPE_OPUS) return |
|||
val srKhz = param.sampleRate.takeIf { it > 0 } ?: RecordParam.SAMPLE_RATE_16K |
|||
val sampleRateHz = srKhz * 1000 |
|||
val packetSize = if (srKhz == RecordParam.SAMPLE_RATE_8K) 50 else 200 |
|||
decoder = OpusStreamDecoder( |
|||
channel = 1, |
|||
packetSize = packetSize, |
|||
sampleRate = sampleRateHz, |
|||
onPcm = { pcm -> emitFrame(connectFeature.connectedDevice(), pcm) }, |
|||
onError = { c, m -> |
|||
dispatcher.send( |
|||
mapOf("type" to "speechError", "code" to c, "message" to "decoder: $m") |
|||
) |
|||
} |
|||
).also { it.start() } |
|||
} |
|||
|
|||
private fun releaseDecoder() { |
|||
runCatching { decoder?.stop() } |
|||
decoder = null |
|||
} |
|||
|
|||
private fun emitFrame( |
|||
device: BluetoothDevice?, |
|||
pcm: ByteArray, |
|||
encoding: String = "pcm16", |
|||
) { |
|||
val sr = (currentParam?.sampleRate ?: RecordParam.SAMPLE_RATE_16K) * 1000 |
|||
dispatcher.send( |
|||
mapOf( |
|||
"type" to "speechAudio", |
|||
"address" to device?.address, |
|||
"encoding" to encoding, |
|||
"sampleRate" to sr, |
|||
"channels" to 1, |
|||
"bitsPerSample" to 16, |
|||
"tsMs" to System.currentTimeMillis(), |
|||
"pcm" to pcm, |
|||
) |
|||
) |
|||
} |
|||
} |
|||
@ -0,0 +1,314 @@ |
|||
package com.jielihome.jielihome.integration |
|||
|
|||
import android.content.Context |
|||
import android.util.Log |
|||
import com.aiagent.device_plugin_interface.DeviceCapability |
|||
import com.aiagent.device_plugin_interface.DeviceConnectOptions |
|||
import com.aiagent.device_plugin_interface.DeviceConnectionState |
|||
import com.aiagent.device_plugin_interface.DeviceErrorCode |
|||
import com.aiagent.device_plugin_interface.DeviceException |
|||
import com.aiagent.device_plugin_interface.DevicePluginConfig |
|||
import com.aiagent.device_plugin_interface.DevicePluginEvent |
|||
import com.aiagent.device_plugin_interface.DevicePluginEventType |
|||
import com.aiagent.device_plugin_interface.DeviceScanFilter |
|||
import com.aiagent.device_plugin_interface.DiscoveredDevice |
|||
import com.aiagent.device_plugin_interface.NativeDevicePlugin |
|||
import com.aiagent.device_plugin_interface.NativeDevicePluginRegistry |
|||
import com.aiagent.device_plugin_interface.NativeDeviceSession |
|||
import com.jielihome.jielihome.api.JieliEventAdapter |
|||
import com.jielihome.jielihome.api.JieliEventListener |
|||
import com.jielihome.jielihome.core.JieliHomeServer |
|||
import kotlinx.coroutines.channels.BufferOverflow |
|||
import kotlinx.coroutines.flow.Flow |
|||
import kotlinx.coroutines.flow.MutableSharedFlow |
|||
import kotlinx.coroutines.flow.asSharedFlow |
|||
import java.util.concurrent.CompletableFuture |
|||
import java.util.concurrent.TimeUnit |
|||
|
|||
/** |
|||
* 杰理 [NativeDevicePlugin] 实现 —— 把 [JieliHomeServer] 包成 vendor-agnostic 接口, |
|||
* 由 device_manager native ([com.aiagent.device_manager.DefaultNativeDeviceManager]) |
|||
* 通过 [NativeDevicePluginRegistry] 创建并管理。 |
|||
* |
|||
* 注册入口:[register],由 `JielihomePlugin.onAttachedToEngine` 调用一次。 |
|||
* |
|||
* 注意:本类**不**关闭 [JieliHomeServer] —— server 是进程级单例,可能被 Dart 直连 |
|||
* 路径 / translate_server / OTA 等并发使用,本类的 [dispose] 只解订阅事件 + 切断 |
|||
* 当前 session,server 自身保留。 |
|||
*/ |
|||
class JieliNativeDevicePlugin( |
|||
private val context: Context, |
|||
) : NativeDevicePlugin { |
|||
|
|||
companion object { |
|||
private const val TAG = "JieliNativeDevicePlugin" |
|||
const val VENDOR_KEY = "jieli" |
|||
const val DISPLAY_NAME = "JieLi (杰理)" |
|||
|
|||
val CAPABILITIES: Set<DeviceCapability> = setOf( |
|||
DeviceCapability.SCAN, |
|||
DeviceCapability.CONNECT, |
|||
DeviceCapability.BOND, |
|||
DeviceCapability.BATTERY, |
|||
DeviceCapability.CUSTOM_COMMAND, |
|||
DeviceCapability.WAKE_WORD, |
|||
DeviceCapability.OTA, |
|||
DeviceCapability.ON_DEVICE_CALL_TRANSLATION, |
|||
DeviceCapability.ON_DEVICE_FACE_TO_FACE_TRANSLATION, |
|||
DeviceCapability.ON_DEVICE_RECORDING_TRANSLATION, |
|||
) |
|||
|
|||
/** |
|||
* 把杰理工厂注册到 [NativeDevicePluginRegistry],由 [JielihomePlugin] |
|||
* 在 onAttachedToEngine 调一次(FlutterPlugin 生命周期保证它早于 device_manager |
|||
* 的 listVendors / useVendor 调用)。 |
|||
*/ |
|||
fun register(appContext: Context) { |
|||
NativeDevicePluginRegistry.register( |
|||
vendorKey = VENDOR_KEY, |
|||
displayName = DISPLAY_NAME, |
|||
capabilities = CAPABILITIES, |
|||
) { JieliNativeDevicePlugin(appContext) } |
|||
} |
|||
} |
|||
|
|||
override val vendorKey: String = VENDOR_KEY |
|||
override val displayName: String = DISPLAY_NAME |
|||
override val capabilities: Set<DeviceCapability> = CAPABILITIES |
|||
|
|||
private val server: JieliHomeServer = JieliHomeServer.get() |
|||
|
|||
@Volatile private var _initialized = false |
|||
@Volatile private var _disposed = false |
|||
|
|||
@Volatile private var _activeSession: JieliNativeDeviceSession? = null |
|||
@Volatile private var _pendingConnect: CompletableFuture<NativeDeviceSession>? = null |
|||
@Volatile private var _pendingConnectAddress: String? = null |
|||
|
|||
private val _events = MutableSharedFlow<DevicePluginEvent>( |
|||
replay = 0, |
|||
extraBufferCapacity = 64, |
|||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|||
) |
|||
|
|||
override val eventStream: Flow<DevicePluginEvent> = _events.asSharedFlow() |
|||
|
|||
private val listener: JieliEventListener = object : JieliEventAdapter() { |
|||
override fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) { |
|||
emit(DevicePluginEvent( |
|||
type = DevicePluginEventType.BLUETOOTH_STATE_CHANGED, |
|||
bluetoothEnabled = enabled, |
|||
)) |
|||
} |
|||
|
|||
override fun onScanStatus(ble: Boolean, started: Boolean) { |
|||
emit(DevicePluginEvent( |
|||
type = if (started) DevicePluginEventType.SCAN_STARTED |
|||
else DevicePluginEventType.SCAN_STOPPED, |
|||
)) |
|||
} |
|||
|
|||
override fun onDeviceFound(payload: Map<String, Any?>) { |
|||
emit(DevicePluginEvent( |
|||
type = DevicePluginEventType.DEVICE_DISCOVERED, |
|||
discovered = DiscoveredDevice( |
|||
id = (payload["address"] as? String) ?: "", |
|||
name = (payload["name"] as? String) ?: "", |
|||
rssi = (payload["rssi"] as? Number)?.toInt(), |
|||
vendor = VENDOR_KEY, |
|||
metadata = mapOf( |
|||
"edrAddr" to payload["edrAddr"], |
|||
"deviceType" to payload["deviceType"], |
|||
"connectWay" to payload["connectWay"], |
|||
), |
|||
), |
|||
)) |
|||
} |
|||
|
|||
override fun onConnectionState(address: String, state: Int) { |
|||
val session = _activeSession ?: return |
|||
if (session.deviceId != address) return |
|||
when (state) { |
|||
2 -> session.setState(DeviceConnectionState.CONNECTING) |
|||
1 -> session.setState(DeviceConnectionState.LINK_CONNECTED) |
|||
0 -> { |
|||
session.setState(DeviceConnectionState.DISCONNECTED) |
|||
_activeSession = null |
|||
val pending = _pendingConnect |
|||
if (pending != null && !pending.isDone) { |
|||
pending.completeExceptionally( |
|||
DeviceException( |
|||
DeviceErrorCode.DISCONNECTED_REMOTE, |
|||
"disconnected before ready", |
|||
) |
|||
) |
|||
_pendingConnect = null |
|||
_pendingConnectAddress = null |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onRcspInit(address: String, code: Int) { |
|||
val session = _activeSession ?: return |
|||
if (session.deviceId != address) return |
|||
if (code == 0) { |
|||
session.setState(DeviceConnectionState.READY) |
|||
val pending = _pendingConnect |
|||
if (pending != null && _pendingConnectAddress == address && !pending.isDone) { |
|||
pending.complete(session) |
|||
_pendingConnect = null |
|||
_pendingConnectAddress = null |
|||
} |
|||
} else { |
|||
val pending = _pendingConnect |
|||
if (pending != null && _pendingConnectAddress == address && !pending.isDone) { |
|||
pending.completeExceptionally( |
|||
DeviceException( |
|||
DeviceErrorCode.HANDSHAKE_FAILED, |
|||
"RCSP init failed code=$code", |
|||
) |
|||
) |
|||
_pendingConnect = null |
|||
_pendingConnectAddress = null |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onBattery(address: String?, level: Int?) { |
|||
val session = _activeSession ?: return |
|||
if (address != null && session.deviceId != address) return |
|||
// 由 session 自己 refresh info 拉一次最新快照 |
|||
runCatching { session.refreshInfo() } |
|||
} |
|||
|
|||
override fun onTranslationAudio(payload: Map<String, Any?>) { |
|||
_activeSession?.emitFeature("jieli.translation.audio", payload) |
|||
} |
|||
|
|||
override fun onTranslationResult(payload: Map<String, Any?>) { |
|||
_activeSession?.emitFeature("jieli.translation.result", payload) |
|||
} |
|||
|
|||
override fun onTranslationLog(payload: Map<String, Any?>) { |
|||
_activeSession?.emitFeature("jieli.translation.log", payload) |
|||
} |
|||
|
|||
override fun onTranslationError(payload: Map<String, Any?>) { |
|||
_activeSession?.emitError( |
|||
"device.feature_failed", |
|||
"translation: ${payload["code"]} ${payload["message"] ?: ""}", |
|||
) |
|||
} |
|||
} |
|||
|
|||
// ─── NativeDevicePlugin 接口 ────────────────────────────────────────── |
|||
|
|||
override fun initialize(config: DevicePluginConfig) { |
|||
checkAlive() |
|||
if (_initialized) return |
|||
if (!server.initialized) { |
|||
server.initialize( |
|||
context = context, |
|||
multiDevice = (config.extra["multiDevice"] as? Boolean) ?: false, |
|||
skipNoNameDev = (config.extra["skipNoNameDev"] as? Boolean) ?: false, |
|||
enableLog = (config.extra["enableLog"] as? Boolean) ?: false, |
|||
) |
|||
} |
|||
server.addEventListener(listener) |
|||
_initialized = true |
|||
emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_READY)) |
|||
} |
|||
|
|||
override fun startScan(filter: DeviceScanFilter?, timeoutMs: Long?) { |
|||
requireInit() |
|||
val ms = (timeoutMs ?: 30_000L).toInt() |
|||
server.scanFeature.startScan(ms).onFailure { |
|||
throw DeviceException("device.scan_failed", it.message, it) |
|||
} |
|||
} |
|||
|
|||
override fun stopScan() { |
|||
if (!_initialized) return |
|||
server.scanFeature.stopScan() |
|||
} |
|||
|
|||
override fun isScanning(): Boolean = |
|||
_initialized && server.scanFeature.isScanning() |
|||
|
|||
override fun bondedDevices(): List<DiscoveredDevice> { |
|||
// 杰理 SDK 没暴露公开的"已配对"接口;不混入系统经典蓝牙列表。 |
|||
return emptyList() |
|||
} |
|||
|
|||
override fun connect(deviceId: String, options: DeviceConnectOptions?): NativeDeviceSession { |
|||
requireInit() |
|||
// 单设备:有旧 session 时先断 |
|||
_activeSession?.let { if (it.deviceId != deviceId) runCatching { it.disconnect() } } |
|||
|
|||
val extra = options?.extra ?: emptyMap() |
|||
val session = JieliNativeDeviceSession( |
|||
server = server, |
|||
deviceId = deviceId, |
|||
initialName = (extra["name"] as? String) ?: "", |
|||
capabilities = capabilities, |
|||
) |
|||
_activeSession = session |
|||
|
|||
val future = CompletableFuture<NativeDeviceSession>() |
|||
_pendingConnect = future |
|||
_pendingConnectAddress = deviceId |
|||
|
|||
server.connectFeature.connect( |
|||
bleAddress = deviceId, |
|||
edrAddress = extra["edrAddr"] as? String, |
|||
deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0, |
|||
connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0, |
|||
).onFailure { |
|||
future.completeExceptionally( |
|||
DeviceException(DeviceErrorCode.CONNECT_FAILED, it.message, it) |
|||
) |
|||
_pendingConnect = null |
|||
_pendingConnectAddress = null |
|||
} |
|||
|
|||
val timeout = (options?.timeoutMs ?: 15_000L) |
|||
return try { |
|||
future.get(timeout, TimeUnit.MILLISECONDS) |
|||
} catch (e: java.util.concurrent.TimeoutException) { |
|||
_pendingConnect = null |
|||
_pendingConnectAddress = null |
|||
throw DeviceException(DeviceErrorCode.CONNECT_TIMEOUT, |
|||
"connect timeout after ${timeout}ms") |
|||
} catch (e: java.util.concurrent.ExecutionException) { |
|||
throw e.cause ?: e |
|||
} |
|||
} |
|||
|
|||
override val activeSession: NativeDeviceSession? |
|||
get() = _activeSession?.takeIf { it.state != DeviceConnectionState.DISCONNECTED } |
|||
|
|||
override fun dispose() { |
|||
if (_disposed) return |
|||
_disposed = true |
|||
runCatching { _activeSession?.disconnect() } |
|||
runCatching { server.removeEventListener(listener) } |
|||
_activeSession = null |
|||
emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_DISPOSED)) |
|||
} |
|||
|
|||
// ─── 内部 ────────────────────────────────────────────────────────────── |
|||
|
|||
private fun checkAlive() { |
|||
if (_disposed) throw IllegalStateException("JieliNativeDevicePlugin disposed") |
|||
} |
|||
|
|||
private fun requireInit() { |
|||
checkAlive() |
|||
if (!_initialized) throw DeviceException(DeviceErrorCode.PLUGIN_NOT_INITIALIZED) |
|||
} |
|||
|
|||
private fun emit(e: DevicePluginEvent) { |
|||
if (!_events.tryEmit(e)) Log.w(TAG, "event buffer full; dropped ${e.type}") |
|||
} |
|||
} |
|||
@ -0,0 +1,266 @@ |
|||
package com.jielihome.jielihome.integration |
|||
|
|||
import android.util.Log |
|||
import com.aiagent.device_plugin_interface.DeviceCallTranslationPort |
|||
import com.aiagent.device_plugin_interface.DeviceCapability |
|||
import com.aiagent.device_plugin_interface.DeviceConnectionState |
|||
import com.aiagent.device_plugin_interface.DeviceErrorCode |
|||
import com.aiagent.device_plugin_interface.DeviceException |
|||
import com.aiagent.device_plugin_interface.DeviceFeatureEvent |
|||
import com.aiagent.device_plugin_interface.DeviceInfo |
|||
import com.aiagent.device_plugin_interface.DeviceSessionEvent |
|||
import com.aiagent.device_plugin_interface.DeviceSessionEventType |
|||
import com.aiagent.device_plugin_interface.NativeDeviceSession |
|||
import com.jielihome.jielihome.core.JieliHomeServer |
|||
import com.jielihome.jielihome.feature.translation.TranslationStreams |
|||
import kotlinx.coroutines.channels.BufferOverflow |
|||
import kotlinx.coroutines.flow.Flow |
|||
import kotlinx.coroutines.flow.MutableSharedFlow |
|||
import kotlinx.coroutines.flow.asSharedFlow |
|||
|
|||
/** |
|||
* 杰理 [NativeDeviceSession] 实现 —— 包装 [JieliHomeServer] 的连接快照与事件。 |
|||
* |
|||
* 状态机由 [JieliNativeDevicePlugin] 在 RCSP 事件回调里推动: |
|||
* - ConnectionStateEvent.state == CONNECTING(2) → connecting |
|||
* - state == OK(1) + RcspInit 未到 → linkConnected |
|||
* - RcspInit(success=true) → ready |
|||
* - state == DISCONNECT(0) → disconnected |
|||
*/ |
|||
class JieliNativeDeviceSession internal constructor( |
|||
private val server: JieliHomeServer, |
|||
override val deviceId: String, |
|||
initialName: String, |
|||
override val capabilities: Set<DeviceCapability>, |
|||
) : NativeDeviceSession { |
|||
|
|||
companion object { |
|||
private const val TAG = "JieliNativeSession" |
|||
} |
|||
|
|||
override val vendor: String = "jieli" |
|||
|
|||
@Volatile private var _state = DeviceConnectionState.CONNECTING |
|||
@Volatile private var _info = DeviceInfo( |
|||
id = deviceId, name = initialName, vendor = vendor, |
|||
) |
|||
@Volatile private var _disposed = false |
|||
|
|||
override val state: DeviceConnectionState get() = _state |
|||
override val info: DeviceInfo get() = _info |
|||
|
|||
private val _events = MutableSharedFlow<DeviceSessionEvent>( |
|||
replay = 0, |
|||
extraBufferCapacity = 32, |
|||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|||
) |
|||
|
|||
override val eventStream: Flow<DeviceSessionEvent> = _events.asSharedFlow() |
|||
|
|||
// ─── 由 plugin 在事件回调中调用 ───────────────────────────────────────── |
|||
|
|||
internal fun setState(s: DeviceConnectionState) { |
|||
if (_state == s) return |
|||
_state = s |
|||
emit(DeviceSessionEvent( |
|||
type = DeviceSessionEventType.CONNECTION_STATE_CHANGED, |
|||
deviceId = deviceId, |
|||
connectionState = s, |
|||
)) |
|||
if (s == DeviceConnectionState.DISCONNECTED) { |
|||
_disposed = true |
|||
} |
|||
} |
|||
|
|||
internal fun emitFeature(key: String, data: Map<String, Any?>) { |
|||
emit(DeviceSessionEvent( |
|||
type = DeviceSessionEventType.FEATURE, |
|||
deviceId = deviceId, |
|||
feature = DeviceFeatureEvent(key = key, data = data), |
|||
)) |
|||
} |
|||
|
|||
internal fun emitError(code: String, message: String?) { |
|||
emit(DeviceSessionEvent( |
|||
type = DeviceSessionEventType.ERROR, |
|||
deviceId = deviceId, |
|||
errorCode = code, |
|||
errorMessage = message, |
|||
)) |
|||
} |
|||
|
|||
private fun emit(e: DeviceSessionEvent) { |
|||
if (!_events.tryEmit(e)) { |
|||
Log.w(TAG, "session event buffer full; dropped ${e.type}") |
|||
} |
|||
} |
|||
|
|||
private fun applySnapshot(snapshot: Map<String, Any?>) { |
|||
_info = _info.copyWith( |
|||
name = (snapshot["name"] as? String).takeIf { !it.isNullOrEmpty() } |
|||
?: _info.name, |
|||
firmwareVersion = (snapshot["versionName"] as? String) |
|||
?: _info.firmwareVersion, |
|||
batteryPercent = (snapshot["battery"] as? Number)?.toInt(), |
|||
metadata = snapshot, |
|||
) |
|||
emit(DeviceSessionEvent( |
|||
type = DeviceSessionEventType.DEVICE_INFO_UPDATED, |
|||
deviceId = deviceId, |
|||
deviceInfo = _info, |
|||
)) |
|||
} |
|||
|
|||
// ─── NativeDeviceSession 接口 ────────────────────────────────────────── |
|||
|
|||
override fun readRssi(): Int { |
|||
requireReady() |
|||
throw DeviceException( |
|||
DeviceErrorCode.NOT_SUPPORTED, |
|||
"jieli native bridge does not expose RSSI yet", |
|||
) |
|||
} |
|||
|
|||
override fun readBattery(): Int? { |
|||
requireReady() |
|||
return server.deviceInfoFeature.snapshot(deviceId) |
|||
?.get("battery") as? Int |
|||
} |
|||
|
|||
override fun refreshInfo(): DeviceInfo { |
|||
requireReady() |
|||
val snap = server.deviceInfoFeature.snapshot(deviceId) ?: return _info |
|||
applySnapshot(snap) |
|||
return _info |
|||
} |
|||
|
|||
override fun invokeFeature( |
|||
featureKey: String, |
|||
args: Map<String, Any?>, |
|||
): Map<String, Any?> { |
|||
requireReady() |
|||
return when (featureKey) { |
|||
"jieli.translation.support" -> mapOf( |
|||
"supportCallStereo" to server.translationFeature |
|||
.isSupportCallTranslationWithStereo(deviceId), |
|||
) |
|||
|
|||
"jieli.translation.start" -> { |
|||
val modeId = (args["modeId"] as? Number)?.toInt() |
|||
?: throw DeviceException( |
|||
DeviceErrorCode.INVALID_ARGUMENT, |
|||
"modeId(int) required", |
|||
) |
|||
@Suppress("UNCHECKED_CAST") |
|||
val extra = (args["args"] as? Map<String, Any?>) ?: emptyMap() |
|||
server.translationFeature.start(modeId, extra).getOrElse { |
|||
throw DeviceException( |
|||
"device.feature_failed", |
|||
"translation.start failed: ${it.message}", |
|||
it, |
|||
) |
|||
} |
|||
mapOf("ok" to true) |
|||
} |
|||
|
|||
"jieli.translation.stop" -> { |
|||
server.translationFeature.stop() |
|||
mapOf("ok" to true) |
|||
} |
|||
|
|||
"jieli.translation.feedTranslatedAudio" -> { |
|||
val pcm = args["pcm"] as? ByteArray |
|||
?: (args["pcm"] as? List<*>) |
|||
?.let { it.map { v -> (v as Number).toByte() }.toByteArray() } |
|||
?: throw DeviceException( |
|||
DeviceErrorCode.INVALID_ARGUMENT, |
|||
"pcm(ByteArray|List<int>) required", |
|||
) |
|||
val streamId = args["streamId"] as? String |
|||
?: throw DeviceException( |
|||
DeviceErrorCode.INVALID_ARGUMENT, |
|||
"streamId required (e.g. ${TranslationStreams.OUT_UPLINK})", |
|||
) |
|||
val ok = server.translationFeature.feedTranslatedAudio( |
|||
outputStreamId = streamId, |
|||
pcm = pcm, |
|||
format = com.jielihome.jielihome.feature.translation.AudioFormat( |
|||
sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000, |
|||
channels = (args["channels"] as? Number)?.toInt() ?: 1, |
|||
bitsPerSample = (args["bitsPerSample"] as? Number)?.toInt() ?: 16, |
|||
), |
|||
isFinal = (args["final"] as? Boolean) == true, |
|||
) |
|||
mapOf("ok" to ok) |
|||
} |
|||
|
|||
"jieli.translation.feedTranslationResult" -> { |
|||
server.translationFeature.feedTranslationResult( |
|||
srcLang = args["srcLang"] as? String, |
|||
srcText = args["srcText"] as? String, |
|||
destLang = args["destLang"] as? String, |
|||
destText = args["destText"] as? String, |
|||
requestId = args["requestId"] as? String, |
|||
) |
|||
mapOf("ok" to true) |
|||
} |
|||
|
|||
"jieli.cmd.send" -> { |
|||
val opCode = (args["opCode"] as? Number)?.toInt() |
|||
?: throw DeviceException( |
|||
DeviceErrorCode.INVALID_ARGUMENT, |
|||
"opCode required", |
|||
) |
|||
@Suppress("UNCHECKED_CAST") |
|||
val payload = (args["payload"] as? List<Number>) |
|||
?.map { it.toByte() }?.toByteArray() |
|||
?: byteArrayOf() |
|||
// 同步等待(5s 超时)—— RCSP 自定义命令通常 < 500ms 返回。 |
|||
val deferred = java.util.concurrent.CompletableFuture<ByteArray?>() |
|||
server.customCmdFeature.send( |
|||
address = deviceId, |
|||
opCode = opCode, |
|||
payload = payload, |
|||
onSuccess = { data -> deferred.complete(data) }, |
|||
onError = { code, msg -> |
|||
deferred.completeExceptionally( |
|||
DeviceException("device.feature_failed", |
|||
"cmd $opCode failed: code=$code msg=${msg ?: ""}") |
|||
) |
|||
}, |
|||
) |
|||
val resp = try { |
|||
deferred.get(5, java.util.concurrent.TimeUnit.SECONDS) |
|||
} catch (e: java.util.concurrent.TimeoutException) { |
|||
throw DeviceException( |
|||
"device.feature_failed", |
|||
"cmd $opCode timeout", |
|||
) |
|||
} catch (e: java.util.concurrent.ExecutionException) { |
|||
throw e.cause ?: e |
|||
} |
|||
mapOf("response" to resp) |
|||
} |
|||
|
|||
else -> throw DeviceException( |
|||
DeviceErrorCode.NOT_SUPPORTED, |
|||
"unknown feature \"$featureKey\"", |
|||
) |
|||
} |
|||
} |
|||
|
|||
override fun callTranslationPort(): DeviceCallTranslationPort = server.callTranslationPort |
|||
|
|||
override fun disconnect() { |
|||
if (_disposed) return |
|||
setState(DeviceConnectionState.DISCONNECTING) |
|||
runCatching { server.connectFeature.disconnect(deviceId) } |
|||
// 真正的 disconnected 状态由 ConnectionStateEvent 推过来 |
|||
} |
|||
|
|||
private fun requireReady() { |
|||
if (_disposed || _state != DeviceConnectionState.READY) { |
|||
throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
package com.jielihome.jielihome |
|||
|
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import kotlin.test.Test |
|||
import org.mockito.Mockito |
|||
|
|||
/* |
|||
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. |
|||
* |
|||
* Once you have built the plugin's example app, you can run these tests from the command |
|||
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or |
|||
* you can run them directly from IDEs that support JUnit such as Android Studio. |
|||
*/ |
|||
|
|||
internal class JielihomePluginTest { |
|||
@Test |
|||
fun onMethodCall_getPlatformVersion_returnsExpectedValue() { |
|||
val plugin = JielihomePlugin() |
|||
|
|||
val call = MethodCall("getPlatformVersion", null) |
|||
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) |
|||
plugin.onMethodCall(call, mockResult) |
|||
|
|||
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
# Miscellaneous |
|||
*.class |
|||
*.log |
|||
*.pyc |
|||
*.swp |
|||
.DS_Store |
|||
.atom/ |
|||
.build/ |
|||
.buildlog/ |
|||
.history |
|||
.svn/ |
|||
.swiftpm/ |
|||
migrate_working_dir/ |
|||
|
|||
# IntelliJ related |
|||
*.iml |
|||
*.ipr |
|||
*.iws |
|||
.idea/ |
|||
|
|||
# The .vscode folder contains launch configuration and tasks you configure in |
|||
# VS Code which you may wish to be included in version control, so this line |
|||
# is commented out by default. |
|||
#.vscode/ |
|||
|
|||
# Flutter/Dart/Pub related |
|||
**/doc/api/ |
|||
**/ios/Flutter/.last_build_id |
|||
.dart_tool/ |
|||
.flutter-plugins |
|||
.flutter-plugins-dependencies |
|||
.pub-cache/ |
|||
.pub/ |
|||
/build/ |
|||
|
|||
# Symbolication related |
|||
app.*.symbols |
|||
|
|||
# Obfuscation related |
|||
app.*.map.json |
|||
|
|||
# Android Studio will place build artifacts here |
|||
/android/app/debug |
|||
/android/app/profile |
|||
/android/app/release |
|||
@ -0,0 +1,16 @@ |
|||
# jielihome_example |
|||
|
|||
Demonstrates how to use the jielihome plugin. |
|||
|
|||
## Getting Started |
|||
|
|||
This project is a starting point for a Flutter application. |
|||
|
|||
A few resources to get you started if this is your first Flutter project: |
|||
|
|||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) |
|||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) |
|||
|
|||
For help getting started with Flutter development, view the |
|||
[online documentation](https://docs.flutter.dev/), which offers tutorials, |
|||
samples, guidance on mobile development, and a full API reference. |
|||
@ -0,0 +1,28 @@ |
|||
# This file configures the analyzer, which statically analyzes Dart code to |
|||
# check for errors, warnings, and lints. |
|||
# |
|||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled |
|||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be |
|||
# invoked from the command line by running `flutter analyze`. |
|||
|
|||
# The following line activates a set of recommended lints for Flutter apps, |
|||
# packages, and plugins designed to encourage good coding practices. |
|||
include: package:flutter_lints/flutter.yaml |
|||
|
|||
linter: |
|||
# The lint rules applied to this project can be customized in the |
|||
# section below to disable rules from the `package:flutter_lints/flutter.yaml` |
|||
# included above or to enable additional rules. A list of all available lints |
|||
# and their documentation is published at https://dart.dev/lints. |
|||
# |
|||
# Instead of disabling a lint rule for the entire project in the |
|||
# section below, it can also be suppressed for a single line of code |
|||
# or a specific dart file by using the `// ignore: name_of_lint` and |
|||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file |
|||
# producing the lint. |
|||
rules: |
|||
# avoid_print: false # Uncomment to disable the `avoid_print` rule |
|||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule |
|||
|
|||
# Additional information about this file can be found at |
|||
# https://dart.dev/guides/language/analysis-options |
|||
@ -0,0 +1,14 @@ |
|||
gradle-wrapper.jar |
|||
/.gradle |
|||
/captures/ |
|||
/gradlew |
|||
/gradlew.bat |
|||
/local.properties |
|||
GeneratedPluginRegistrant.java |
|||
.cxx/ |
|||
|
|||
# Remember to never publicly share your keystore. |
|||
# See https://flutter.dev/to/reference-keystore |
|||
key.properties |
|||
**/*.keystore |
|||
**/*.jks |
|||
@ -0,0 +1,45 @@ |
|||
plugins { |
|||
id("com.android.application") |
|||
id("kotlin-android") |
|||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. |
|||
id("dev.flutter.flutter-gradle-plugin") |
|||
} |
|||
|
|||
android { |
|||
namespace = "com.jielihome.jielihome_example" |
|||
compileSdk = 36 |
|||
ndkVersion = "27.0.12077973" |
|||
|
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_17 |
|||
targetCompatibility = JavaVersion.VERSION_17 |
|||
} |
|||
|
|||
kotlinOptions { |
|||
jvmTarget = JavaVersion.VERSION_17.toString() |
|||
} |
|||
|
|||
defaultConfig { |
|||
applicationId = "com.jielihome.jielihome_example" |
|||
minSdk = 21 |
|||
targetSdk = flutter.targetSdkVersion |
|||
versionCode = flutter.versionCode |
|||
versionName = flutter.versionName |
|||
} |
|||
|
|||
buildTypes { |
|||
release { |
|||
// TODO: Add your own signing config for the release build. |
|||
// Signing with the debug keys for now, so `flutter run --release` works. |
|||
signingConfig = signingConfigs.getByName("debug") |
|||
} |
|||
} |
|||
} |
|||
|
|||
flutter { |
|||
source = "../.." |
|||
} |
|||
|
|||
dependencies { |
|||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) |
|||
} |
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,7 @@ |
|||
<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> |
|||
@ -0,0 +1,55 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<uses-permission android:name="android.permission.INTERNET" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> |
|||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
|||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
|||
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
|||
<application |
|||
android:label="jielihome_example" |
|||
android:name="${applicationName}" |
|||
android:icon="@mipmap/ic_launcher"> |
|||
<activity |
|||
android:name=".MainActivity" |
|||
android:exported="true" |
|||
android:launchMode="singleTop" |
|||
android:taskAffinity="" |
|||
android:theme="@style/LaunchTheme" |
|||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" |
|||
android:hardwareAccelerated="true" |
|||
android:windowSoftInputMode="adjustResize"> |
|||
<!-- Specifies an Android theme to apply to this Activity as soon as |
|||
the Android process has started. This theme is visible to the user |
|||
while the Flutter UI initializes. After that, this theme continues |
|||
to determine the Window background behind the Flutter UI. --> |
|||
<meta-data |
|||
android:name="io.flutter.embedding.android.NormalTheme" |
|||
android:resource="@style/NormalTheme" |
|||
/> |
|||
<intent-filter> |
|||
<action android:name="android.intent.action.MAIN"/> |
|||
<category android:name="android.intent.category.LAUNCHER"/> |
|||
</intent-filter> |
|||
</activity> |
|||
<!-- Don't delete the meta-data below. |
|||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> |
|||
<meta-data |
|||
android:name="flutterEmbedding" |
|||
android:value="2" /> |
|||
</application> |
|||
<!-- Required to query activities that can process text, see: |
|||
https://developer.android.com/training/package-visibility and |
|||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. |
|||
|
|||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> |
|||
<queries> |
|||
<intent> |
|||
<action android:name="android.intent.action.PROCESS_TEXT"/> |
|||
<data android:mimeType="text/plain"/> |
|||
</intent> |
|||
</queries> |
|||
</manifest> |
|||
@ -0,0 +1,5 @@ |
|||
package com.jielihome.jielihome_example |
|||
|
|||
import io.flutter.embedding.android.FlutterActivity |
|||
|
|||
class MainActivity : FlutterActivity() |
|||
@ -0,0 +1,12 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<!-- Modify this file to customize your launch splash screen --> |
|||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<item android:drawable="?android:colorBackground" /> |
|||
|
|||
<!-- You can insert your own image assets here --> |
|||
<!-- <item> |
|||
<bitmap |
|||
android:gravity="center" |
|||
android:src="@mipmap/launch_image" /> |
|||
</item> --> |
|||
</layer-list> |
|||
@ -0,0 +1,12 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<!-- Modify this file to customize your launch splash screen --> |
|||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<item android:drawable="@android:color/white" /> |
|||
|
|||
<!-- You can insert your own image assets here --> |
|||
<!-- <item> |
|||
<bitmap |
|||
android:gravity="center" |
|||
android:src="@mipmap/launch_image" /> |
|||
</item> --> |
|||
</layer-list> |
|||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,18 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<resources> |
|||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> |
|||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> |
|||
<!-- Show a splash screen on the activity. Automatically removed when |
|||
the Flutter engine draws its first frame --> |
|||
<item name="android:windowBackground">@drawable/launch_background</item> |
|||
</style> |
|||
<!-- Theme applied to the Android Window as soon as the process has started. |
|||
This theme determines the color of the Android Window while your |
|||
Flutter UI initializes, as well as behind your Flutter UI while its |
|||
running. |
|||
|
|||
This Theme is only used starting with V2 of Flutter's Android embedding. --> |
|||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> |
|||
<item name="android:windowBackground">?android:colorBackground</item> |
|||
</style> |
|||
</resources> |
|||
@ -0,0 +1,18 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<resources> |
|||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> |
|||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> |
|||
<!-- Show a splash screen on the activity. Automatically removed when |
|||
the Flutter engine draws its first frame --> |
|||
<item name="android:windowBackground">@drawable/launch_background</item> |
|||
</style> |
|||
<!-- Theme applied to the Android Window as soon as the process has started. |
|||
This theme determines the color of the Android Window while your |
|||
Flutter UI initializes, as well as behind your Flutter UI while its |
|||
running. |
|||
|
|||
This Theme is only used starting with V2 of Flutter's Android embedding. --> |
|||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> |
|||
<item name="android:windowBackground">?android:colorBackground</item> |
|||
</style> |
|||
</resources> |
|||
@ -0,0 +1,7 @@ |
|||
<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> |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue