diff --git a/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart index d4bd5bf4e..5f2bc7e0e 100644 --- a/lib/data/services/ast_service.dart +++ b/lib/data/services/ast_service.dart @@ -54,7 +54,18 @@ class AstTtsFrame { /// 'A' = 己方→对端 译文(让对端听);'B' = 对端→己方 译文(让本机听) final String leg; final Uint8List pcm; - const AstTtsFrame({required this.leg, required this.pcm}); + + /// 是否为当前 utterance 的最后一帧。 + /// + /// 仅当原生 AST 服务(azure / iflytek / volcano / alibaba 任一)确实发出段尾事件时为 true。 + /// 段尾帧通常 pcm 为空字节,仅作信号传递,由下游(杰理 RCSP runtime)触发立即整段下发。 + final bool isFinal; + + const AstTtsFrame({ + required this.leg, + required this.pcm, + this.isFinal = false, + }); } // 识别事件类型 diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 8686c6094..a2991b6cf 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -220,7 +220,9 @@ class BleManager extends GetxService { int channels = 1, bool isFinal = false, }) async { - if (pcm.isEmpty) return true; + // 段尾帧(pcm 为空 + isFinal=true)必须放行,让 native 触发立即整段下发; + // 仅对"普通空帧(非段尾)"短路。 + if (pcm.isEmpty && !isFinal) return true; final upper = leg.toUpperCase(); final streamId = (upper == 'A' || upper == 'UPLINK') ? 'out.uplink' @@ -271,12 +273,10 @@ class BleManager extends GetxService { } else if (event is DeviceFoundEvent) { final found = event.device; final exists = scannedDevices.any((d) => d.address == found.address); - // 每次广播都用最新数据覆盖(电量/充电状态会变) + // 每次广播都用最新数据覆盖(后续若 SDK 扩展了广播字段,仍可从这里拿到) _latestAdvByAddress[found.address] = found; print('[BLE] [EVT] deviceFound name="${found.name}" addr=${found.address} ' - 'rssi=${found.rssi} L=${found.leftBattery}${found.leftCharging == true ? "+" : ""} ' - 'R=${found.rightBattery}${found.rightCharging == true ? "+" : ""} ' - 'Case=${found.caseBattery}${found.caseCharging == true ? "+" : ""} ' + 'rssi=${found.rssi} ' '(已存在=$exists, 当前列表 size=${scannedDevices.length})'); if (!exists) { scannedDevices.add(found); @@ -289,6 +289,15 @@ class BleManager extends GetxService { if (event.success) { // RCSP init 完成后 SDK 才开始往缓存里填电量;这里启动轮询直到拿到非 0 _startBatteryWatchdog(); + // 同时启动 AgentService(azureAsrHelper / TTS / chatApi 在这里建出来)。 + // native initialize 自带幂等保护,重连重复触发是 no-op; + // 老的 PairingController 删除时这一调用丢了,不补回来 AI 助理 / PTT + // 等所有 ASR 路径都会因为 azureAsrHelper=null 静默失败。 + startAgentService().then((ok) { + Logger.i('BleManager', 'startAgentService on RCSP init: ok=$ok'); + }).catchError((e) { + Logger.e('BleManager', 'startAgentService on RCSP init 失败: $e'); + }); } } else if (event is BatteryEvent) { Logger.i('BleManager', @@ -385,33 +394,20 @@ class BleManager extends GetxService { ); } - /// 用扫到的 advertisement 数据立刻填 DeviceInfo 的双端电量/充电状态。 - /// 在 connectionOk 时调用,无需等 RCSP 拉快照。 + /// 杰理同步后的 `JieliDevice` 仅暴露 name / address / rssi 等基础字段, + /// 不再提供左右耳/仓的电量与充电状态。电量改由以下两路拿: + /// 1. 连接成功后 RCSP 初始化完成 → `_startBatteryWatchdog` 轮询 + /// `fetchDeviceInfo` 拉 `deviceSnapshot` / `queryTargetInfo`; + /// 2. SDK 主动推送的 [BatteryEvent](见 `_handleBattery`)。 + /// 所以这里不再从扫描广播里预填电量,保留空实现以兼容既有调用点。 void _fillBatteryFromAdvertisement(String address) { final adv = _latestAdvByAddress[address]; if (adv == null) { Logger.w('BleManager', '[BAT] 没有 $address 的 advertisement 数据可用'); return; } - final cur = deviceInfo.value; - if (cur == null) return; - // 单耳设备:rightDeviceQuantity 通常是 0 → 把左耳的值镜像过去,避免 UI 显示 0% - final l = adv.leftBattery ?? 0; - final r = adv.rightBattery ?? 0; - final caseBat = adv.caseBattery ?? 0; - final fallbackR = (r == 0 && l > 0) ? l : r; - deviceInfo.value = cur.copyWith( - batteryLevel: l > 0 ? l : (r > 0 ? r : 0), - leftEarBattery: l, - rightEarBattery: fallbackR, - caseBattery: caseBat, - leftEarCharging: adv.leftCharging ?? false, - rightEarCharging: adv.rightCharging ?? false, - caseCharging: adv.caseCharging ?? false, - ); Logger.i('BleManager', - '[BAT] 来自广播: L=$l R=$fallbackR Case=$caseBat ' - '(charging L=${adv.leftCharging} R=${adv.rightCharging} Case=${adv.caseCharging})'); + '[BAT] 广播不再携带电量字段,连接后将通过 RCSP/BatteryEvent 补齐'); } void _handleTranslationAudio(TranslationAudioEvent e) { @@ -466,10 +462,25 @@ class BleManager extends GetxService { } // ─── 扫描 / 连接 ──────────────────────────────────── + /// 启动扫描。 + /// + /// 扫描的 service UUID 白名单来源: + /// 1) 调用方显式传入 [uuids](通常用于调试或定向扫描); + /// 2) 否则根据 [productIds] 从 [AppConfig.config.products] 映射出每个产品 + /// 的 `scanuuid`(服务端下发的产品配置),在 Flutter 侧拼接成 uuids 列表 + /// 再通过平台通道下发给底层,由 Android `ScanFilter.setServiceUuid` + /// 进行 OS 级过滤; + /// 3) 如果上面两项都没给,就默认使用 [AppConfig.allproductUuids()],等价 + /// 于「下发所有已配置产品」的 uuid 白名单。 + /// + /// 空列表 / AppConfig 未初始化时,底层不做 uuid 过滤,全部放行。 Future startScan({ Duration timeout = const Duration(seconds: 30), + List? uuids, + List? productIds, }) async { - print('[BLE] [SCAN] BleManager.startScan 入口'); + print('[BLE] [SCAN] BleManager.startScan 入口 ' + 'productIds=$productIds uuids=$uuids'); _lastScanError = null; try { await ensureInitialized(); @@ -479,11 +490,37 @@ class BleManager extends GetxService { isScanning.value = false; return false; } - print('[BLE] [SCAN] init 已通过,清空列表并发送 native startScan'); + + final List resolvedUuids; + if (uuids != null && uuids.isNotEmpty) { + resolvedUuids = + uuids.where((u) => u.isNotEmpty).toList(growable: false); + } else if (productIds != null && + productIds.isNotEmpty && + AppConfig.isInitialized()) { + final ids = productIds.toSet(); + resolvedUuids = AppConfig.config.products + .where((p) => ids.contains(p.id)) + .map((p) => p.scanuuid) + .where((u) => u.isNotEmpty) + .toList(growable: false); + } else if (AppConfig.isInitialized()) { + resolvedUuids = AppConfig.allproductUuids() + .where((u) => u.isNotEmpty) + .toList(growable: false); + } else { + resolvedUuids = const []; + } + + print('[BLE] [SCAN] init 已通过,UUID 白名单=$resolvedUuids'); scannedDevices.clear(); isScanning.value = true; try { - await Jielihome.instance.startScan(timeout: timeout); + await Jielihome.instance.startScan( + timeout: timeout, + uuidList: resolvedUuids, + skipUnnamed: true, + ); print('[BLE] [SCAN] native startScan 返回成功,等待 deviceFound 事件'); return true; } catch (e, s) { diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index 71748b5a6..889c2e193 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -458,8 +458,12 @@ class AzureAstService extends GetxService implements AstService { if (raw is! Map) return; final leg = raw['leg'] as String?; final pcm = raw['pcm']; + // 兼容旧 native:未带 final 字段时按 false 处理。 + final isFinal = raw['final'] == true; if (leg == null || pcm is! Uint8List) return; - if (!c.isClosed) c.add(AstTtsFrame(leg: leg, pcm: pcm)); + if (!c.isClosed) { + c.add(AstTtsFrame(leg: leg, pcm: pcm, isFinal: isFinal)); + } }, onError: (e) => Logger.error('astTtsFrames stream error: $e'), ); diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 47a40cc61..1ffbdde13 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:agent_service/agent_service.dart'; import 'package:chat_storage/chat_storage.dart'; +import 'package:device_jieli/device_jieli.dart'; import 'package:image_picker/image_picker.dart'; import 'package:get_storage/get_storage.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -111,6 +112,17 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 打电话模式状态 final isPhoneCallMode = false.obs; // 是否处于打电话模式 + // ─── 设备通话模式(device_jieli 耳机录音 → 外部注入 ASR)───── + // 独立于普通打电话模式,不复用 isPhoneCallMode + final isDeviceCallMode = false.obs; // 是否处于设备通话模式 + final isDeviceCallMicMuted = false.obs; // 设备通话模式下是否静音(丢帧) + StreamSubscription? _deviceCallJieliSub; + bool _deviceCallPushing = false; // 是否当前允许把设备 PCM 推给 ASR + // 调试:每秒打印一次 PCM 推送统计,确认音频是否真正流到 ASR + int _deviceCallPushedFrames = 0; + int _deviceCallPushedBytes = 0; + int _deviceCallLastReportMs = 0; + // 按住说话状态 final isPushToTalkActive = false.obs; // 是否正在按住说话 @@ -235,6 +247,10 @@ class AgentController extends GetxController with WidgetsBindingObserver { @override void onClose() { + // 若处于设备通话模式,fire-and-forget 结束 + if (isDeviceCallMode.value) { + unawaited(_stopDeviceCallInternal()); + } AgentService.stopAiSteam(); //停止AI流 AgentService.stopConversation(); // 停止当前会话 AgentService.stopTts(); // 停止当前语音 @@ -242,6 +258,8 @@ class AgentController extends GetxController with WidgetsBindingObserver { scrollController.dispose(); _eventSubscription?.cancel(); _amapeventSubscription?.cancel(); + _deviceCallJieliSub?.cancel(); + _deviceCallJieliSub = null; Intro.of(Get.context!).dispose(); // 取消应用生命周期监听 @@ -1724,6 +1742,148 @@ class AgentController extends GetxController with WidgetsBindingObserver { } } + // ─── 设备通话模式(device_jieli AI 助手通路) ──────────────────── + // 走 device_jieli 的 AI 助手通路(speechStart/speechStop): + // 上行帧由 SpeechAudioEvent 派发,PCM_S16LE/16k/mono/20ms,直接灌给 ASR。 + // 与翻译 / 设备录音互斥;不影响已有 phone_call / push_to_talk 任何路径。 + + /// 启动设备通话模式 + Future startDeviceCall() async { + Logger.i(TAG, '🎧 开始设备通话模式(AI 助手通路)'); + if (isDeviceCallMode.value) { + Logger.w(TAG, '已处于设备通话模式,忽略'); + return; + } + + // 必须在 Android 上且已连接杰理耳机 + if (!Platform.isAndroid) { + Get.snackbar('tips'.tr, '该功能仅安卓端支持'); + return; + } + final BleManager bleManager = Get.find(); + if (!bleManager.isConnected) { + Get.snackbar('tips'.tr, '请先连接耳机'); + return; + } + + // 收缩 + 号 + collapsePlusOptions(); + + isDeviceCallMode.value = true; + isDeviceCallMicMuted.value = false; + _deviceCallPushing = true; + + try { + // 先停之前可能存在的会话(不同模式之间切换保底) + await AgentService.stopConversation(); + + // 订阅 device_jieli 事件流,按 uplink 推给 ASR + await _deviceCallJieliSub?.cancel(); + _deviceCallJieliSub = Jielihome.instance.events.listen(_onDeviceCallEvent); + // ignore: avoid_print + print( + '[DEVCALL] _deviceCallJieliSub 已订阅 sub=$_deviceCallJieliSub mode=${isDeviceCallMode.value} pushing=$_deviceCallPushing', + ); + + // 启动 AgentService 外部音频模式 + 电话通话模式(避免 push_to_talk 静默超时) + final started = await AgentService.startConversation( + mode: 'phone_call', + isExternalActive: true, + ); + if (!started) { + Logger.w(TAG, '设备通话:AgentService 启动失败'); + } + + // 启动 AI 助理通路(JieliAssistantPort):MODE_RECORD + DEVICE_ALWAYS_RECORDING, + // 耳机自主持续推 OPUS,由 Port 解码成 16k/16bit/mono/20ms PCM 帧 + final ok = await Jielihome.instance.assistantStart(); + if (!ok) { + Logger.w(TAG, '设备通话:assistantStart 启动失败'); + await _stopDeviceCallInternal(); + Get.snackbar('error'.tr, '设备通话启动失败'); + return; + } + Logger.i(TAG, '设备通话模式已启动(JieliAssistantPort)'); + } catch (e) { + Logger.e(TAG, '启动设备通话失败: $e'); + await _stopDeviceCallInternal(); + Get.snackbar('error'.tr, '设备通话启动失败'); + } + } + + /// 结束设备通话模式 + Future endDeviceCall() async { + Logger.i(TAG, '🎧 结束设备通话模式'); + await _stopDeviceCallInternal(); + } + + /// 设备通话模式下切换静音(丢帧) + void toggleDeviceCallMic() { + if (!isDeviceCallMode.value) return; + isDeviceCallMicMuted.value = !isDeviceCallMicMuted.value; + _deviceCallPushing = !isDeviceCallMicMuted.value; + Logger.i(TAG, '设备通话静音切换: muted=${isDeviceCallMicMuted.value}'); + } + + Future _stopDeviceCallInternal() async { + _deviceCallPushing = false; + try { + await _deviceCallJieliSub?.cancel(); + } catch (_) {} + _deviceCallJieliSub = null; + + try { + await Jielihome.instance.assistantStop(); + } catch (e) { + Logger.w(TAG, 'assistantStop 失败(忽略): $e'); + } + + try { + await AgentService.stopConversation(); + } catch (e) { + Logger.w(TAG, 'stopConversation 失败(忽略): $e'); + } + + isDeviceCallMicMuted.value = false; + isDeviceCallMode.value = false; + isListening.value = false; + } + + void _onDeviceCallEvent(JieliEvent event) { + // 入口诊断:每秒打一次"是否进入此函数",无条件,用 print 走 I/flutter 通道 + final now0 = DateTime.now().millisecondsSinceEpoch; + if (event is AssistantAudioEvent) { + _deviceCallPushedFrames += 1; + _deviceCallPushedBytes += event.pcm.length; + if (now0 - _deviceCallLastReportMs >= 1000) { + // ignore: avoid_print + print( + '[DEVCALL] _onDeviceCallEvent 入口 (last 1s): frames=$_deviceCallPushedFrames bytes=$_deviceCallPushedBytes mode=${isDeviceCallMode.value} pushing=$_deviceCallPushing seq=${event.sequence}', + ); + _deviceCallPushedFrames = 0; + _deviceCallPushedBytes = 0; + _deviceCallLastReportMs = now0; + } + } + if (!isDeviceCallMode.value) return; + if (event is AssistantAudioEvent) { + if (!_deviceCallPushing) return; + if (event.encoding != 'pcm16') return; + if (event.pcm.isEmpty) return; + // fire-and-forget 推送给 ASR + AgentService.pushAudioData(event.pcm).catchError((e) { + Logger.w(TAG, 'pushAudioData 失败: $e'); + return false; + }); + } else if (event is AssistantStartEvent) { + Logger.i(TAG, 'AI 助理通路已启动(端侧通知)sr=${event.sampleRate}'); + } else if (event is AssistantErrorEvent) { + Logger.e(TAG, 'AI 助理错误 code=${event.code} msg=${event.message ?? ''}'); + } else if (event is AssistantEndEvent) { + Logger.i(TAG, 'AI 助理通路已停止(端侧通知)'); + } + } + // 检查当前是否可以启动耳机通话 bool canStartHeadsetCall() { // 如果已经在打电话模式,不允许重复启动 diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 8db6251da..52d7a0b4a 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:io'; -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; @@ -10,7 +8,6 @@ import '../../../data/services/version_update_service.dart'; import '../controllers/agent_controller.dart'; import '../controllers/message_action_controller.dart'; import 'message_bubble.dart'; -import '../widgets/full_screen_editor.dart'; import '../widgets/sound_bar_widget.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -26,9 +23,6 @@ class _AgentViewState extends State with WidgetsBindingObserver { late MessageActionController messageActionController; double _keyboardHeight = 0; - // 👇 新增:用于控制 TextField 焦点 - final FocusNode _textFocusNode = FocusNode(); - void _showTtsSpeedDialog() { final primaryColor = Theme.of(context).primaryColor; final isDarkMode = Theme.of(context).brightness == Brightness.dark; @@ -110,7 +104,6 @@ class _AgentViewState extends State with WidgetsBindingObserver { @override void dispose() { - _textFocusNode.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -400,30 +393,31 @@ class _AgentViewState extends State with WidgetsBindingObserver { key: key, ), ), - // 主要聊天界面 - Column( - children: [ - // 聊天内容 - Expanded( - child: GestureDetector( - onTap: () { - // 点击聊天区域时,收起键盘并切换工具显示状态 - FocusScope.of(context).unfocus(); - if (controller.isPlusOptionsExpanded.value) { - controller.collapsePlusOptions(); - } - }, - child: Obx(() { - return Align( - alignment: Alignment.topCenter, - child: ListView.builder( - controller: controller.scrollController, - shrinkWrap: true, - reverse: true, - padding: EdgeInsets.symmetric( - vertical: 16.h, horizontal: 16.w), - itemCount: controller.messages.length + - (controller.hasMoreMessages.value ? 1 : 0), + // 主要聊天界面(占满整个 body,底部按钮浮在其上) + GestureDetector( + onTap: () { + // 点击聊天区域时,收起键盘并切换工具显示状态 + FocusScope.of(context).unfocus(); + if (controller.isPlusOptionsExpanded.value) { + controller.collapsePlusOptions(); + } + }, + child: Obx(() { + return Align( + alignment: Alignment.topCenter, + child: ListView.builder( + controller: controller.scrollController, + shrinkWrap: true, + reverse: true, + padding: EdgeInsets.only( + top: 16.h, + // 预留浮动通话/挂断按钮的高度,避免最后一条消息被遮挡 + bottom: 140.h, + left: 16.w, + right: 16.w, + ), + itemCount: controller.messages.length + + (controller.hasMoreMessages.value ? 1 : 0), itemBuilder: (context, index) { // 如果是最后一项且有更多消息,显示加载指示器 if (index == controller.messages.length && @@ -528,682 +522,59 @@ class _AgentViewState extends State with WidgetsBindingObserver { ); }), ), - ), - - // 输入区域 - Container( - padding: - EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - decoration: BoxDecoration( - color: backgroundColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.06), - blurRadius: 8.w, - offset: const Offset(0, -1), - ), - ], - border: Border( - top: BorderSide( - color: Colors.grey.withValues(alpha: 0.15), - width: 1.w, - ), - ), - ), + // 底部浮动通话按钮:悬浮在聊天界面之上 + // 点击启动耳机录音 → JieliAssistantPort 通路 + // (MODE_RECORD + STRATEGY_DEVICE_ALWAYS_RECORDING) + Align( + alignment: Alignment.bottomCenter, + child: Obx(() { + if (controller.isDeviceCallMode.value) { + return const SizedBox.shrink(); + } + return Padding( + padding: EdgeInsets.only(bottom: 16.h), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - // 图片预览区域 - 仅在图片输入模式下显示 - Obx( - () { - if (controller.isImageInputActive.value && - controller.selectedImagePath.value != null) { - return Column( - children: [ - // 图片预览和关闭按钮 - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12.w), - child: Image.file( - File(controller - .selectedImagePath.value!), - height: 150.h, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - Positioned( - top: 8.h, - right: 8.w, - child: GestureDetector( - onTap: () => - controller.clearImageInput(), - child: Container( - padding: EdgeInsets.all(4.w), - decoration: BoxDecoration( - color: Colors.black - .withValues(alpha: 0.6), - shape: BoxShape.circle, - ), - child: Icon( - Icons.close, - color: Colors.white, - size: 16.sp, - ), - ), - ), - ), - ], - ), - - // 预设提示选项 - SizedBox(height: 12.h), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildPromptChip( - context: context, - label: - 'summarizeImageContent'.tr, //总结图片内容 - onTap: () => - controller.summarizeImage(), - ), - SizedBox(width: 8.w), - _buildPromptChip( - context: context, - label: 'extractImageText'.tr, //抽取图像文字 - onTap: () => - controller.extractTextFromImage(), - ), - SizedBox(width: 8.w), - _buildPromptChip( - context: context, - label: 'translateImageText'.tr, //翻译图像文字 - onTap: () => - controller.translateImageText(), - ), - ], - ), - ), - SizedBox(height: 12.h), - ], - ); - } else { - // 文本输入模式下,且输入框为空 - return Column( - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildPromptChip( - context: context, - label: 'presetMusic'.tr, // 来点音乐 - onTap: () => controller - .preSetPromptAction('Music')), - SizedBox(width: 8.w), - _buildPromptChip( - context: context, - label: 'presetNavigation'.tr, // 导航 - onTap: () => - controller.preSetPromptAction( - 'Navigation')), - SizedBox(width: 8.w), - _buildPromptChip( - context: context, - label: 'presetWeather'.tr, // 天气 - onTap: () => controller - .preSetPromptAction('Weather')), - SizedBox(width: 8.w), - _buildPromptChip( - context: context, - label: 'presetNews'.tr, // 新闻 - onTap: () => controller - .preSetPromptAction('News')), - SizedBox(width: 8.w), - _buildPromptChip( - context: context, - label: 'presetFood'.tr, // 美食 - onTap: () => controller - .preSetPromptAction('Food')), - ], - )), - SizedBox(height: 12.h), - ], - ); - } - }, - ), - - // 输入控件区域 - 包含输入行和展开的选项 - Column( - children: [ - Row( - crossAxisAlignment: - CrossAxisAlignment.end, // 所有元素底部对齐 - children: [ - // 键盘/语音切换按钮 - Obx(() { - final isTextMode = - controller.isTextInputMode.value; - return GestureDetector( - onTap: () { - controller.toggleInputMode(); - // 切换模式时,确保焦点正确 - if (!isTextMode) { - // 切换到语音模式时,请求焦点 - Future.delayed(Duration.zero, () { - _textFocusNode.requestFocus(); - }); - } - }, - child: Container( - width: 44.w, - height: 44.w, - margin: EdgeInsets.symmetric(vertical: 2.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.grey[800] - : Colors.grey[200], - borderRadius: BorderRadius.circular(22), - ), - child: Icon( - isTextMode - ? Icons.mic_none - : Icons.keyboard, - color: isDarkMode - ? Colors.grey[300] - : Colors.grey[700], - size: 22.sp, - ), - ), - ); - }), - - SizedBox(width: 8.w), - - // 文本输入框或语音波形图 - Expanded( - child: Obx(() { - final isTextMode = - controller.isTextInputMode.value; - - if (isTextMode) { - // 文本输入模式 - 限制最大高度并只向上扩展 - return IntroStepBuilder( - group: 'ai', - order: 1, - getOverlayPosition: - IntroOverlayBuilder.overlayPosition, - overlayBuilder: (params) { - return IntroOverlayBuilder.aiBuilder( - params, - '${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['startKey']}' - .tr, - buttonText: 'confirmInput'.tr, - onNext: () { - controller.textController.text = - '${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['inputKey']}' - .tr; - }, - onTap: () { - controller.textController.text = - '${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['inputKey']}' - .tr; - params.onNext!(); - }, - ); - }, - onWidgetLoad: () { - controller.noviceGuideService - .startAIGuide(); - }, - builder: (BuildContext context, - GlobalKey key) => - Container( - key: key, - constraints: BoxConstraints( - minHeight: 48.w, - ), - padding: EdgeInsets.symmetric( - horizontal: 16.w, - vertical: 5.w, - ), - alignment: Alignment.center, - decoration: BoxDecoration( - color: isDarkMode - ? Colors.grey[800] - : Colors.grey[200], - borderRadius: - BorderRadius.circular(22.w), - border: Border.all( - color: isDarkMode - ? Colors.grey[800]! - : Colors.grey - .withValues(alpha: 0.1), - width: 1.w, - ), - ), - child: Obx(() { - // 图片模式下使用更高的输入框 - final isImageActive = controller - .isImageInputActive.value; - - return TextField( - focusNode: _textFocusNode, // 绑定焦点节点 - controller: - controller.textController, - maxLines: isImageActive - ? 3 - : 5, // 设置合理的最大行数 - minLines: isImageActive ? 2 : 1, - keyboardType: TextInputType - .multiline, // 启用多行键盘 - textInputAction: TextInputAction - .newline, // 支持回车换行 - onTap: () { - // 当点击输入框时,如果'+'选项已展开,则收缩 - if (controller - .isPlusOptionsExpanded - .value) { - controller - .collapsePlusOptions(); - } - }, - style: TextStyle( - fontSize: 15.sp, - color: isDarkMode - ? Colors.white - : Colors.grey[800], - ), - decoration: InputDecoration( - hintText: isImageActive - ? 'addQuestionAboutImage' - .tr // 添加对图片的问题或描述... - : 'inputMessage' - .tr, // 输入消息... - hintStyle: TextStyle( - color: Colors.grey[500], - fontSize: 15.sp, - ), - filled: false, - isDense: true, - border: InputBorder.none, - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: isDarkMode - ? Colors.grey[800]! - : Colors.grey[200]!, - width: 1.w, - ), - ), - contentPadding: EdgeInsets.zero, - ), - onSubmitted: (_) => - controller.sendMessage(), - ); - }), - ), - ); - } else { - // 语音输入模式 - 按住说话模式 - return Listener( - onPointerDown: (_) { - controller.startPushToTalk(); - }, - onPointerUp: (_) { - controller.endPushToTalk(); - }, - onPointerCancel: (_) { - controller.endPushToTalk(); - }, - // onLongPressStart: (_) { - // // 开始按住说话 - // controller.startPushToTalk(); - // }, - // onLongPressEnd: (_) { - // // 结束按住说话 - // controller.endPushToTalk(); - // }, - // onLongPressCancel: () { - // // 取消按住说话 - // controller.endPushToTalk(); - // }, - child: Obx(() { - final isPushToTalkActive = - controller.isPushToTalkActive.value; - - return Container( - height: 48.w, - padding: EdgeInsets.symmetric( - horizontal: 16.w), - decoration: BoxDecoration( - color: isPushToTalkActive - ? (isDarkMode - ? Colors.blue[900] - : Colors.blue[100]) - : (isDarkMode - ? Colors.grey[800] - : const Color.fromARGB( - 255, 255, 255, 255)), - borderRadius: - BorderRadius.circular(22.w), - boxShadow: [ - BoxShadow( - color: Colors.black - .withValues(alpha: 0.2), - spreadRadius: 1, - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - // border: Border.all( - // color: isPushToTalkActive - // ? Colors.blue - // : (isDarkMode - // ? Colors.grey[800]! - // : const Color.fromARGB( - // 255, 255, 255, 255) - // .withOpacity(0.1)), - // width: - // isPushToTalkActive ? 2.w : 1.w, - // ), - ), - child: Row( - children: [ - // 语音波形图 - 只在按住时显示 - if (isPushToTalkActive) ...[ - const AgentSoundBarsWidget(), - //const Spacer(), - ], - - // 状态文字和图标 - Expanded( - child: Row( - mainAxisAlignment: - isPushToTalkActive - ? MainAxisAlignment - .center - : MainAxisAlignment - .center, - children: [ - Icon( - isPushToTalkActive - ? Icons.mic - : Icons.mic_none, - size: 16.sp, - color: isPushToTalkActive - ? Colors.blue - : Colors.grey[500], - ), - const SizedBox(width: 6), - Text( - isPushToTalkActive - ? 'recording' - .tr // 正在录音... - : 'holdToSpeak' - .tr, // 按住说话 - style: TextStyle( - fontSize: 14, - fontWeight: - FontWeight.w500, - color: - isPushToTalkActive - ? Colors.blue - : Colors - .grey[600], - ), - ), - ], - ), - ), - ], - ), - ); - }), - ); - } - }), - ), - - SizedBox(width: 8.w), - - // 动态按钮区域:展开按钮 + 发送按钮 或 图片选择按钮 - IntroStepBuilder( - group: 'ai', - order: 2, - getOverlayPosition: - IntroOverlayBuilder.overlayPosition, - overlayBuilder: (params) { - return IntroOverlayBuilder.aiBuilder( - params, - '${'guideClickSendPrefix'.tr}${'${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['sendKey']}'.tr}', - alignment: false, - onTap: () async { - await controller - .noviceGuideService.aiStepsList[ - controller.noviceGuideService - .aiStep]['method'](); - params.onNext!(); - controller.sendMessage(); - }, - ); - }, - builder: - (BuildContext context, GlobalKey key) => - Obx( - key: key, - () { - final hasText = - controller.hasTextInput.value; - final hasImage = - controller.isImageInputActive.value; - final shouldShowSend = hasText || hasImage; - final shouldShowExpand = - controller.shouldShowExpandButton.value; - - if (shouldShowSend) { - // 显示发送按钮(可能还有展开按钮) - return Column( - children: [ - // 展开按钮(在发送按钮上方) - if (shouldShowExpand && hasText) - Padding( - padding: - EdgeInsets.only(bottom: 40.h), - child: GestureDetector( - onTap: () { - // 打开底部弹出编辑模式 - controller - .openFullScreenEdit(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: - Colors.transparent, - builder: (context) => - const FullScreenEditor(), - ).then((_) { - // 弹出页关闭时的回调 - controller - .closeFullScreenEdit(); - }); - }, - child: Container( - width: 32.w, - height: 32.w, - decoration: BoxDecoration( - color: isDarkMode - ? Colors.grey[700] - : Colors.grey[300], - borderRadius: - BorderRadius.circular( - 16.w), - ), - child: Icon( - Icons.expand_less, - color: isDarkMode - ? Colors.grey[300] - : Colors.grey[700], - size: 20.sp, - ), - ), - ), - ), - // 发送按钮 - GestureDetector( - onTap: () => - controller.sendMessage(), - child: Container( - width: 44.w, - height: 44.w, - margin: EdgeInsets.symmetric( - vertical: 2.w), - decoration: BoxDecoration( - color: primaryColor, - borderRadius: - BorderRadius.circular(22.w), - ), - child: Icon( - Icons.send, - color: Colors.white, - size: 22.sp, - ), - ), - ), - ], - ); - } else { - // 显示'+'选择按钮 - return Obx(() { - final isExpanded = controller - .isPlusOptionsExpanded.value; - return GestureDetector( - onTap: () { - controller.togglePlusOptions(); - }, - child: Container( - width: 44.w, - height: 44.w, - margin: EdgeInsets.symmetric( - vertical: 2.w), - decoration: BoxDecoration( - color: isDarkMode - ? Colors.grey[800] - : Colors.grey[200], - borderRadius: - BorderRadius.circular(22.w), - ), - child: Icon( - isExpanded - ? Icons.close - : Icons.add, - color: isDarkMode - ? Colors.grey[300] - : Colors.grey[700], - size: 22.sp, - ), - ), - ); - }); - } - }, - ), + GestureDetector( + onTap: () => controller.startDeviceCall(), + child: Container( + width: 68.w, + height: 68.w, + decoration: BoxDecoration( + color: const Color(0xFF10B981), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFF10B981) + .withValues(alpha: 0.35), + blurRadius: 16.w, + offset: Offset(0, 6.h), ), ], ), - - // 展开的'+'选择选项 - Obx(() { - final isExpanded = - controller.isPlusOptionsExpanded.value; - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - height: isExpanded ? 100.h : 0, // 调整高度以适应按钮尺寸 - child: isExpanded - ? LayoutBuilder( - // 添加LayoutBuilder获取实际约束 - builder: (context, constraints) { - return SingleChildScrollView( - // 确保内容可滚动 - scrollDirection: - Axis.horizontal, // 水平滚动 - child: Container( - margin: EdgeInsets.only(top: 16.h), - padding: EdgeInsets.symmetric( - horizontal: 16.w), - // 根据可用宽度调整容器宽度 - width: max(constraints.maxWidth, - 300.w), // 最小300宽 - child: Wrap( - // 使用Wrap替代Row,自动换行 - alignment: WrapAlignment.center, - spacing: 26.w, // 按钮水平间距 - runSpacing: 8.h, // 按钮垂直间距 - children: [ - // 相机按钮 - _buildPlusOptionButton( - context: context, - icon: Icons.camera_alt, - label: 'takePhoto'.tr, // 拍照 - onTap: () async { - //controller.collapsePlusOptions(); - await controller - .takePhoto(); - }, - isDarkMode: isDarkMode, - ), - // 相册按钮 - _buildPlusOptionButton( - context: context, - icon: Icons.photo_library, - label: 'photoAlbum'.tr, // 相册 - onTap: () async { - //controller.collapsePlusOptions(); - await controller - .pickImage(); - }, - isDarkMode: isDarkMode, - ), - // 文件按钮(预留位置) - // _buildPlusOptionButton( - // context: context, - // icon: Icons.folder, - // label: '文件', - // onTap: () { - // //controller.collapsePlusOptions(); - // // TODO: 实现文件选择功能 - // }, - // isDarkMode: isDarkMode, - // ), - // 通话按钮 - _buildPlusOptionButton( - context: context, - icon: Icons.phone, - label: 'makeCall'.tr, // 打电话 - onTap: () { - // 启动打电话模式 - controller.startPhoneCall(); - }, - isDarkMode: isDarkMode, - ), - ], - ), - ), - ); - }, - ) - : const SizedBox.shrink(), - ); - }), - ], + child: Icon( + Icons.call, + color: Colors.white, + size: 30.sp, + ), + ), + ), + SizedBox(height: 8.h), + Text( + '通话', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isDarkMode + ? Colors.grey[300] + : Colors.grey[700], + ), ), ], ), - ), - ], + ); + }), ), // 打电话模式覆盖层 @@ -1213,94 +584,14 @@ class _AgentViewState extends State with WidgetsBindingObserver { } return const SizedBox.shrink(); }), - ], - ), - ), - ); - } - // 构建提示选项芯片 - Widget _buildPromptChip({ - required BuildContext context, - required String label, - required VoidCallback onTap, - }) { - final primaryColor = Theme.of(context).primaryColor; - - return GestureDetector( - onTap: onTap, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - color: primaryColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(16.w), - border: Border.all( - color: primaryColor.withValues(alpha: 0.2), - width: 1.w, - ), - ), - child: Text( - label, - style: TextStyle( - fontSize: 13.sp, - color: primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ); - } - - // 构建"+"号展开选项按钮 - Widget _buildPlusOptionButton({ - required BuildContext context, - required IconData icon, - required String label, - required VoidCallback onTap, - required bool isDarkMode, - }) { - final primaryColor = Theme.of(context).primaryColor; - - return GestureDetector( - onTap: onTap, - child: SizedBox( - // 调整为一行四个按钮的宽度,考虑屏幕宽度和间距 - width: (1.sw - 100.w) / 4, // 屏幕宽度减去左右边距,除以4个按钮 - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 正方形图标容器 - 增大尺寸 - Container( - width: 50.w, // 调整为44.w以适应布局 - height: 50.w, // 调整为44.w以适应布局 - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[800] : Colors.grey[100], - borderRadius: BorderRadius.circular(12.w), - border: Border.all( - color: isDarkMode ? Colors.grey[700]! : Colors.grey[300]!, - width: 1.w, - ), - ), - child: Icon( - icon, - color: primaryColor, - size: 20.sp, // 调整为18.sp - ), - ), - SizedBox(height: 4.h), // 调整为3.h - // 文字标签 - Text( - label, - style: TextStyle( - fontSize: 10.sp, // 调整为9.sp - color: isDarkMode ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + // 设备通话模式覆盖层 + Obx(() { + if (controller.isDeviceCallMode.value) { + return _buildDeviceCallOverlay(context, isDarkMode); + } + return const SizedBox.shrink(); + }), ], ), ), @@ -1408,6 +699,56 @@ class _AgentViewState extends State with WidgetsBindingObserver { ); } + // ─── 设备通话覆盖层(接听后仅一个挂断按钮,悬浮在聊天界面之上)───────────────────── + Widget _buildDeviceCallOverlay(BuildContext context, bool isDarkMode) { + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only(bottom: 16.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => controller.endDeviceCall(), + child: Container( + width: 68.w, + height: 68.w, + decoration: BoxDecoration( + color: const Color(0xFFEF4444), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFFEF4444).withValues(alpha: 0.35), + blurRadius: 16.w, + offset: Offset(0, 6.h), + ), + ], + ), + child: Transform.rotate( + angle: 2.356, + child: Icon( + Icons.call_end, + color: Colors.white, + size: 30.sp, + ), + ), + ), + ), + SizedBox(height: 8.h), + Text( + '挂断', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.grey[300] : Colors.grey[700], + ), + ), + ], + ), + ), + ); + } + // 构建加载更多指示器 Widget _buildLoadMoreIndicator() { return Obx( diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index 2c7ed43ac..a106cafa0 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -7,6 +7,7 @@ import 'package:get/get.dart'; import 'package:permission_handler/permission_handler.dart'; import '/core/utils/logger.dart'; +import '/data/models/appconfig.dart'; import '/data/services/ble_manager.dart'; import '/routes/app_routes.dart'; @@ -48,7 +49,10 @@ class DevicesController extends GetxController { Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr); return; } - final ok = await _bleManager.startScan(); + // 设备页是通用绑定入口,扫描白名单 = AppConfig 下发的所有产品 scanuuid + final uuids = + AppConfig.isInitialized() ? AppConfig.allproductUuids() : []; + final ok = await _bleManager.startScan(uuids: uuids); if (!ok) { final err = _bleManager.lastScanError ?? 'unknown'; print('[BLE] [SCAN] 扫描启动失败: $err'); diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 496123477..05d6b6541 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -20,6 +20,7 @@ import '../../../routes/app_routes.dart'; import '../../../data/services/asr_service.dart'; import '../../../data/services/ble_manager.dart'; import '../../../data/services/meeting/meeting_task_service.dart'; +import '../utils/jieli_call_recorder.dart'; /// 会议记录状态枚举 enum TimerStatus { @@ -374,6 +375,7 @@ class MeetingRecordController extends GetxController // 移除了未使用的变量和TabController StreamSubscription? _audioDataSubscription; + JieliCallRecorder? _jieliCallRecorder; double _smoothedRms = 0.0; double _latestRms = 0.0; Timer? _waveformTimer; @@ -477,10 +479,15 @@ class MeetingRecordController extends GetxController _audioDataSubscription = null; titleEditingController.dispose(); titleFocusNode.dispose(); - if (audioType.value != 0) { + if (audioType.value == 1) { _bleManager.closeCodec(); } - if (isRecording.value) { + final pendingJieli = _jieliCallRecorder; + _jieliCallRecorder = null; + if (pendingJieli != null) { + // onClose 不能 await,这里发起 fire-and-forget 关闭,丢弃文件即可。 + unawaited(pendingJieli.stop(save: false)); + } else if (isRecording.value) { _asrService.stopRecord(true); } isRecording.value = false; @@ -603,22 +610,37 @@ class MeetingRecordController extends GetxController acceptAudioData = true; } - await _asrService.enableRecord( - audioSourceType, - filePath, - acceptAudioData, - ); + if (audioType.value == 2) { + // 通话录音:走 device_jieli 设备端录音,直接在 Dart 侧合成立体声 WAV, + // 不复用 Azure 插件的 RecordFile 路径(其 setAudioConfig 为 no-op, + // 也没有干净的 Dart push 入口)。 + _jieliCallRecorder = JieliCallRecorder( + onWavePcm: _processAudioData, + ); + final ok = await _jieliCallRecorder!.start(filePath); + if (!ok) { + _jieliCallRecorder = null; + Get.snackbar('error'.tr, 'meetingRecordSaveFailed'.tr); + return; + } + } else { + await _asrService.enableRecord( + audioSourceType, + filePath, + acceptAudioData, + ); - // 通过 asrService 获取音频数据流 - _audioDataSubscription = - _asrService.getAudioDataStream()?.listen((audioData) { - // 处理音频数据,例如: - // 1. 实时音频可视化 - // 2. 音频质量检测 - // 3. 发送到其他服务 - // 处理音频数据的逻辑 - _processAudioData(audioData); - }); + // 通过 asrService 获取音频数据流 + _audioDataSubscription = + _asrService.getAudioDataStream()?.listen((audioData) { + // 处理音频数据,例如: + // 1. 实时音频可视化 + // 2. 音频质量检测 + // 3. 发送到其他服务 + // 处理音频数据的逻辑 + _processAudioData(audioData); + }); + } isRecording.value = true; _overlayPermissionPromptedThisSession = false; @@ -648,15 +670,20 @@ class MeetingRecordController extends GetxController _bleManager.openDecoder(); break; case 2: - _asrService.setAudioConfig(sampleRate: 16000, channels: 2); - _bleManager.openCallRecordDecoder(); + // 通话录音已切换到 device_jieli JieliDeviceRecordPort 通道, + // 由 JieliCallRecorder 在 Dart 层接管 PCM 合成与文件写入, + // 不再依赖 BleManager.openCallRecordDecoder(当前为空壳实现)。 break; } } /// 暂停当前录音会话 Future _pauseRecording() async { - await _asrService.pauseRecord(); + if (_jieliCallRecorder != null) { + _jieliCallRecorder!.pause(); + } else { + await _asrService.pauseRecord(); + } _stopWaveformTimer(); rms.value = 0.0; pauseTimer(); @@ -665,7 +692,11 @@ class MeetingRecordController extends GetxController /// 恢复已暂停的录音会话 Future _resumeRecording() async { - await _asrService.resumeRecord(); + if (_jieliCallRecorder != null) { + _jieliCallRecorder!.resume(); + } else { + await _asrService.resumeRecord(); + } _startWaveformTimer(); startTimer(); await _refreshRecordingIndicator(); @@ -749,16 +780,24 @@ class MeetingRecordController extends GetxController _stopWaveformTimer(); try { - final bool stopped = await _asrService - .stopRecord(true) - .timeout(_stopRecordTimeout, onTimeout: () => false); + final bool stopped; + if (_jieliCallRecorder != null) { + stopped = await _jieliCallRecorder! + .stop(save: true) + .timeout(_stopRecordTimeout, onTimeout: () => false); + _jieliCallRecorder = null; + } else { + stopped = await _asrService + .stopRecord(true) + .timeout(_stopRecordTimeout, onTimeout: () => false); + } if (!stopped) { _restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs); Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr); return false; } - if (audioType.value != 0) { + if (audioType.value == 1) { _bleManager.closeCodec(); } @@ -805,16 +844,24 @@ class MeetingRecordController extends GetxController _stopWaveformTimer(); try { - final bool stopped = await _asrService - .stopRecord(false) - .timeout(_stopRecordTimeout, onTimeout: () => false); + final bool stopped; + if (_jieliCallRecorder != null) { + stopped = await _jieliCallRecorder! + .stop(save: false) + .timeout(_stopRecordTimeout, onTimeout: () => false); + _jieliCallRecorder = null; + } else { + stopped = await _asrService + .stopRecord(false) + .timeout(_stopRecordTimeout, onTimeout: () => false); + } if (!stopped) { _restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs); Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr); return false; } - if (audioType.value != 0) { + if (audioType.value == 1) { _bleManager.closeCodec(); } diff --git a/lib/modules/meeting/utils/jieli_call_recorder.dart b/lib/modules/meeting/utils/jieli_call_recorder.dart new file mode 100644 index 000000000..f08dadd8b --- /dev/null +++ b/lib/modules/meeting/utils/jieli_call_recorder.dart @@ -0,0 +1,277 @@ +// 通话录音 Dart 端 PCM 合成器 +// +// 用途:把 device_jieli 上行(in.uplink)/下行(in.downlink)两路异步 PCM 帧 +// 配对成 16k/16bit/双声道(左=本端、右=对端)的标准 WAV 文件,独立于 +// Azure 插件的 RecordFile 路径(避开其 setAudioConfig no-op 与 push 通道的 +// 参数 key 不一致 bug)。 +// +// 注意:device_jieli 的设备录音没有原生 pause API,本类的暂停语义通过 +// 在 Dart 层丢弃接收到的 PCM 帧实现。 +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:device_jieli/device_jieli.dart'; + +import '/core/utils/logger.dart'; + +class JieliCallRecorder { + JieliCallRecorder({ + this.sampleRate = 16000, + this.bitsPerSample = 16, + this.onWavePcm, + }); + + /// 采样率(Hz)。device_jieli DeviceRecord 默认 16000。 + final int sampleRate; + + /// 位深,固定 16bit。 + final int bitsPerSample; + + /// 写入文件后的立体声 PCM 通知,用于驱动上层波形/振幅。 + final void Function(Uint8List stereoPcm)? onWavePcm; + + static const String _tag = 'JieliCallRecorder'; + static const int _channels = 2; + + StreamSubscription? _eventSub; + RandomAccessFile? _raf; + String? _filePath; + int _dataBytes = 0; + bool _paused = false; + bool _running = false; + + // 上下行 PCM 字节缓冲(s16le) + final BytesBuilder _uplink = BytesBuilder(copy: false); + final BytesBuilder _downlink = BytesBuilder(copy: false); + + bool get isRunning => _running; + String? get filePath => _filePath; + + /// 启动录音:写 WAV 头占位、订阅事件、调起设备录音上行。 + Future start(String filePath) async { + if (_running) { + Logger.w(_tag, '重复 start 调用,已忽略'); + return false; + } + try { + final file = File(filePath); + final dir = file.parent; + if (!await dir.exists()) { + await dir.create(recursive: true); + } + if (await file.exists()) { + await file.delete(); + } + _raf = await file.open(mode: FileMode.write); + await _raf!.writeFrom(_makeWavHeader(0)); + + _dataBytes = 0; + _filePath = filePath; + _paused = false; + _uplink.clear(); + _downlink.clear(); + + _eventSub = Jielihome.instance.events.listen(_onEvent); + + try { + await Jielihome.instance.deviceRecordStart(sampleRate: sampleRate); + } catch (e) { + Logger.error('$_tag deviceRecordStart 失败: $e'); + await _shutdown(saveFile: false); + return false; + } + + _running = true; + Logger.info('$_tag 启动通话录音 path=$filePath sr=$sampleRate'); + return true; + } catch (e) { + Logger.error('$_tag 启动失败: $e'); + await _shutdown(saveFile: false); + return false; + } + } + + /// 暂停:仅在 Dart 层丢帧;设备端继续推流。 + void pause() { + if (!_running) return; + _paused = true; + } + + /// 恢复接收设备端帧。 + void resume() { + if (!_running) return; + _paused = false; + } + + /// 停止录音;[save] 为 false 时回滚删除文件。 + Future stop({required bool save}) async { + if (!_running) { + // 即便外部异常重复 stop,也保证幂等 + await _shutdown(saveFile: save); + return true; + } + _running = false; + try { + try { + await Jielihome.instance.deviceRecordStop(); + } catch (e) { + Logger.w(_tag, 'deviceRecordStop 失败(忽略): $e'); + } + await _shutdown(saveFile: save); + return true; + } catch (e) { + Logger.error('$_tag 停止失败: $e'); + return false; + } + } + + Future _shutdown({required bool saveFile}) async { + await _eventSub?.cancel(); + _eventSub = null; + final raf = _raf; + final path = _filePath; + _raf = null; + _filePath = null; + _uplink.clear(); + _downlink.clear(); + _paused = false; + + if (raf != null) { + try { + if (saveFile) { + await raf.setPosition(0); + await raf.writeFrom(_makeWavHeader(_dataBytes)); + } + } catch (e) { + Logger.error('$_tag 写 WAV 头失败: $e'); + } finally { + try { + await raf.flush(); + } catch (_) {} + try { + await raf.close(); + } catch (_) {} + } + } + + if (!saveFile && path != null) { + try { + final f = File(path); + if (await f.exists()) { + await f.delete(); + } + } catch (e) { + Logger.w(_tag, '删除录音文件失败: $e'); + } + } + _dataBytes = 0; + } + + void _onEvent(JieliEvent event) { + if (event is DeviceRecordAudioEvent) { + if (_paused) return; + if (event.streamId == DeviceRecordStreams.inUplink) { + _uplink.add(event.pcm); + } else if (event.streamId == DeviceRecordStreams.inDownlink) { + _downlink.add(event.pcm); + } else { + return; + } + _drain(); + } else if (event is DeviceRecordErrorEvent) { + Logger.error( + '$_tag 设备录音错误: code=${event.code} msg=${event.message ?? ''}'); + } + } + + /// 按 sample 对齐配对 uplink/downlink,写入立体声 WAV 数据段。 + void _drain() { + final raf = _raf; + if (raf == null) return; + + final upBytes = _uplink.takeBytes(); + final downBytes = _downlink.takeBytes(); + final pairableBytes = (min(upBytes.length, downBytes.length) ~/ 2) * 2; + if (pairableBytes <= 0) { + // 没有可配对样本,原样放回缓冲 + _uplink.add(upBytes); + _downlink.add(downBytes); + return; + } + + final samples = pairableBytes ~/ 2; + final stereo = Uint8List(samples * 4); + for (int i = 0; i < samples; i++) { + final si = i * 2; + final di = i * 4; + // L = uplink(本端) + stereo[di] = upBytes[si]; + stereo[di + 1] = upBytes[si + 1]; + // R = downlink(对端) + stereo[di + 2] = downBytes[si]; + stereo[di + 3] = downBytes[si + 1]; + } + + try { + raf.writeFromSync(stereo); + _dataBytes += stereo.length; + } catch (e) { + Logger.error('$_tag 写 PCM 失败: $e'); + // 写失败时,把字节放回缓冲,避免丢数据无可恢复 + _uplink.add(upBytes); + _downlink.add(downBytes); + return; + } + + onWavePcm?.call(stereo); + + if (pairableBytes < upBytes.length) { + _uplink.add(Uint8List.sublistView(upBytes, pairableBytes)); + } + if (pairableBytes < downBytes.length) { + _downlink.add(Uint8List.sublistView(downBytes, pairableBytes)); + } + } + + /// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。 + Uint8List _makeWavHeader(int dataLength) { + final byteRate = sampleRate * _channels * (bitsPerSample ~/ 8); + final blockAlign = _channels * (bitsPerSample ~/ 8); + final totalLength = 36 + dataLength; + final h = ByteData(44); + + // "RIFF" + h.setUint8(0, 0x52); + h.setUint8(1, 0x49); + h.setUint8(2, 0x46); + h.setUint8(3, 0x46); + h.setUint32(4, totalLength, Endian.little); + // "WAVE" + h.setUint8(8, 0x57); + h.setUint8(9, 0x41); + h.setUint8(10, 0x56); + h.setUint8(11, 0x45); + // "fmt " + h.setUint8(12, 0x66); + h.setUint8(13, 0x6d); + h.setUint8(14, 0x74); + h.setUint8(15, 0x20); + h.setUint32(16, 16, Endian.little); // fmt chunk size + h.setUint16(20, 1, Endian.little); // PCM + h.setUint16(22, _channels, Endian.little); + h.setUint32(24, sampleRate, Endian.little); + h.setUint32(28, byteRate, Endian.little); + h.setUint16(32, blockAlign, Endian.little); + h.setUint16(34, bitsPerSample, Endian.little); + // "data" + h.setUint8(36, 0x64); + h.setUint8(37, 0x61); + h.setUint8(38, 0x74); + h.setUint8(39, 0x61); + h.setUint32(40, dataLength, Endian.little); + + return h.buffer.asUint8List(); + } +} diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index b2b3f7538..d6bc593d7 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -1259,7 +1259,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { ); _jieliCallAstTtsSub = _astService.astTtsFrames.listen( (frame) { - bleManager.feedAstTtsPcm(leg: frame.leg, pcm: frame.pcm); + bleManager.feedAstTtsPcm( + leg: frame.leg, + pcm: frame.pcm, + isFinal: frame.isFinal, + ); }, onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'), ); diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 56158a387..cd8ca76fa 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -29,6 +29,7 @@ import android.media.AudioFocusRequest import android.media.AudioManager import android.os.Build import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.sync.Mutex import com.deep_voice.speech.tts.TtsEvent import com.deep_voice.speech.tts.TtsEventListener @@ -1222,7 +1223,23 @@ object AgentService : CoroutineScope { } } + private val pushAudioFrameCount = AtomicLong(0) + @Volatile private var pushAudioLastReportMs = 0L + fun pushAudioData(data: ByteArray) { + pushAudioFrameCount.incrementAndGet() + val now = System.currentTimeMillis() + if (now - pushAudioLastReportMs >= 1000L) { + pushAudioLastReportMs = now + val helper = azureAsrHelper + val stream = helper?.audioStream + _log( + "INFO", + "[Agent][PCM] pushAudioData 1s frames=${pushAudioFrameCount.getAndSet(0)} " + + "helperReady=${helper != null} streamSource=${stream?.audioSourceType} " + + "active=${stream?.isContinuousRecognitionActive}" + ) + } azureAsrHelper?.audioStream?.saveAudioDataTo(data) } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index f4078607c..f94d8ed4a 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt @@ -156,13 +156,30 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream "startConversation" -> { try { val mode = call.argument("mode") ?: "normal" - val isStarted = AgentService.startRecognition(false, mode) + // isExternalActive: true = 走外部 PCM 注入(由 Dart 侧通过 pushAudioData 推送) + val isExternalActive = call.argument("isExternalActive") ?: false + val isStarted = AgentService.startRecognition(isExternalActive, mode) result.success(isStarted) } catch (e: Exception) { Log.e(TAG, "启动对话失败", e) result.error("START_ERROR", "启动对话失败: ${e.message}", null) } } + + "pushAudioData" -> { + try { + val data = call.argument("data") + if (data == null) { + result.error("INVALID_ARGUMENTS", "音频数据不能为空", null) + return + } + AgentService.pushAudioData(data) + result.success(true) + } catch (e: Exception) { + Log.e(TAG, "推送音频数据失败", e) + result.error("PUSH_AUDIO_ERROR", "推送音频数据失败: ${e.message}", null) + } + } "isInterrupt" -> { try { val isinterrupt = call.argument("isinterrupt") diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 533f76ddf..06c1c1dd6 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -287,7 +287,10 @@ class AgentService { static DateTime? _lastStartConversationAt; static const Duration _conversationDebounce = Duration(milliseconds: 600); - static Future startConversation({String mode = 'normal'}) async { + static Future startConversation({ + String mode = 'normal', + bool isExternalActive = false, + }) async { // 防抖:短时间内重复点击直接忽略(冷却式防抖) final now = DateTime.now(); if (_lastStartConversationAt != null && @@ -306,6 +309,7 @@ class AgentService { print('liwei--------- view startConversation'); final bool result = await _channel.invokeMethod('startConversation', { 'mode': mode, + 'isExternalActive': isExternalActive, }); return result; } on PlatformException catch (e) { @@ -313,6 +317,22 @@ class AgentService { } } + /// 推送外部 PCM 音频数据到识别引擎 + /// + /// 仅在通过 [startConversation] 设置 `isExternalActive = true` 时有效。 + /// PCM 格式要求:16bit signed, little-endian, 1 channel, 16000 Hz。 + static Future pushAudioData(Uint8List data) async { + if (data.isEmpty) return false; + try { + final bool result = await _channel.invokeMethod('pushAudioData', { + 'data': data, + }); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '推送音频数据失败', e.details); + } + } + static Future isInterrupt(bool isInterrupt) async { try { final bool result = await _channel.invokeMethod('isInterrupt', { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt index 00d07b236..93c3d20c5 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt @@ -11,10 +11,16 @@ fun interface AstEventSender { } /** - * Interface for writing audio data (PCM) + * Interface for writing audio data (PCM). + * + * 由 4 个 AST provider 的 callback 在产出 TTS PCM 时回调; + * [markEnd] 在每段 utterance(一句翻译)TTS 流结束时调用一次, + * 让下游(Dart → 杰理 RCSP runtime)感知到段尾以触发"立即整段下发"。 */ -fun interface AudioWriter { +interface AudioWriter { fun write(data: ByteArray) + /** 当前 utterance 的最后一段 PCM 已经 [write] 过;触发下游 isFinal=true 信号。 */ + fun markEnd() {} } /** @@ -170,6 +176,8 @@ class AzureAstCallback( "text" to text ) ) + // Azure 段尾:触发下游 RCSP runtime 立即整段下发。 + audioWriter?.markEnd() } override fun onSynthesisAudioGenerated( @@ -419,6 +427,8 @@ class IflytekAstCallback( "text" to text ) ) + // 讯飞段尾:触发下游 RCSP runtime 立即整段下发。 + audioWriter?.markEnd() } override fun onSynthesisAudioGenerated( @@ -603,6 +613,8 @@ class DoubaoAstCallback( // if (finalAudio.isNotEmpty()) { // audioWriter?.write(finalAudio) // } + // 火山豆包段尾:触发下游 RCSP runtime 立即整段下发。 + audioWriter?.markEnd() } override fun onSessionError(sessionId: String, code: Int, message: String) { @@ -616,6 +628,8 @@ class DoubaoAstCallback( "code" to code ) ) + // 错误也要把当前 buffer 释放掉,避免残留。 + audioWriter?.markEnd() } override fun onFinalTranslatedText(sessionId: String, finalText: String) { @@ -725,6 +739,8 @@ class AliyunAstCallback( // if (finalAudio.isNotEmpty()) { // audioWriter?.write(finalAudio) // } + // 阿里百炼段尾:触发下游 RCSP runtime 立即整段下发。 + audioWriter?.markEnd() } override fun onSessionError(sessionId: String, code: Int, message: String) { @@ -738,6 +754,8 @@ class AliyunAstCallback( "code" to code ) ) + // 错误也要把当前 buffer 释放掉,避免残留。 + audioWriter?.markEnd() } override fun onFinalTranslatedText(sessionId: String, finalText: String) { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 505b256dc..444e3a941 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -80,12 +80,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, // 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。 @Volatile private var broadcastPeerTranslate: Boolean = false - private fun emitAstTtsFrame(leg: String, data: ByteArray) { + private fun emitAstTtsFrame(leg: String, data: ByteArray, isFinal: Boolean = false) { val sink = astTtsEventSink ?: return // EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略 mainHandler.post { try { - sink.success(mapOf("leg" to leg, "pcm" to data)) + sink.success(mapOf("leg" to leg, "pcm" to data, "final" to isFinal)) } catch (e: Exception) { FileLogger.e(tag, "发送 AST TTS 事件失败 leg=$leg: ${e.message}") } @@ -93,14 +93,25 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, } /** A 路 TTS(己方→对端语种)→ 让对端听 = SOURCE_E_SCO_UP_LINK */ - private val aPcmWriter: AudioWriter = AudioWriter { data -> - emitAstTtsFrame("A", data) + private val aPcmWriter: AudioWriter = object : AudioWriter { + override fun write(data: ByteArray) { + emitAstTtsFrame("A", data) + } + override fun markEnd() { + // 空 PCM + isFinal=true:通知下游当前 utterance 结束。 + emitAstTtsFrame("A", ByteArray(0), isFinal = true) + } } /** B 路 TTS(对端→己方语种)→ 让本机听 = SOURCE_E_SCO_DOWN_LINK; * 若 broadcastPeerTranslate=true 还额外通过手机外放叠加播放。 */ - private val bPcmWriter: AudioWriter = AudioWriter { data -> - emitAstTtsFrame("B", data) - if (broadcastPeerTranslate) callBPcmPlayer.feed(data) + private val bPcmWriter: AudioWriter = object : AudioWriter { + override fun write(data: ByteArray) { + emitAstTtsFrame("B", data) + if (broadcastPeerTranslate) callBPcmPlayer.feed(data) + } + override fun markEnd() { + emitAstTtsFrame("B", ByteArray(0), isFinal = true) + } } // 翻译(Volcano)相关 diff --git a/local_plugins/device_jieli/JIELI_SDK_COMMANDS.md b/local_plugins/device_jieli/JIELI_SDK_COMMANDS.md new file mode 100644 index 000000000..e487f2a0a --- /dev/null +++ b/local_plugins/device_jieli/JIELI_SDK_COMMANDS.md @@ -0,0 +1,242 @@ +# 杰理 RCSP SDK 指令 / 常量参考 + +> **来源**:`android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar` 中 +> `com.jieli.bluetooth.constant.Command` 等 class 的 `javap -p -constants` 反编译结果。 +> **生成时间**:2026-05-07 +> **用途**:核对项目里硬编码的 cmd / opcode / 模式 ID 是否与 SDK 内部定义一致,避免猜测。 + +--- + +## 一、RCSP CMD opcode(`com.jieli.bluetooth.constant.Command`) + +按 opcode 升序排列,**斜体** = 项目内已被 Kotlin 代码直接调用或回调命中。 + +| opcode (10) | opcode (16) | 常量名 | 用途速记 | +|---:|---:|---|---| +| 1 | 0x01 | CMD_DATA | 通用数据 | +| 2 | 0x02 | CMD_GET_TARGET_FEATURE_MAP | 拉取设备能力位图 | +| 3 | 0x03 | CMD_GET_TARGET_INFO | 拉取设备基础信息 | +| **4** | **0x04** | ***CMD_RECEIVE_SPEECH_START*** | **耳机语音助手按键唤醒:开始上推语音** | +| **5** | **0x05** | ***CMD_RECEIVE_SPEECH_STOP*** | **耳机语音助手按键松开:停止上推** | +| 6 | 0x06 | CMD_DISCONNECT_CLASSIC_BLUETOOTH | 断开经典蓝牙 | +| 7 | 0x07 | CMD_GET_SYS_INFO | 取系统信息 | +| 8 | 0x08 | CMD_SET_SYS_INFO | 设系统信息 | +| 9 | 0x09 | CMD_SYS_INFO_AUTO_UPDATE | 系统信息自动同步 | +| 10 | 0x0A | CMD_PHONE_CALL_REQUEST | 通话相关请求 | +| 11 | 0x0B | CMD_SWITCH_DEVICE_REQUEST | 多机切换请求 | +| 12 | 0x0C | CMD_START_FILE_BROWSE | 开始文件浏览 | +| 13 | 0x0D | CMD_STOP_FILE_BROWSE | 结束文件浏览 | +| 14 | 0x0E | CMD_FUNCTION | 功能控制(按键映射等) | +| 15 | 0x0F | CMD_LRC_GET_START | 开始拉取歌词 | +| 16 | 0x10 | CMD_LRC_GET_STOP | 停止拉取歌词 | +| 17 | 0x11 | CMD_LRC_PUSH_START_TTS | 推送 TTS 歌词 | +| 18 | 0x12 | CMD_START_PERIPHERALS_SCAN | 设备代扫外设开始 | +| 19 | 0x13 | CMD_UPDATE_PERIPHERALS_RESULT | 代扫结果上报 | +| 20 | 0x14 | CMD_STOP_PERIPHERALS_SCAN | 代扫结束 | +| 22 | 0x16 | CMD_START_FILE_TRANSFER | 文件传输开始 | +| 23 | 0x17 | CMD_STOP_FILE_TRANSFER | 文件传输结束 | +| 24 | 0x18 | CMD_NOTIFY_FILE_TRANSFER_OP | 文件传输操作通知 | +| 25 | 0x19 | CMD_SEARCH_DEVICE | 搜索设备 | +| 26 | 0x1A | CMD_EXTERNAL_FLASH_IO_CTRL | 外部 Flash IO 控制 | +| 27 | 0x1B | CMD_START_LARGE_FILE_TRANSFER | 大文件传输开始 | +| 28 | 0x1C | CMD_STOP_LARGE_FILE_TRANSFER | 大文件传输结束 | +| 29 | 0x1D | CMD_LARGE_FILE_TRANSFER_OP | 大文件传输操作 | +| 30 | 0x1E | CMD_CANCEL_LARGE_FILE_TRANSFER | 取消大文件传输 | +| 31 | 0x1F | CMD_FILE_BROWSE_DELETE | 文件浏览删除 | +| 32 | 0x20 | CMD_LARGE_FILE_TRANSFER_GET_NAME | 取大文件名 | +| 33 | 0x21 | CMD_NOTIFY_PREPARE_ENV | 准备环境通知 | +| 34 | 0x22 | CMD_FORMAT_DEVICE | 设备格式化 | +| 35 | 0x23 | CMD_DELETE_FILE_BY_NAME | 按名删文件 | +| 36 | 0x24 | CMD_READ_FILE_FROM_DEVICE | 从设备读文件 | +| 37 | 0x25 | CMD_RTC_EXPAND | RTC 扩展 | +| 38 | 0x26 | CMD_BATCH | 批量命令 | +| 39 | 0x27 | CMD_DEV_PARAM_EXTEND | 设备参数扩展 | +| 40 | 0x28 | CMD_SMALL_FILE_TRANSFER | 小文件传输 | +| 41 | 0x29 | CMD_READ_ERROR_MSG | 读错误信息 | +| 48 | 0x30 | CMD_DATA_TRANSFER | 通用数据传输 | +| 49 | 0x31 | CMD_QUERY_CONNECTED_PHONE_BT_INFO | 查询已连手机蓝牙信息 | +| 51 | 0x33 | CMD_PUBLIC_SETTINGS | 公共设置 | +| **52** | **0x34** | ***CMD_TRANSLATION_MODE*** | **翻译模式控制 + 上下行 PCM 数据通道** | +| 161 | 0xA1 | CMD_PUSH_MESSAGE_TO_DEVICE | APP → 设备消息推送 | +| 192 | 0xC0 | CMD_ADV_SETTINGS | 广告设置 | +| 193 | 0xC1 | CMD_ADV_GET_INFO | 广告信息查询 | +| 194 | 0xC2 | CMD_ADV_DEVICE_NOTIFY | 设备广告通知 | +| 195 | 0xC3 | CMD_ADV_NOTIFY_SETTINGS | 广告通知设置 | +| 196 | 0xC4 | CMD_ADV_DEV_REQUEST_OPERATION | 设备发起的请求 | +| 208 | 0xD0 | CMD_NOTIFY_DEVICE_APP_INFO | 通知设备 APP 信息 | +| 209 | 0xD1 | CMD_SETTINGS_COMMUNICATION_MTU | 协商通讯 MTU | +| **210** | **0xD2** | ***CMD_RECEIVE_SPEECH_CANCEL*** | **取消语音助手会话** | +| 212 | 0xD4 | CMD_GET_DEV_MD5 | 取设备 MD5 | +| 213 | 0xD5 | CMD_GET_LOW_LATENCY_SETTINGS | 低延时模式设置 | +| 214 | 0xD6 | CMD_GET_EXTERNAL_FLASH_MSG | 外部 Flash 信息 | +| 216 | 0xD8 | CMD_SET_DEVICE_STORAGE | 设备存储设置 | +| 217 | 0xD9 | CMD_GET_DEVICE_CONFIG_INFO | 设备配置信息 | +| 225 | 0xE1 | CMD_OTA_GET_DEVICE_UPDATE_FILE_INFO_OFFSET | OTA:取升级文件偏移 | +| 226 | 0xE2 | CMD_OTA_INQUIRE_DEVICE_IF_CAN_UPDATE | OTA:询问可否升级 | +| 227 | 0xE3 | CMD_OTA_ENTER_UPDATE_MODE | OTA:进入升级模式 | +| 228 | 0xE4 | CMD_OTA_EXIT_UPDATE_MODE | OTA:退出升级模式 | +| 229 | 0xE5 | CMD_OTA_SEND_FIRMWARE_UPDATE_BLOCK | OTA:发送固件块 | +| 230 | 0xE6 | CMD_OTA_GET_DEVICE_REFRESH_FIRMWARE_STATUS | OTA:取刷新状态 | +| 231 | 0xE7 | CMD_REBOOT_DEVICE | 重启设备 | +| 232 | 0xE8 | CMD_OTA_NOTIFY_UPDATE_CONTENT_SIZE | OTA:通知升级内容大小 | +| 240 | 0xF0 | CMD_CUSTOM | 厂商自定义 | +| 241 | 0xF1 | CMD_PHONE_NUMBER_PLAY_MODE | 手机号播报模式 | +| 255 | 0xFF | CMD_EXTRA_CUSTOM | 扩展自定义 | + +--- + +## 二、音频通道两条独立通路(关键) + +杰理 SDK 上行麦克风音频实际上有两条**互不关联**的命令通路,调用入口和回调机制完全不同: + +### 通路 A:原生语音助手(cmd=4 / 5 / 210) +- **类**:`com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl` +- **回调**:`OnRecordStateCallback`(多回调可共存) +- **触发模型**:固件以"耳机硬键唤醒"为前提才会持续上推 + - APP 主动调用 `startRecord()` 通常只能拿到 `RECORD_STATE_START` 回调, + `RECORD_STATE_WORKING` 帧推送依赖耳机端按键事件 / 厂商定制 +- **下行**:**无**。该通路只管上行 +- **不抢占 eSCO / A2DP** + +### 通路 B:翻译模式(cmd=52) +- **类**:`com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl` +- **接口**:`writeAudioData()` 上下行复用 +- **触发模型**:APP 调用 `enterMode()` 后固件**主动**持续上推 PCM + - `MODE_CALL_TRANSLATION (3)` + `STRATEGY_DEVICE_ALWAYS_RECORDING (1)` → 不依赖通话事件即可拿帧 + - `MODE_CALL_TRANSLATION_WITH_STEREO (6)` → 依赖真实通话事件(SCO_MIX),AI 助理场景**零帧** +- **下行**:通过 `feedTranslatedAudio()` 走 RCSP 注入回耳机 +- **会占用 SCO**,与 A2DP 媒体音乐互斥 + +> 实测结论(ColorOS + 当前耳机型号):通路 A 的 `startRecord` 仅有 `START` 回调, +> 持续帧不到货。**当前可拿上行 PCM 的唯一稳定通路是 B(cmd=52)+ MODE_CALL_TRANSLATION (3)**。 + +--- + +## 三、`TranslationMode` 模式常量 + +来自 `com.jieli.bluetooth.bean.translation.TranslationMode`: + +| 值 | 常量 | 上行 | 下行 | AI 助理可用性 | +|---:|---|:---:|:---:|---| +| 0 | MODE_IDLE | - | - | 关闭 | +| 1 | MODE_RECORD | ✓ | ✗ | 只录音,不能放 TTS | +| 2 | MODE_RECORDING_TRANSLATION | ✓ | ✗ | 只录音 + 字幕 | +| **3** | **MODE_CALL_TRANSLATION** | ✓ | ✓ | **当前唯一全双工可用** | +| 4 | MODE_AUDIO_TRANSLATION | ✓ | ✓ | 音视频文件翻译,需喂文件 PCM | +| 5 | MODE_FACE_TO_FACE_TRANSLATION | ✓ | ✓ | 面对面翻译,依赖双麦阵列 | +| **6** | **MODE_CALL_TRANSLATION_WITH_STEREO** | **✗** | ✓ | **AI 助理场景零帧 → 必须规避** | + +> ⚠️ 项目里 [TranslationModeIds](android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt) 与 SDK 编号**完全一致**, +> `MODE_CALL_TRANSLATION = 3`、`MODE_CALL_TRANSLATION_WITH_STEREO = 6`。 + +### RecordingStrategy(`TranslationMode` 内嵌) + +| 值 | 常量 | 含义 | +|---:|---|---| +| 0 | STRATEGY_CUSTOM_RECORDING | APP 自定录音节奏 | +| **1** | **STRATEGY_DEVICE_ALWAYS_RECORDING** | **设备持续录音上推(AI 助理场景需用此项)** | +| 2 | STRATEGY_DEVICE_AUTO_RECORDING | 设备 VAD 自动启停 | + +--- + +## 四、`AudioData.source`(cmd=52 上下行 PCM 包的来源标识) + +| 值 | 常量 | 含义 | +|---:|---|---| +| -1 | SOURCE_UNKNOWN | 未知 | +| 0 | SOURCE_FILE | 文件喂入 | +| 1 | SOURCE_DEVICE_MIC | 耳机麦 | +| 2 | SOURCE_PHONE_MIC | 手机麦 | +| 3 | SOURCE_E_SCO_UP_LINK | SCO 上行(通话场景) | +| 4 | SOURCE_E_SCO_DOWN_LINK | SCO 下行(通话场景) | +| 5 | SOURCE_M_SBC | mSBC 编码源 | +| 6 | SOURCE_E_SCO_MIX | SCO 上下行混音(mode=6 stereo 用) | + +> AI 助理上行帧实际取自 `SOURCE_DEVICE_MIC`(mode=3);mode=6 等待 `SOURCE_E_SCO_MIX`, +> 没有真实通话时此源不出包,所以零帧。 + +--- + +## 五、`RecordParam`(cmd=4 通路的录音参数) + +来自 `com.jieli.bluetooth.bean.record.RecordParam`: + +### voiceType +| 值 | 常量 | +|---:|---| +| 0 | VOICE_TYPE_PCM | +| 1 | VOICE_TYPE_SPEEX | +| **2** | **VOICE_TYPE_OPUS**(项目当前使用) | + +### sampleRate(**单位是 kHz**,注意不是 Hz) +| 值 | 常量 | 实际频率 | +|---:|---|---:| +| 8 | SAMPLE_RATE_8K | 8000 Hz | +| **16** | **SAMPLE_RATE_16K** | **16000 Hz**(项目当前使用) | + +### vadWay +| 值 | 常量 | +|---:|---| +| **0** | **VAD_WAY_DEVICE**(设备端 VAD,项目当前使用) | +| 1 | VAD_WAY_SDK(SDK 软 VAD) | + +--- + +## 六、`RecordState`(cmd=4 通路的回调状态机) + +| 字段 | 值 | 常量 | 说明 | +|---|---:|---|---| +| state | 0 | RECORD_STATE_IDLE | 录音结束 / 异常退出 | +| state | 1 | RECORD_STATE_START | 已应答 startRecord,**注意:不代表后续会有数据** | +| state | 2 | RECORD_STATE_WORKING | 数据持续上推中(payload 见 voiceData / voiceDataBlock) | +| reason | 0 | REASON_NORMAL | 正常 | +| reason | 1 | REASON_STOP | 主动 stop | + +字段: +- `voiceDataBlock`:分块裸数据 +- `voiceData`:单包数据 +- `recordParam`:本次录音参数回带 +- `message`:异常描述 + +--- + +## 七、音频编解码类型(`com.jieli.bluetooth.constant.Constants`) + +| 值 | 常量 | +|---:|---| +| 0 | AUDIO_TYPE_PCM | +| 1 | AUDIO_TYPE_SPEEX | +| **2** | **AUDIO_TYPE_OPUS**(项目当前使用) | +| 3 | AUDIO_TYPE_M_SBC | +| 4 | AUDIO_TYPE_JLA_V2 | + +--- + +## 八、项目现状速查表 + +| 功能 | 入口类 | 走的 cmd | 模式 ID | 备注 | +|---|---|---:|---:|---| +| 翻译 / 通话翻译 | TranslationFeature | 52 | 3 / 6 | 6 在 ColorOS+AI 场景零帧 | +| 录音(独立通路) | RecordOpImpl | 4 / 5 | - | 当前 AI 助理曾尝试此通路,**实测 WORKING 帧不到货** | +| AI 助理 | JieliAssistantPort | 见下文决策 | - | 上行需复用 cmd=52 + mode=3 | + +> **AI 助理音频路由结论**: +> - **上行**:必须走 cmd=52 + `MODE_CALL_TRANSLATION (3)` + `STRATEGY_DEVICE_ALWAYS_RECORDING (1)`, +> 规避 mode=6 +> - **下行**:APP 端 `AudioTrack` USAGE_MEDIA 直送(系统蓝牙路由 → A2DP), +> **不走** RCSP `feedTranslatedAudio`,避免 SCO/A2DP 抢通道 + +--- + +## 附:如何从 aar 自行核对 + +```bash +unzip jl_bluetooth_rcsp_*.aar -d /tmp/jl +unzip /tmp/jl/classes.jar -d /tmp/jl/classes +javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/constant/Command.class +javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/record/RecordParam.class +javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/record/RecordState.class +javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/translation/TranslationMode.class +javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/translation/AudioData.class +javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/constant/Constants.class | grep AUDIO_TYPE +``` diff --git a/local_plugins/device_jieli/android/build.gradle b/local_plugins/device_jieli/android/build.gradle index 24cfe9a8b..9756badd4 100644 --- a/local_plugins/device_jieli/android/build.gradle +++ b/local_plugins/device_jieli/android/build.gradle @@ -51,8 +51,8 @@ android { } dependencies { - // 杰理原生 SDK:library 模块只能 compileOnly(AGP 限制:library 不能直接 bundle 本地 aar)。 - // host app 需在 android/app/build.gradle.kts 里把同名 aar 用 implementation 引入作运行时依赖。 + // The JieLi AARs are compile-only here. The host app must redeclare them + // as runtime dependencies in its own build.gradle (see example/android/app). compileOnly fileTree(dir: 'libs', include: ['*.aar', '*.jar']) // device_plugin_interface 提供 DeviceCallTranslationPort 等通用接口, // 让 JieliCallTranslationPort 可以无 vendor 耦合地被 translate_server 消费。 diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt index ddec2cbbf..908960f6d 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt @@ -94,6 +94,11 @@ abstract class JieliEventAdapter : JieliEventListener { "speechEnd" -> onSpeechEnd(payload) "speechError" -> onSpeechError(payload) + "deviceRecordStart" -> onDeviceRecordStart(payload) + "deviceRecordAudio" -> onDeviceRecordAudio(payload) + "deviceRecordStop" -> onDeviceRecordStop(payload) + "deviceRecordError" -> onDeviceRecordError(payload) + "otaState" -> onOtaState(payload) "otaError" -> onOtaError(payload) @@ -144,6 +149,23 @@ abstract class JieliEventAdapter : JieliEventListener { open fun onSpeechError(payload: Map) {} + // ── 设备录音 ──────────────────────────────────────────────────────────── + + /** 录音上行已启动;payload: address, sampleRate, tsMs */ + open fun onDeviceRecordStart(payload: Map) {} + + /** + * 录音音频帧;payload: address, streamId("in.uplink"/"in.downlink"), + * pcm(ByteArray), sampleRate, channels, bitsPerSample, tsMs + */ + open fun onDeviceRecordAudio(payload: Map) {} + + /** 录音上行已停止;payload: address, tsMs */ + open fun onDeviceRecordStop(payload: Map) {} + + /** 录音错误;payload: address, code, message */ + open fun onDeviceRecordError(payload: Map) {} + /** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */ open fun onOtaState(payload: Map) {} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt index e2aa9ea7d..370357ba2 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt @@ -10,7 +10,10 @@ import com.jieli.jl_audio_decode.opus.model.OpusOption * 输入:从耳机收到的 OPUS 编码帧 * 输出:16kHz / mono PCM 流 * - * 立体声场景把 [channel] 设 2 + [packetSize] 设 80。 + * **packetSize 是单个 OPUS 帧的实际字节大小,不是"传输块大小"**。 + * 16k/16bit/mono/20ms ≈ 40B/帧,stereo ≈ 80B/帧。 + * 杰理 SDK demo 里用 200/80 这类"块"值会让 OpusManager 把多帧拼包当成 1 帧解, + * 直接丢掉后面的帧(mono 200B = 5 帧 → 只解 1 帧,丢 80%)。 */ class OpusStreamDecoder( private val channel: Int = 1, diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt index ffaf69c3c..67c532de7 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt @@ -32,7 +32,10 @@ class MethodRouter( "startScan" -> { val timeout = call.argument("timeoutMs") ?: 30000 - server.scanFeature.startScan(timeout) + val nameList = call.argument>("nameList") ?: emptyList() + val uuidList = call.argument>("uuidList") ?: emptyList() + val skipUnnamed = call.argument("skipUnnamed") ?: true + server.scanFeature.startScan(timeout, nameList, uuidList, skipUnnamed) .fold({ result.success(true) }, { result.error("SCAN_FAILED", it.message, null) }) } @@ -113,6 +116,8 @@ class MethodRouter( ?: return result.error("BAD_ARG", "modeId required", null) @Suppress("UNCHECKED_CAST") val args = (call.argument>("args") ?: emptyMap()) + // 设备录音与翻译互斥:启动翻译前先停录音 + if (server.deviceRecordFeature.isRecording()) server.deviceRecordFeature.stop() server.translationFeature.start(modeId, args) .fold({ result.success(true) }, { result.error("TRANSLATION_ERR", it.message, null) }) @@ -202,6 +207,44 @@ class MethodRouter( ) } + // ── AI 助理通路(JieliAssistantPort:MODE_RECORD + DEVICE_ALWAYS_RECORDING)── + "assistantStart" -> { + // 翻译 / 设备录音与本通路互斥(同一 RCSP TranslationImpl 状态机) + if (server.translationFeature.isWorking()) server.translationFeature.stop() + if (server.deviceRecordFeature.isRecording()) server.deviceRecordFeature.stop() + val ok = server.assistantBridge.start() + if (ok) result.success(true) + else result.error("ASSISTANT_START_ERR", "assistant bridge start failed", null) + } + + "assistantStop" -> { + server.assistantBridge.stop() + result.success(true) + } + + "assistantIsRunning" -> + result.success(server.assistantBridge.isRunning()) + + // ── 设备录音 ────────────────────────────────────────────────── + "deviceRecordStart" -> { + @Suppress("UNCHECKED_CAST") + val args = (call.argument>("args") ?: emptyMap()) + // 翻译与设备录音互斥:启动录音前先停翻译 + if (server.translationFeature.isWorking()) server.translationFeature.stop() + server.deviceRecordFeature.start(args) + .fold({ result.success(true) }, + { result.error("DEVICE_RECORD_ERR", it.message, null) }) + } + + "deviceRecordStop" -> { + server.deviceRecordFeature.stop() + result.success(true) + } + + "deviceRecordStatus" -> result.success( + mapOf("recording" to server.deviceRecordFeature.isRecording()) + ) + "otaStart" -> { val path = call.argument("firmwareFilePath") ?: return result.error("BAD_ARG", "firmwareFilePath required", null) diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt index 8536020de..13d9703b2 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt @@ -16,6 +16,7 @@ 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.record.DeviceRecordFeature import com.jielihome.jielihome.feature.translation.EventChannelAudioBridge import com.jielihome.jielihome.feature.translation.TranslationAudioBridge import com.jielihome.jielihome.feature.translation.TranslationFeature @@ -58,6 +59,10 @@ class JieliHomeServer private constructor() { private lateinit var btManager: JL_BluetoothManager + /** 模块内访问 btManager 用(如 [com.jielihome.jielihome.feature.assistant.JieliAssistantPort] + * 需要直接持有 [com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl] 单例时)。 */ + internal val internalBtManager: JL_BluetoothManager get() = btManager + private lateinit var bluetoothForwarder: BluetoothEventForwarder private lateinit var deviceInfoForwarder: DeviceInfoEventForwarder private lateinit var mediaForwarder: MediaEventForwarder @@ -77,6 +82,8 @@ class JieliHomeServer private constructor() { private set lateinit var otaFeature: OtaFeature private set + lateinit var deviceRecordFeature: DeviceRecordFeature + private set /** 默认翻译音频桥(EventChannel 透传到 Dart);宿主 native 想完全自管时可调 * [TranslationFeature.setBridge] 替换。 @@ -97,14 +104,21 @@ class JieliHomeServer private constructor() { JL_Log.configureLog(context, enableLog, enableLog) - val option = BluetoothOption.createDefaultOption().also { - it.isUseMultiDevice = multiDevice - it.isSkipNoNameDev = skipNoNameDev - // 清空杰理协议广播过滤头:默认值会让 SDK 只回调匹配杰理私有广播头的设备, - // 导致 onDiscovery 在普通 BLE 设备 / 测试场景下完全不触发。 - it.scanFilterData = "" - it.otaScanFilterData = "" - } + // 注意:扫描已不走 Jieli SDK(改为 ScanFeature 内的 BluetoothLeScanner), + // 所以 BluetoothOption 的 bleScanStrategy / scanFilterData 不再设置—— + // 它们仅影响 SDK 自己的 `btManager.scan()` 路径,留默认即可。 + val option = BluetoothOption.createDefaultOption() + option.setUseMultiDevice(multiDevice) + option.setSkipNoNameDev(skipNoNameDev) + // 关闭设备认证:SDK 默认 isUseDeviceAuth=true,会要求设备走 RCSP 授权流程 + // (`DeviceStatusManager.isAuthBtDevice`),未授权设备会被拒绝 → 表现为 + // 连上后 RCSP init 完成立刻断开。授权需要服务端发签名密钥的整套基础 + // 设施,目前没接,统一关掉。 + option.setUseDeviceAuth(true) + android.util.Log.d( + "JieliHome", + "init multiDev=$multiDevice skipNoName=$skipNoNameDev useAuth=false" + ) RCSPController.init(context, option) btManager = RCSPController.getInstance().bluetoothManager @@ -117,13 +131,14 @@ class JieliHomeServer private constructor() { customForwarder = CustomEventForwarder(btManager, dispatcher).also { it.attach() } // 业务功能层 - scanFeature = ScanFeature(btManager) + scanFeature = ScanFeature(context.applicationContext, dispatcher) 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) + deviceRecordFeature = DeviceRecordFeature(context.applicationContext, btManager, connectFeature, dispatcher) // 默认音频桥:EventChannel;injector 把 Dart 推过来的 PCM 路由回当前 ModeHandler defaultBridge = EventChannelAudioBridge(dispatcher) { _, streamId, pcm, fmt, isFinal -> @@ -144,8 +159,9 @@ class JieliHomeServer private constructor() { "connectionState" -> { val state = payload["state"] as? Int ?: return // StateCode.CONNECTION_DISCONNECT == 0 - if (state == 0 && translationFeature.isWorking()) { - translationFeature.stop() + if (state == 0) { + if (translationFeature.isWorking()) translationFeature.stop() + if (deviceRecordFeature.isRecording()) deviceRecordFeature.stop() } } "phoneCallStatus" -> { @@ -181,9 +197,39 @@ class JieliHomeServer private constructor() { com.jielihome.jielihome.feature.translation.JieliCallTranslationPort(this) } + /** + * 设备录音端口 —— 原生层直连入口。 + * 收集 [deviceRecordPort.audioFrames] 获取 PCM 帧,调用 [deviceRecordPort.start]/[stop] 控制录音。 + */ + val deviceRecordPort: com.jielihome.jielihome.feature.record.JieliDeviceRecordPort by lazy { + com.jielihome.jielihome.feature.record.JieliDeviceRecordPort(this) + } + + /** + * AI 助理能力端口([com.aiagent.device_plugin_interface.DeviceAssistantPort] + * 的杰理实现)。延迟创建;`assistant_server` native 编排器通过 + * [JieliNativeDeviceSession.assistantPort] 拿引用,与 [callTranslationPort] 语义独立。 + * + * 底层同样依赖 RCSP `MODE_CALL_TRANSLATION`,但接口上已完全隐藏 leg/翻译语义。 + */ + val assistantPort: com.jielihome.jielihome.feature.assistant.JieliAssistantPort by lazy { + com.jielihome.jielihome.feature.assistant.JieliAssistantPort(this) + } + + /** + * Flutter ↔ [assistantPort] 的桥接。让 Dart 侧可以通过 MethodChannel + * 启停 AI 助理的耳机录音通路(`MODE_RECORD` + `STRATEGY_DEVICE_ALWAYS_RECORDING`), + * 上行 PCM 帧通过 EventDispatcher 以 `assistantAudio` 事件推给 Dart。 + */ + val assistantBridge: com.jielihome.jielihome.feature.assistant.AssistantBridge by lazy { + com.jielihome.jielihome.feature.assistant.AssistantBridge(assistantPort, dispatcher) + } + fun shutdown() { if (!initialized) return + runCatching { assistantBridge.shutdown() } runCatching { otaFeature.cancel() } + runCatching { deviceRecordFeature.stop() } runCatching { translationFeature.stop() } runCatching { speechFeature.detach() } runCatching { bluetoothForwarder.detach() } diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt index 1c78c4b79..44b2c5704 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt @@ -1,20 +1,21 @@ 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、连接状态。 + * 蓝牙基础事件:适配器开关、配对、RCSP init、连接状态。 * 与设备「业务数据」(电量、音乐等)解耦。 + * + * 注意:扫描结果(`onDiscovery` / `onDiscoveryStatus`)已不再走这里—— + * [com.jielihome.jielihome.feature.ScanFeature] 直接用 Android 原生 + * `BluetoothLeScanner` 扫描,避免 Jieli SDK `ParseDataUtil.isFilterBleDevice` + * 的 strategy / flagContent 过滤导致看不到设备。SDK 自身若偶发回调 + * `onDiscovery`(例如内部自动重连等路径),这里**忽略**,避免重复上报。 */ class BluetoothEventForwarder( private val btManager: JL_BluetoothManager, @@ -22,58 +23,16 @@ class BluetoothEventForwarder( ) { 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) - ) - } + // SDK 内部仍会触发,但扫描状态由 ScanFeature 自行派发,这里不转发。 + override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) = Unit - @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) - ) - ) - } + // 同上:扫描结果由 ScanFeature 直接派发,这里忽略。 + override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) = Unit override fun onBondStatus(device: BluetoothDevice?, status: Int) { if (device == null) return @@ -101,13 +60,11 @@ class BluetoothEventForwarder( } 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) } diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt index 51c2917b5..39cbee16c 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt @@ -2,6 +2,7 @@ package com.jielihome.jielihome.event import android.bluetooth.BluetoothDevice import android.util.Base64 +import android.util.Log import com.jieli.bluetooth.impl.JL_BluetoothManager import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener import com.jielihome.jielihome.bridge.EventDispatcher @@ -17,8 +18,13 @@ class CustomEventForwarder( private val btManager: JL_BluetoothManager, private val dispatcher: EventDispatcher, ) { + companion object { + private const val TAG = "CustomEventForwarder" + } + private val listener = object : OnRcspEventListener() { override fun onExpandFunction(device: BluetoothDevice?, opCode: Int, payload: ByteArray?) { + Log.i(TAG, "[SDK<-DEV] onExpandFunction addr=${device?.address} opCode=$opCode(0x${String.format("%02X", opCode)}) payloadSize=${payload?.size ?: 0}") dispatcher.send( mapOf( "type" to "expandFunction", diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt index a71e7fbe1..8fc83dbbd 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt @@ -1,6 +1,7 @@ package com.jielihome.jielihome.feature import android.bluetooth.BluetoothDevice +import android.util.Log import com.jieli.bluetooth.bean.base.BaseError import com.jieli.bluetooth.bean.base.CommandBase import com.jieli.bluetooth.bean.command.custom.CustomCmd @@ -17,6 +18,10 @@ import com.jieli.bluetooth.utils.BluetoothUtil */ class CustomCmdFeature(private val btManager: JL_BluetoothManager) { + companion object { + private const val TAG = "CustomCmdFeature" + } + fun send( address: String, opCode: Int, @@ -27,14 +32,17 @@ class CustomCmdFeature(private val btManager: JL_BluetoothManager) { val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) ?: return onError(-1, "remote device not found") + Log.i(TAG, "[APP->SDK] sendRcspCommand addr=$address opCode=$opCode(0x${String.format("%02X", opCode)}) payloadSize=${payload.size}") 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 + Log.i(TAG, "[SDK<-DEV] sendRcspCommand onResponse addr=${dev?.address} opCode=$opCode dataSize=${r?.data?.size ?: 0}") onSuccess(r?.data) } override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { + Log.w(TAG, "[SDK<-DEV] sendRcspCommand onErrCode addr=${dev?.address} opCode=$opCode code=${err?.code} msg=${err?.message}") onError(err?.code ?: -2, err?.message) } }) diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt index ce57c80cf..47639b2df 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt @@ -1,19 +1,298 @@ package com.jielihome.jielihome.feature -import com.jieli.bluetooth.constant.ErrorCode -import com.jieli.bluetooth.impl.JL_BluetoothManager +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.ParcelUuid +import android.util.Log +import com.jieli.bluetooth.bean.BluetoothOption +import com.jieli.bluetooth.utils.ParseDataUtil +import com.jielihome.jielihome.bridge.EventDispatcher +import java.util.UUID -class ScanFeature(private val btManager: JL_BluetoothManager) { +/** + * BLE 扫描入口。 + * + * 不再走 Jieli SDK 的 `btManager.scan()`(受其内置 ParseDataUtil 的 + * flagContent / strategy 限制),改为直接用 Android 原生 + * [BluetoothLeScanner] + [ScanFilter] 扫描,复合过滤: + * + * - [nameList]:对 `BluetoothDevice.name` 精确匹配(忽略大小写),空 = 不按名过滤 + * - [uuidList]:构造 `ScanFilter.setServiceUuid(...)`,由 OS 过滤;空 = 不按 UUID 过滤 + * + * 两者同时设置时按 (UUID 由 OS 过滤后) AND (名字命中) 上报。 + * + * 由于不再依赖 Jieli SDK 的 `BleScanMessage`,发出的 `deviceFound` 事件里 + * `edrAddr` / `deviceType` / `connectWay` 三个字段为 null —— 后续连接由 + * [com.jielihome.jielihome.feature.ConnectFeature] 用默认值(deviceType=-1, + * connectWay=0)尝试,可在调用方通过 connectWay override 指定。 + */ +class ScanFeature( + private val context: Context, + private val dispatcher: EventDispatcher, +) { - fun startScan(timeoutMs: Int): Result { - val code = btManager.scan(timeoutMs) - return if (code == ErrorCode.ERR_NONE) Result.success(Unit) - else Result.failure(IllegalStateException("scan returned $code")) + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * 用于把 Android 原生 ScanRecord 字节交给 Jieli SDK 解析的可复用 option。 + * `bleScanStrategy=3` + 空 flagContent → 跳过 SDK 的 flagContent.equals 校验, + * 任何 mode-3 可解析的 Jieli 广播都返回 BleScanMessage(含 edrAddr/pid 等)。 + */ + private val parserOption: BluetoothOption by lazy { + BluetoothOption.createDefaultOption().apply { + setBleScanStrategy(3) + setScanFilterData("") + } + } + + @Volatile private var scanCallback: ScanCallback? = null + @Volatile private var scanning: Boolean = false + private var stopRunnable: Runnable? = null + + @Volatile private var currentNameList: List = emptyList() + @Volatile private var currentSkipUnnamed: Boolean = true + + @SuppressLint("MissingPermission") + fun startScan( + timeoutMs: Int, + nameList: List = emptyList(), + uuidList: List = emptyList(), + skipUnnamed: Boolean = true, + ): Result { + val scanner = leScanner() + ?: return Result.failure(IllegalStateException("BLE scanner unavailable")) + + // 已在扫描:先停旧的(替换扫描参数) + if (scanning) doStop(scanner) + + currentNameList = nameList.filter { it.isNotEmpty() } + currentSkipUnnamed = skipUnnamed + + val filters = mutableListOf() + uuidList.forEach { raw -> + val uuid = parseUuid(raw) ?: return@forEach + filters.add( + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(uuid)) + .build() + ) + Log.d(TAG, "add ScanFilter serviceUuid=$uuid") + } + + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setReportDelay(0) + .build() + + val cb = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + handleScanResult(result) + } + + override fun onBatchScanResults(results: MutableList) { + for (r in results) handleScanResult(r) + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "onScanFailed errorCode=$errorCode") + scanning = false + scanCallback = null + stopRunnable?.let { mainHandler.removeCallbacks(it) } + stopRunnable = null + dispatcher.send( + mapOf("type" to "scanStatus", "ble" to true, "started" to false) + ) + } + } + + return try { + // filters 为空时传 null("扫描所有 BLE 广播") + scanner.startScan(filters.takeIf { it.isNotEmpty() }, settings, cb) + scanCallback = cb + scanning = true + Log.d( + TAG, + "startScan timeoutMs=$timeoutMs filters=${filters.size} " + + "nameList=$currentNameList" + ) + dispatcher.send( + mapOf("type" to "scanStatus", "ble" to true, "started" to true) + ) + + val stop = Runnable { + Log.d(TAG, "scan timeout reached, stopping") + stopScan() + } + stopRunnable = stop + mainHandler.postDelayed(stop, timeoutMs.toLong()) + + Result.success(Unit) + } catch (e: Exception) { + Log.e(TAG, "startScan exception", e) + scanning = false + Result.failure(e) + } } fun stopScan() { - btManager.stopScan() + val scanner = leScanner() ?: return + doStop(scanner) + } + + @SuppressLint("MissingPermission") + private fun doStop(scanner: BluetoothLeScanner) { + val cb = scanCallback + if (cb != null) { + try { + scanner.stopScan(cb) + } catch (e: Exception) { + Log.w(TAG, "stopScan exception: ${e.message}") + } + } + scanCallback = null + stopRunnable?.let { mainHandler.removeCallbacks(it) } + stopRunnable = null + scanning = false + currentNameList = emptyList() + dispatcher.send( + mapOf("type" to "scanStatus", "ble" to true, "started" to false) + ) } - fun isScanning(): Boolean = btManager.bluetoothOperation.isScanning + fun isScanning(): Boolean = scanning + + @SuppressLint("MissingPermission") + private fun handleScanResult(result: ScanResult) { + val device = result.device + val name = try { + device.name ?: result.scanRecord?.deviceName + } catch (_: SecurityException) { null } + + // 未命名过滤(默认 ON):drop 掉 name 为空的环境噪声 + if (currentSkipUnnamed && name.isNullOrEmpty()) return + + val names = currentNameList + if (names.isNotEmpty()) { + if (name.isNullOrEmpty() || + names.none { it.equals(name, ignoreCase = true) } + ) return + } + + // 用 Jieli SDK 的 ParseDataUtil 解一次原始 ScanRecord,能解出来就拿到 + // edrAddr / pid / deviceType / connectWay 等私有字段;解不出来就当作普通 + // BLE 设备(这些字段为 null,连接时走 BLE-only fallback)。 + val rawBytes = result.scanRecord?.bytes + val msg = if (rawBytes != null) { + try { ParseDataUtil.isFilterBleDevice(parserOption, rawBytes) } + catch (e: Throwable) { + Log.w(TAG, "parseBleScanMessage failed: ${e.message}") + null + } + } else null + + // 解析出给 Flutter 侧 "广播详情" 弹窗用的结构化字段。 + val adRecords = if (rawBytes != null) parseAdRecords(rawBytes) else emptyList() + val advRecordsPayload = adRecords.map { r -> + mapOf( + "len" to "%02d".format(r.len), + "type" to "0x%02X".format(r.type), + "data" to "0x" + r.data.toUpperHex(), + ) + } + val advFlags = adRecords.firstOrNull { it.type == 0x01 } + ?.data?.takeIf { it.isNotEmpty() } + ?.let { it[0].toInt() and 0xFF } + val mfrRecord = adRecords.firstOrNull { it.type == 0xFF } + val manufacturerCompanyId = mfrRecord?.data?.takeIf { it.size >= 2 }?.let { + ((it[1].toInt() and 0xFF) shl 8) or (it[0].toInt() and 0xFF) + } + val manufacturerData = mfrRecord?.data?.takeIf { it.size > 2 } + ?.let { it.copyOfRange(2, it.size).toUpperHex() } + val serviceUuidStrings = result.scanRecord?.serviceUuids + ?.map { it.uuid.toString().uppercase() } ?: emptyList() + + dispatcher.send( + mapOf( + "type" to "deviceFound", + "name" to (name ?: ""), + "address" to device.address, + "edrAddr" to msg?.edrAddr, + "deviceType" to msg?.deviceType, + "connectWay" to msg?.connectWay, + "rssi" to result.rssi, + "rawAdv" to rawBytes?.toUpperHex(), + "advRecords" to advRecordsPayload, + "advFlags" to advFlags, + "manufacturerCompanyId" to manufacturerCompanyId, + "manufacturerData" to manufacturerData, + "serviceUuids" to serviceUuidStrings, + ) + ) + } + + private fun leScanner(): BluetoothLeScanner? { + val mgr = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + ?: return null + return mgr.adapter?.bluetoothLeScanner + } + + /** 单个 AD (Advertising Data) 结构:len 为 "type + data" 的字节数,不含 len 字节自身。 */ + private data class AdRecord(val len: Int, val type: Int, val data: ByteArray) + + /** + * 按 BLE Core Spec §11(AD structure)解析 ScanRecord 字节。 + * 每条记录:`[len:1][type:1][data:len-1]`;遇到 len==0 视为 padding 结束。 + */ + private fun parseAdRecords(bytes: ByteArray): List { + val out = mutableListOf() + var i = 0 + while (i < bytes.size) { + val len = bytes[i].toInt() and 0xFF + if (len == 0) break + if (i + len >= bytes.size) break // 畸形包:data 越界,丢弃尾部 + val type = bytes[i + 1].toInt() and 0xFF + val data = bytes.copyOfRange(i + 2, i + 1 + len) + out.add(AdRecord(len, type, data)) + i += 1 + len + } + return out + } + + private fun ByteArray.toUpperHex(): String = + joinToString("") { "%02X".format(it) } + + private fun parseUuid(raw: String): UUID? { + val s = raw.trim() + return try { + when { + s.length == 4 -> UUID.fromString("0000$s-0000-1000-8000-00805f9b34fb") + s.length == 8 -> UUID.fromString("$s-0000-1000-8000-00805f9b34fb") + s.length == 36 -> UUID.fromString(s) + s.length == 32 -> UUID.fromString( + "${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}" + + "-${s.substring(16, 20)}-${s.substring(20, 32)}" + ) + else -> { + Log.w(TAG, "invalid UUID format: $raw") + null + } + } + } catch (e: IllegalArgumentException) { + Log.e(TAG, "parseUuid failed: $raw", e) + null + } + } + + private companion object { + const val TAG = "JieliScan" + } } diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt new file mode 100644 index 000000000..300e53868 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt @@ -0,0 +1,143 @@ +package com.jielihome.jielihome.feature.assistant + +import android.util.Log +import com.aiagent.device_plugin_interface.AssistantAudioFormat +import com.jielihome.jielihome.bridge.EventDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicLong + +/** + * Flutter ↔ [JieliAssistantPort] 的桥接。把 SharedFlow 形态的音频帧 / 错误流 + * 转成 [EventDispatcher] 事件(`assistantAudio` / `assistantError`),让 + * Dart 侧可以通过 EventChannel 实时收到耳机麦上行的 16 kHz PCM。 + * + * `start` 内部调用 [JieliAssistantPort.enter](`MODE_RECORD` + + * `STRATEGY_DEVICE_ALWAYS_RECORDING`),耳机会持续推 OPUS,由 Port 解码成 + * 16 kHz/16 bit/mono/20 ms PCM。`stop` 调用 [JieliAssistantPort.exit] 并 + * 取消订阅。两者通过 `synchronized` 互斥,重复调用安全。 + */ +class AssistantBridge( + private val port: JieliAssistantPort, + private val dispatcher: EventDispatcher, +) { + + companion object { + private const val TAG = "AssistantBridge" + } + + private val scopeJob = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + scopeJob) + + @Volatile private var audioJob: Job? = null + @Volatile private var errorJob: Job? = null + @Volatile private var running = false + + /** 调试:每秒打一次 PCM 转发统计,方便确认音频是否真正流到 Dart */ + private val fwdCount = AtomicLong(0) + @Volatile private var lastReportMs = 0L + + @Synchronized + fun start(): Boolean { + if (running) { + Log.w(TAG, "start: already running, ignored") + return true + } + + // 先订阅 Flow,再 enter(),避免首帧落在订阅者建立之前被 SharedFlow 丢掉 + // (JieliAssistantPort 用 replay=0 + DROP_OLDEST) + audioJob = port.audioFrames.onEach { frame -> + fwdCount.incrementAndGet() + val now = System.currentTimeMillis() + if (now - lastReportMs >= 1000L) { + Log.i( + TAG, + "fwd PCM stats (last 1s): frames=${fwdCount.getAndSet(0)} bytes/frame=${frame.bytes.size} sr=${frame.sampleRate}", + ) + lastReportMs = now + } + dispatcher.send( + mapOf( + "type" to "assistantAudio", + "encoding" to "pcm16", + "sampleRate" to frame.sampleRate, + "channels" to frame.channels, + "bitsPerSample" to 16, + "sequence" to frame.sequence, + "tsMs" to (frame.timestampUs / 1000L), + "pcm" to frame.bytes, + ) + ) + }.launchIn(scope) + + errorJob = port.errors.onEach { err -> + Log.w(TAG, "port.error code=${err.code} msg=${err.message}") + dispatcher.send( + mapOf( + "type" to "assistantError", + "code" to err.code, + "message" to err.message, + ) + ) + }.launchIn(scope) + + try { + port.enter(AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS) + } catch (t: Throwable) { + Log.e(TAG, "start: port.enter failed: ${t.message}", t) + runCatching { audioJob?.cancel() } + runCatching { errorJob?.cancel() } + audioJob = null + errorJob = null + dispatcher.send( + mapOf( + "type" to "assistantError", + "code" to "device.assistant.enter_failed", + "message" to (t.message ?: t.javaClass.simpleName), + ) + ) + return false + } + + running = true + dispatcher.send( + mapOf( + "type" to "assistantStart", + "sampleRate" to 16000, + "tsMs" to System.currentTimeMillis(), + ) + ) + Log.i(TAG, "start: assistant port entered, audio bridge active") + return true + } + + @Synchronized + fun stop() { + if (!running) return + running = false + runCatching { audioJob?.cancel() } + runCatching { errorJob?.cancel() } + audioJob = null + errorJob = null + runCatching { port.exit() } + dispatcher.send( + mapOf( + "type" to "assistantEnd", + "tsMs" to System.currentTimeMillis(), + ) + ) + Log.i(TAG, "stop: assistant port exited, audio bridge inactive") + } + + fun isRunning(): Boolean = running + + fun shutdown() { + stop() + runCatching { scope.cancel() } + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt new file mode 100644 index 000000000..87f35cf6c --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt @@ -0,0 +1,276 @@ +package com.jielihome.jielihome.feature.assistant + +import android.bluetooth.BluetoothDevice +import android.util.Log +import com.aiagent.device_plugin_interface.AssistantAudioCodec +import com.aiagent.device_plugin_interface.AssistantAudioFormat +import com.aiagent.device_plugin_interface.AssistantAudioFrame +import com.aiagent.device_plugin_interface.AssistantError +import com.aiagent.device_plugin_interface.AssistantPlaybackFrame +import com.aiagent.device_plugin_interface.DeviceAssistantPort +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.rcsp.translation.TranslationImpl +import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback +import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback +import com.jielihome.jielihome.audio.LocalPlayer +import com.jielihome.jielihome.audio.OpusStreamDecoder +import com.jielihome.jielihome.core.JieliHomeServer +import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import java.util.concurrent.atomic.AtomicLong + +/** + * `DeviceAssistantPort` 的杰理实现 —— **录音模式(MODE_RECORD=1)通路**版。 + * + * # 设计要点 + * 杰理 demo 里没有「AI 助理」这种产品形态,但其 [TranslationImpl.enterMode] + + * `MODE_RECORD`(=1) + `STRATEGY_DEVICE_ALWAYS_RECORDING`(=1) 能让耳机持续上推 + * OPUS 音频帧到 [TranslationCallback.onReceiveAudioData],完全满足 AI 助理的 + * 上行采集需求,我们就借这条录音模式通路: + * - 上行采集:耳机麦 → 耳机固件 OPUS 编码 → onReceiveAudioData → OpusStreamDecoder + * → 16kHz PCM → SharedFlow 抛给编排器。**mode 字段 = 1,与 demo 行为一致**。 + * - 下行回灌:TTS PCM 写 [LocalPlayer](AudioTrack + USAGE_MEDIA),由 Android + * 蓝牙路由走 A2DP 通道送回耳机扬声器。不走 RCSP 下发,避免与 A2DP 媒体音频抢通道。 + * + * # 与 [com.jielihome.jielihome.feature.translation.JieliCallTranslationPort] 的关系 + * 两者都走 TranslationImpl,但 mode 不同、策略不同: + * - CallTranslation:mode=3/6(通话翻译),依赖真实 eSCO 通话 + * - 本类:mode=1(录音),纯上行,不依赖任何通话事件 + * 两者不会同时工作(enterMode 互斥)。 + * + * # 格式 + * 仅放开 PCM_S16LE / 16 kHz / mono / 20 ms。 + * 上行 SDK 推 OPUS,由 [OpusStreamDecoder] 解码成 16 kHz PCM 推到 Flow。 + * 下行 PCM 直接写 AudioTrack。 + * + * # 线程安全 + * `enter` / `exit` / `reportPlayback` 通过 `synchronized(this)` 互斥; + * SharedFlow.tryEmit / AudioTrack.write 自身线程安全; + * SDK 回调线程不固定,所有共享状态用 @Volatile 标记。 + */ +class JieliAssistantPort( + private val server: JieliHomeServer, +) : DeviceAssistantPort { + + companion object { + private const val TAG = "JieliAssistantPort" + private val PCM_16K_MONO_20MS = AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS + } + + private val _audioFrames = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 128, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val _errors = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Volatile private var entered = false + @Volatile private var device: BluetoothDevice? = null + @Volatile private var decoder: OpusStreamDecoder? = null + @Volatile private var player: LocalPlayer? = null + @Volatile private var translationImpl: TranslationImpl? = null + @Volatile private var translationCallback: TranslationCallback? = null + + /** 调试统计:每秒打一次 SDK PCM 帧产出量 */ + private val upCount = AtomicLong(0) + @Volatile private var lastReportMs = 0L + /** 首帧 flag:首次 onModeChange / onReceiveAudioData / 首次 PCM 帧发射都各打一条 INFO */ + @Volatile private var rxFirstModeChangeLogged = false + @Volatile private var rxFirstAudioLogged = false + /** 上行帧自增序号 */ + private val seqGen = AtomicLong(0) + + override fun supportedSourceFormats(): Set = setOf(PCM_16K_MONO_20MS) + override fun supportedSinkFormats(): Set = setOf(PCM_16K_MONO_20MS) + + override val audioFrames: Flow = _audioFrames.asSharedFlow() + override val errors: Flow = _errors.asSharedFlow() + + @Synchronized + override fun enter(sourceFormat: AssistantAudioFormat) { + require(sourceFormat == PCM_16K_MONO_20MS) { + "JieliAssistantPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)" + } + check(!entered) { "device.assistant.busy" } + + val dev = server.connectFeature.connectedDevice() + ?: throw IllegalStateException("device.assistant.no_device: no connected device") + device = dev + + // 1. 启动本地 A2DP 播放器(接收 TTS PCM) + val lp = LocalPlayer(sampleRate = 16000, channels = 1).also { it.start() } + player = lp + + // 2. 启动 Opus 解码器:耳机上推的录音流是 OPUS,解码后丢回 SharedFlow。 + // packetSize 必须是耳机单个 OPUS 帧的真实大小(16k/16bit/20ms 约 40B), + // 不是 SDK 里那种"块大小"。配 200 时 OpusManager 把 5 个拼接帧当 1 帧解, + // 1s 只出 10 帧 PCM(应有 50),丢 80% 数据 —— 与"AI 助理 10 fps"问题完全对应。 + val od = OpusStreamDecoder( + channel = 1, + packetSize = 40, + sampleRate = 16000, + onPcm = { pcm -> emitUplinkFrame(pcm) }, + onError = { c, m -> + _errors.tryEmit(AssistantError( + code = "device.decoder_failed", + message = "opus: code=$c msg=${m ?: ""}", + )) + }, + ).also { it.start() } + decoder = od + + // 3. 构造 TranslationImpl + 前置校验 + val impl = TranslationImpl(server.internalBtManager, NoOpAITranslationApi(), dev) + if (!impl.isInit) { + runCatching { impl.destroy() } + cleanup() + throw IllegalStateException("device.assistant.rcsp_not_init: RCSP not init for ${dev.address}") + } + if (!impl.isSupportTranslation) { + runCatching { impl.destroy() } + cleanup() + throw IllegalStateException("device.assistant.not_supported: device does not support translation") + } + + // 4. 注册 TranslationCallback —— 关键回调:onReceiveAudioData 拿 OPUS 帧 + val cb = object : TranslationCallback { + override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { + if (!rxFirstModeChangeLogged) { + rxFirstModeChangeLogged = true + Log.i(TAG, "[SDK<-DEV] onModeChange FIRST addr=${d.address} mode=${m.mode} type=${m.audioType} ch=${m.channel} sr=${m.sampleRate} strategy=${m.recordingStrategy}") + } else { + Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} strategy=${m.recordingStrategy}") + } + if (m.mode == TranslationMode.MODE_IDLE) { + // 耳机主动退出 RECORD 模式(按键 / 超时 / 被抢占) + _errors.tryEmit(AssistantError( + code = "device.mode_exited", + message = "headset exited MODE_RECORD → MODE_IDLE", + )) + } + } + + override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { + val payload = data.audioData + if (payload == null || payload.isEmpty()) { + Log.w(TAG, "[SDK<-DEV] onReceiveAudioData null/empty payload source=${data.source} type=${data.type}") + return + } + if (!rxFirstAudioLogged) { + rxFirstAudioLogged = true + Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=${payload.size}") + } + when (data.type) { + Constants.AUDIO_TYPE_PCM -> emitUplinkFrame(payload) + Constants.AUDIO_TYPE_OPUS -> decoder?.feedEncoded(payload) + else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown type=${data.type} (skip)") + } + } + + override fun onError(d: BluetoothDevice, code: Int, msg: String) { + Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") + _errors.tryEmit(AssistantError( + code = "device.translation_error", + message = "code=$code msg=$msg", + )) + } + } + impl.addTranslationCallback(cb) + translationImpl = impl + translationCallback = cb + + // 5. 下发 enterMode(MODE_RECORD=1, OPUS, ch=1, 16k, STRATEGY_DEVICE_ALWAYS_RECORDING) + // - mode=1:与 demo 录音模式对齐,耳机 RCSP 状态机进入 MODE_RECORD + // - STRATEGY_DEVICE_ALWAYS_RECORDING=1:耳机固件自主采集并持续上推,APP 不用手机麦 + val sdkMode = TranslationMode( + TranslationMode.MODE_RECORD, + Constants.AUDIO_TYPE_OPUS, + 1, + 16000, + ).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING) + Log.i(TAG, "[APP->SDK] enterMode addr=${dev.address} mode=${sdkMode.mode}(MODE_RECORD) type=${sdkMode.audioType}(OPUS) ch=${sdkMode.channel} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(DEVICE_ALWAYS_RECORDING)") + impl.enterMode(sdkMode, cb) + + entered = true + Log.d(TAG, "entered assistant mode (MODE_RECORD=1, DEVICE_ALWAYS_RECORDING, A2DP playback)") + } + + private fun emitUplinkFrame(pcm: ByteArray) { + upCount.incrementAndGet() + val now = System.currentTimeMillis() + if (now - lastReportMs >= 1000L) { + Log.d(TAG, "uplink PCM stats (last 1s): frames=${upCount.getAndSet(0)} bytes/frame=${pcm.size}") + lastReportMs = now + } + val frame = AssistantAudioFrame( + codec = AssistantAudioCodec.PCM_S16LE, + sampleRate = 16000, + channels = 1, + bytes = pcm, + sequence = seqGen.incrementAndGet(), + timestampUs = now * 1000L, + ) + if (!_audioFrames.tryEmit(frame)) { + Log.w(TAG, "audioFrames buffer overflow; dropped seq=${frame.sequence}") + } + } + + @Synchronized + override fun reportPlayback(frame: AssistantPlaybackFrame) { + check(entered) { "device.assistant.not_active" } + require(frame.codec == AssistantAudioCodec.PCM_S16LE) { + "JieliAssistantPort only accepts PCM_S16LE for sink (got ${frame.codec})" + } + // TTS PCM 写入本地 A2DP 播放器,由系统蓝牙路由送回耳机扬声器 + player?.feed(frame.bytes) + } + + @Synchronized + override fun exit() { + if (!entered) return + entered = false + cleanup() + Log.d(TAG, "exited assistant mode") + } + + /** 释放 enterMode / decoder / player / callback;可被 enter 失败路径调用,幂等 */ + private fun cleanup() { + val impl = translationImpl + val cb = translationCallback + val addr = device?.address + if (impl != null) { + runCatching { + Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORD}") + impl.exitMode(object : OnRcspActionCallback { + override fun onSuccess(d: BluetoothDevice?, t: Int?) { + Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") + } + override fun onError(d: BluetoothDevice?, e: com.jieli.bluetooth.bean.base.BaseError?) { + Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${e?.code} msg=${e?.message}") + } + }) + } + if (cb != null) runCatching { impl.removeTranslationCallback(cb) } + runCatching { impl.destroy() } + } + runCatching { decoder?.stop() } + runCatching { player?.stop() } + translationImpl = null + translationCallback = null + decoder = null + player = null + device = null + // 复位首帧标志,下次 enter 重新打点 + rxFirstModeChangeLogged = false + rxFirstAudioLogged = false + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt new file mode 100644 index 000000000..6c60e4350 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt @@ -0,0 +1,141 @@ +package com.jielihome.jielihome.feature.record + +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.bridge.EventDispatcher +import com.jielihome.jielihome.feature.ConnectFeature +import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime +import java.io.File + +/** + * 设备录音功能。 + * + * 通过 RCSP [TranslationMode.MODE_CALL_TRANSLATION] + STRATEGY_DEVICE_ALWAYS_RECORDING + * 让耳机持续上推双通道 PCM: + * - [AudioData.SOURCE_E_SCO_UP_LINK] → streamId = "in.uplink" (本端/耳机麦克风) + * - [AudioData.SOURCE_E_SCO_DOWN_LINK] → streamId = "in.downlink" (对端/通话对方) + * + * 事件类型: + * - `deviceRecordStart` — 上行启动成功 + * - `deviceRecordAudio` — PCM 帧(含 streamId 区分上下行) + * - `deviceRecordStop` — 上行已停止 + * - `deviceRecordError` — 错误 + * + * 与 [com.jielihome.jielihome.feature.translation.TranslationFeature] 互斥: + * 两者都调用 SDK enterMode,同时只能有一个活跃。 + * 调用方(MethodRouter)负责在启动前 stop 另一个。 + */ +class DeviceRecordFeature( + private val context: Context, + private val btManager: JL_BluetoothManager, + private val connectFeature: ConnectFeature, + private val dispatcher: EventDispatcher, +) { + @Volatile private var runtime: RcspTranslationRuntime? = null + @Volatile private var working = false + @Volatile private var deviceAddress: String? = null + + fun isRecording(): Boolean = working + + /** + * 启动设备录音上行。 + * + * @param args 可选参数: + * - `address` String 目标设备 MAC;省略则取当前已连设备 + * - `sampleRate` Int 采样率(Hz),默认 16000 + */ + fun start(args: Map): Result { + if (working) return Result.failure(IllegalStateException("already recording")) + + val address = args["address"] as? String + val device = address?.let { connectFeature.deviceByAddress(it) } + ?: connectFeature.connectedDevice() + ?: return Result.failure(IllegalStateException("no connected device; pass args.address or connect first")) + + val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + + val sdkMode = TranslationMode( + TranslationMode.MODE_CALL_TRANSLATION, + Constants.AUDIO_TYPE_OPUS, + 1, + sampleRate, + ).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING) + + val rt = RcspTranslationRuntime( + btManager = btManager, + device = device, + mode = sdkMode, + tempDir = File(context.cacheDir, "jieli_device_record_tmp"), + onPcm = { source, pcm -> + val streamId = when (source) { + AudioData.SOURCE_E_SCO_UP_LINK -> "in.uplink" + AudioData.SOURCE_E_SCO_DOWN_LINK -> "in.downlink" + else -> return@RcspTranslationRuntime + } + dispatcher.send( + mapOf( + "type" to "deviceRecordAudio", + "address" to device.address, + "streamId" to streamId, + "sampleRate" to sampleRate, + "channels" to 1, + "bitsPerSample" to 16, + "tsMs" to System.currentTimeMillis(), + "pcm" to pcm, + ) + ) + }, + onError = { code, msg -> + dispatcher.send( + mapOf( + "type" to "deviceRecordError", + "address" to device.address, + "code" to code, + "message" to msg, + ) + ) + }, + ) + + return rt.start().fold( + onSuccess = { + runtime = rt + working = true + deviceAddress = device.address + dispatcher.send( + mapOf( + "type" to "deviceRecordStart", + "address" to device.address, + "sampleRate" to sampleRate, + "tsMs" to System.currentTimeMillis(), + ) + ) + Result.success(Unit) + }, + onFailure = { err -> + rt.stop() + Result.failure(err) + }, + ) + } + + /** 停止设备录音上行。幂等:未录音时无副作用。 */ + fun stop() { + if (!working) return + working = false + val addr = deviceAddress + deviceAddress = null + runtime?.stop() + runtime = null + dispatcher.send( + mapOf( + "type" to "deviceRecordStop", + "address" to addr, + "tsMs" to System.currentTimeMillis(), + ) + ) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt new file mode 100644 index 000000000..1d3dc1a84 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt @@ -0,0 +1,151 @@ +package com.jielihome.jielihome.feature.record + +import android.util.Log +import com.jielihome.jielihome.api.JieliEventAdapter +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 + +/** 录音音频帧(已解码为 PCM_S16LE)。*/ +data class DeviceRecordFrame( + val address: String, + /** [DeviceRecordFeature] 的 "in.uplink"(本端)或 "in.downlink"(对端)*/ + val streamId: String, + val pcm: ByteArray, + val sampleRate: Int, + val channels: Int = 1, + val bitsPerSample: Int = 16, + val tsMs: Long, +) { + // ByteArray 默认 equals/hashCode 按内容比,这里改为引用比避免大帧逐字节比 + override fun equals(other: Any?): Boolean = this === other + override fun hashCode(): Int = System.identityHashCode(this) +} + +/** 录音错误。 */ +data class DeviceRecordError(val address: String?, val code: Int, val message: String?) + +/** + * 设备录音端口 —— 原生层编排器的直连入口。 + * + * 与 [com.jielihome.jielihome.feature.translation.JieliCallTranslationPort] 对称: + * - [start] 启动耳机音频上行 + * - [audioFrames] 是 Kotlin Flow,收集即可获取上行/下行 PCM 帧 + * - [stop] 停止上行 + * + * Flutter 路径(EventChannel)不受影响:同一帧会同时推给 native flow 和 Dart EventChannel。 + * + * # 典型用法 + * ```kotlin + * val port = server.deviceRecordPort + * val job = scope.launch { + * port.audioFrames.collect { frame -> + * // frame.streamId == "in.uplink" → 本端说话 + * // frame.streamId == "in.downlink" → 对端说话 + * writeToFile(frame.streamId, frame.pcm) + * } + * } + * port.start(address = device.address) + * // ... + * port.stop() + * job.cancel() + * ``` + * + * # 生命周期 + * Port 是 [JieliHomeServer] 的懒加载单例(`server.deviceRecordPort`), + * 与 server 共存亡,无需手动 [release]。若在 server 生命周期外独立使用则需调 [release]。 + */ +class JieliDeviceRecordPort( + private val server: JieliHomeServer, +) { + companion object { + private const val TAG = "JieliDeviceRecordPort" + + const val STREAM_UPLINK = "in.uplink" + const val STREAM_DOWNLINK = "in.downlink" + } + + private val _audioFrames = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val _errors = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** 录音 PCM 帧流。每帧包含 streamId 区分上行/下行。 */ + val audioFrames: Flow = _audioFrames.asSharedFlow() + + /** 错误流。 */ + val errors: Flow = _errors.asSharedFlow() + + val isRecording: Boolean get() = server.deviceRecordFeature.isRecording() + + private val eventListener = object : JieliEventAdapter() { + override fun onDeviceRecordAudio(payload: Map) { + val address = payload["address"] as? String ?: return + val streamId = payload["streamId"] as? String ?: return + val pcm = payload["pcm"] as? ByteArray ?: return + val sampleRate = (payload["sampleRate"] as? Number)?.toInt() ?: 16000 + val channels = (payload["channels"] as? Number)?.toInt() ?: 1 + val bitsPerSample = (payload["bitsPerSample"] as? Number)?.toInt() ?: 16 + val tsMs = (payload["tsMs"] as? Number)?.toLong() ?: System.currentTimeMillis() + val frame = DeviceRecordFrame( + address = address, + streamId = streamId, + pcm = pcm, + sampleRate = sampleRate, + channels = channels, + bitsPerSample = bitsPerSample, + tsMs = tsMs, + ) + if (!_audioFrames.tryEmit(frame)) { + Log.w(TAG, "audioFrames buffer overflow; dropped streamId=$streamId") + } + } + + override fun onDeviceRecordError(payload: Map) { + _errors.tryEmit( + DeviceRecordError( + address = payload["address"] as? String, + code = (payload["code"] as? Number)?.toInt() ?: 0, + message = payload["message"] as? String, + ) + ) + } + } + + init { + server.addEventListener(eventListener) + } + + /** + * 启动设备录音上行。若翻译功能正在运行,会先自动停止。 + * + * @param address 目标设备 MAC;null 取当前已连设备 + * @param sampleRate 采样率(Hz),默认 16000 + */ + fun start(address: String? = null, sampleRate: Int = 16000): Result { + if (server.translationFeature.isWorking()) server.translationFeature.stop() + val args = buildMap { + if (address != null) put("address", address) + put("sampleRate", sampleRate) + } + return server.deviceRecordFeature.start(args) + } + + /** 停止设备录音上行。幂等。 */ + fun stop() { + server.deviceRecordFeature.stop() + } + + /** 释放事件监听。Port 作为 server 单例使用时无需调用;独立使用时须在不再需要时调用。 */ + fun release() { + server.removeEventListener(eventListener) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt index 84388868e..c51d8f470 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt @@ -63,7 +63,13 @@ class TranslationFeature( // + OPUS + 设备支持立体声方案时,自动升级到 MODE_CALL_TRANSLATION_WITH_STEREO // (channel=2)。很多固件版本下 mono CALL_TRANSLATION 通路实际上不出帧, // 真正出数据的是立体声方案——上下行 mix 成一路立体声 PCM 上推。 + // + // 例外:AI 助理 / 纯上行录音 场景没有真实通话事件,固件不会下发 SCO_MIX, + // 升级到 stereo 反而会"零帧"。这种调用方应在 args 里传 + // "bypassStereoUpgrade"=true,强制走 mono mode=5 + ALWAYS_RECORDING。 + val bypassStereo = args["bypassStereoUpgrade"] == true val effectiveModeId = if ( + !bypassStereo && modeId == TranslationModeIds.MODE_CALL_TRANSLATION && (args["audioType"] == null || args["audioType"] == com.jieli.bluetooth.constant.Constants.AUDIO_TYPE_OPUS || @@ -76,6 +82,12 @@ class TranslationFeature( ) TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO } else modeId + if (bypassStereo) { + android.util.Log.d( + "TranslationFeature", + "bypassStereoUpgrade=true → keep mono MODE_CALL_TRANSLATION (no real phone-call scenario)", + ) + } val handler = handlers[effectiveModeId] ?: return Result.failure(IllegalArgumentException("unknown modeId=$effectiveModeId")) diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt index 8bbdedec1..1ac08fbef 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt @@ -1,20 +1,46 @@ package com.jielihome.jielihome.feature.translation.mode +import android.bluetooth.BluetoothDevice import android.content.Context +import android.util.Log +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 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 +import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi /** - * MODE_RECORD —— 单向录音翻译。 - * 输入:手机麦 → 推到外部 - * 输出:speaker ← 外部回送 TTS PCM;当前不依赖耳机回送,直接交给宿主自行播放(可选)。 + * MODE_RECORD —— 单向录音翻译(mode=1)。 * - * 注:本模式完全不动 RCSP,只用手机麦。耳机端不需要进入翻译模式。 + * # 与 JL_HomeSdkDemo 对齐 + * Demo 里 TranslationFragment 进入"录音"时会调 `TranslationImpl.enterMode(MODE_RECORD=1, ...)`, + * 耳机端 RCSP 状态机随之从 MODE_IDLE(0) 切到 MODE_RECORD(0x01),耳机可能播放提示音 / 切 LED 状态。 + * 之前本类仅启动手机麦、**未发 enterMode**,导致耳机侧始终停在 IDLE(0),固件不认。 + * + * 现在按 demo: + * 1. 构造 TranslationMode(mode=1, OPUS/PCM, ch=1, sampleRate) + * + setRecordingStrategy(STRATEGY_CUSTOM_RECORDING=0) + * 2. TranslationImpl.enterMode(mode, callback) 通知耳机进入 RECORD 模式 + * 3. STRATEGY_CUSTOM_RECORDING 语义 → APP 用手机麦采音(与 demo 一致) + * 4. stop() 调 exitMode 让耳机回到 IDLE,避免状态机残留 + * + * # 音频流向 + * - 输入:手机麦 → PhoneMicCapture → pushFrame → 外部翻译服务 + * - 输出:OUT_SPEAKER 由宿主自行播放(STRATEGY_CUSTOM_RECORDING 不走 RCSP 下发 TTS) + * + * # 与 DeviceRecordFeature 区分 + * - 本类仍是 "手机麦 + 通知耳机进 RECORD 状态",和 demo 对齐 + * - DeviceRecordFeature 用 `MODE_CALL_TRANSLATION + STRATEGY_DEVICE_ALWAYS_RECORDING` + * 直接从耳机麦抓 OPUS 上行帧,路径完全不同 */ class RecordModeHandler( private val context: Context, @@ -22,32 +48,105 @@ class RecordModeHandler( bridge: TranslationAudioBridge, ) : BaseTranslationModeHandler(btManager, bridge) { + companion object { + private const val TAG = "RecordModeHandler" + } + 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 + @Volatile private var translationImpl: TranslationImpl? = null + @Volatile private var activeDevice: BluetoothDevice? = null + + private val translationCallback = object : TranslationCallback { + override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { + Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} strategy=${m.recordingStrategy}") + } + + override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { + // STRATEGY_CUSTOM_RECORDING 下耳机不上推音频;若固件仍推帧则忽略。 + } + + override fun onError(d: BluetoothDevice, code: Int, msg: String) { + Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") + emitError(code, msg) + } + } override fun start(args: Map) { if (working) return val sampleRate = (args["sampleRate"] as? Int) ?: 16000 val frameMs = (args["frameDurationMs"] as? Int) ?: 20 + + // 1. 发 enterMode 让耳机进入 MODE_RECORD(0x01)状态,与 demo 对齐 + val device = btManager.connectedDevice + if (device == null) { + emitError(-200, "no connected device; cannot enter MODE_RECORD") + return + } + val impl = TranslationImpl(btManager, NoOpAITranslationApi(), device) + if (!impl.isInit) { + runCatching { impl.destroy() } + emitError(-201, "RCSP not init for ${device.address}") + return + } + val sdkMode = TranslationMode( + TranslationMode.MODE_RECORD, // mode=1 + Constants.AUDIO_TYPE_PCM, // RECORD 模式不需要 OPUS 编码,直接 PCM + 1, + sampleRate, + ).setRecordingStrategy(TranslationMode.STRATEGY_CUSTOM_RECORDING) // =0,APP 自己用手机麦采 + impl.addTranslationCallback(translationCallback) + Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${sdkMode.mode}(MODE_RECORD) type=${sdkMode.audioType} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(CUSTOM_RECORDING)") + impl.enterMode(sdkMode, translationCallback) + translationImpl = impl + activeDevice = device + + // 2. 启动手机麦采集(STRATEGY_CUSTOM_RECORDING 语义) 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 + if (mic?.start() != true) { + // 起麦失败:回滚耳机端模式,避免卡在 RECORD + exitHeadsetMode() + return + } working = true - emitLog("RecordMode start sampleRate=$sampleRate") + emitLog("RecordMode start sampleRate=$sampleRate (headset in MODE_RECORD=1, phone mic active)") } override fun stop() { if (!working) return mic?.stop() mic = null + exitHeadsetMode() working = false - emitLog("RecordMode stop") + emitLog("RecordMode stop (headset back to MODE_IDLE=0)") + } + + /** 让耳机 exitMode 回到 IDLE,并释放 TranslationImpl。幂等。 */ + private fun exitHeadsetMode() { + val impl = translationImpl ?: return + val addr = activeDevice?.address + runCatching { + Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORD}") + impl.exitMode(object : OnRcspActionCallback { + override fun onSuccess(d: BluetoothDevice?, t: Int?) { + Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") + } + override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { + Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}") + } + }) + } + runCatching { impl.removeTranslationCallback(translationCallback) } + runCatching { impl.destroy() } + translationImpl = null + activeDevice = null } override fun onTranslatedAudio( diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt index e5c44f11a..480ac32f2 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt @@ -1,17 +1,37 @@ package com.jielihome.jielihome.feature.translation.mode +import android.bluetooth.BluetoothDevice import android.content.Context +import android.util.Log +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 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 +import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi /** - * MODE_RECORDING_TRANSLATION —— 流式录音翻译。等同于 RecordMode 的实时版,差别只在语义: - * 调用方实现实时 STT/翻译流水线、不缓存整段录音再发。 + * MODE_RECORDING_TRANSLATION —— 流式录音翻译(mode=2)。等同于 RecordMode 的实时版, + * 差别只在语义:调用方实现实时 STT/翻译流水线、不缓存整段录音再发。 + * + * # 与 JL_HomeSdkDemo 对齐 + * Demo 会调 `TranslationImpl.enterMode(MODE_RECORDING_TRANSLATION=2, ...)` 让耳机进入状态机。 + * 之前本类仅启动手机麦、**未发 enterMode**,耳机侧始终停在 MODE_IDLE(0),固件不认。 + * + * 现在按 demo: + * 1. 构造 TranslationMode(mode=2, PCM, ch=1, sampleRate) + * + setRecordingStrategy(STRATEGY_CUSTOM_RECORDING=0) + * 2. TranslationImpl.enterMode 通知耳机进入录音翻译模式 + * 3. APP 用手机麦采音并 pushFrame(与 demo 一致) + * 4. stop() 调 exitMode 让耳机回到 IDLE */ class RecordingTranslationModeHandler( private val context: Context, @@ -19,31 +39,103 @@ class RecordingTranslationModeHandler( bridge: TranslationAudioBridge, ) : BaseTranslationModeHandler(btManager, bridge) { + companion object { + private const val TAG = "RecTransModeHandler" + } + 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 + @Volatile private var translationImpl: TranslationImpl? = null + @Volatile private var activeDevice: BluetoothDevice? = null + + private val translationCallback = object : TranslationCallback { + override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { + Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} strategy=${m.recordingStrategy}") + } + + override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { + // STRATEGY_CUSTOM_RECORDING 下耳机不上推音频;若固件仍推帧则忽略。 + } + + override fun onError(d: BluetoothDevice, code: Int, msg: String) { + Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") + emitError(code, msg) + } + } override fun start(args: Map) { if (working) return val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + + // 1. 发 enterMode 让耳机进入 MODE_RECORDING_TRANSLATION(0x02)状态,与 demo 对齐 + val device = btManager.connectedDevice + if (device == null) { + emitError(-200, "no connected device; cannot enter MODE_RECORDING_TRANSLATION") + return + } + val impl = TranslationImpl(btManager, NoOpAITranslationApi(), device) + if (!impl.isInit) { + runCatching { impl.destroy() } + emitError(-201, "RCSP not init for ${device.address}") + return + } + val sdkMode = TranslationMode( + TranslationMode.MODE_RECORDING_TRANSLATION, // mode=2 + Constants.AUDIO_TYPE_PCM, + 1, + sampleRate, + ).setRecordingStrategy(TranslationMode.STRATEGY_CUSTOM_RECORDING) + impl.addTranslationCallback(translationCallback) + Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${sdkMode.mode}(MODE_RECORDING_TRANSLATION) type=${sdkMode.audioType} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(CUSTOM_RECORDING)") + impl.enterMode(sdkMode, translationCallback) + translationImpl = impl + activeDevice = device + + // 2. 启动手机麦采集 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 + if (mic?.start() != true) { + exitHeadsetMode() + return + } working = true - emitLog("RecordingTranslation start sampleRate=$sampleRate") + emitLog("RecordingTranslation start sampleRate=$sampleRate (headset in MODE_RECORDING_TRANSLATION=2)") } override fun stop() { if (!working) return mic?.stop() mic = null + exitHeadsetMode() working = false - emitLog("RecordingTranslation stop") + emitLog("RecordingTranslation stop (headset back to MODE_IDLE=0)") + } + + /** 让耳机 exitMode 回到 IDLE,并释放 TranslationImpl。幂等。 */ + private fun exitHeadsetMode() { + val impl = translationImpl ?: return + val addr = activeDevice?.address + runCatching { + Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORDING_TRANSLATION}") + impl.exitMode(object : OnRcspActionCallback { + override fun onSuccess(d: BluetoothDevice?, t: Int?) { + Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") + } + override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { + Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}") + } + }) + } + runCatching { impl.removeTranslationCallback(translationCallback) } + runCatching { impl.destroy() } + translationImpl = null + activeDevice = null } override fun onTranslatedAudio( diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt index fca4c751a..4a6c3545c 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt @@ -94,17 +94,31 @@ class RcspTranslationRuntime( @Volatile private var rxTypeMismatch = 0L @Volatile private var rxNullPayload = 0L @Volatile private var rxLastReportMs = 0L + /** 首帧 flag:首次收到任意 AudioData 时立即打一条 INFO 级别日志(不受 1s 节流影响)。 */ + @Volatile private var rxFirstLogged = false + /** 首帧(按 source 维度):uplink / downlink / mix 分别各打一条,确认链路通畅。 */ + private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap() 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}") + Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel} strategy=${m.recordingStrategy}") } override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { rxCount++ + val payloadSize = data.audioData?.size ?: 0 + // 首帧(总):立即打点,避免节流导致"完全静默"误判 + if (!rxFirstLogged) { + rxFirstLogged = true + Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=$payloadSize expect.type=${mode.audioType}") + } + // 首帧(按 source):分别打点,快速判断上行/下行是否都在推 + if (rxFirstPerSource.putIfAbsent(data.source, true) == null) { + Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST source=${data.source} type=${data.type} size=$payloadSize") + } 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}") + Log.d(TAG, "[SDK<-DEV] RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=$payloadSize") rxCount = 0; rxTypeMismatch = 0; rxNullPayload = 0; rxLastReportMs = now } if (data.type != mode.audioType) { @@ -124,11 +138,12 @@ class RcspTranslationRuntime( 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) + else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown source=${data.source} dropped") } } override fun onError(d: BluetoothDevice, code: Int, msg: String) { - Log.e(TAG, "TranslationCallback.onError code=$code msg=$msg") + Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") this@RcspTranslationRuntime.onError(code, msg) } } @@ -142,10 +157,11 @@ class RcspTranslationRuntime( /** * 周期性 flush 间隔:每隔此毫秒数巡检一次 buffer,**只要非空就 flush**。 * - * 实测:低于 1s(试过 200ms)耳机端 RCSP 解码器会被频繁 reset 直至死机, - * 因此周期上限只能保持 1s。降低段间延迟改走"首段优先 flush"路线(B)。 + * 历史实测:200ms 时耳机端 RCSP 解码器会被频繁 reset 直至死机;1s 长期稳定。 + * 当前实验:500ms —— 通话翻译"播报中间断一下"的优化尝试。需真机长时压测 + * (≥10 分钟连续讲话)确认无解码器 reset 风暴,否则回退到 1_000L。 */ - private const val PERIODIC_FLUSH_MS = 1_000L + private const val PERIODIC_FLUSH_MS = 1000L /** * 首段优先 flush 阈值(路线 B):每个 utterance 的**首段**达到此毫秒数对应字节量 * 就立即 flush,不等周期。让用户开口到对方听到的延迟尽可能小(< 200ms)。 @@ -274,7 +290,7 @@ class RcspTranslationRuntime( 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)") + Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} 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] 切一次缓冲。 @@ -368,16 +384,21 @@ class RcspTranslationRuntime( 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 + // 2) 首段优先(路线 B):当前**实验中临时屏蔽**——为观察 500ms 周期 flush 的纯效果。 + // 屏蔽后所有 flush 走两条路:a) isFinal=true 短路;b) PERIODIC_FLUSH_MS 周期。 + // 实验完成后若需恢复,把下面 if (false) 改回 if (pending) 即可。 + @Suppress("ConstantConditionIf") + if (false) { + 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 @@ -431,9 +452,14 @@ class RcspTranslationRuntime( runCatching { flushWatcher?.shutdownNow() } flushWatcher = null runCatching { + Log.i(TAG, "[APP->SDK] exitMode addr=${device.address} mode=${mode.mode}") translationImpl.exitMode(object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, t: Int?) {} - override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {} + override fun onSuccess(d: BluetoothDevice?, t: Int?) { + Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") + } + override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { + Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}") + } }) } runCatching { translationImpl.removeTranslationCallback(translationCallback) } diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt index fc1291632..ed3342410 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt @@ -109,6 +109,22 @@ class JieliNativeDevicePlugin( } override fun onDeviceFound(payload: Map) { + val meta = mutableMapOf( + "edrAddr" to payload["edrAddr"], + "deviceType" to payload["deviceType"], + "connectWay" to payload["connectWay"], + ) + // 透传 ScanFeature 解析出的广播详情,供 UI 弹窗展示。 + listOf( + "rawAdv", + "advRecords", + "advFlags", + "manufacturerCompanyId", + "manufacturerData", + "serviceUuids", + ).forEach { key -> + if (payload.containsKey(key)) meta[key] = payload[key] + } emit(DevicePluginEvent( type = DevicePluginEventType.DEVICE_DISCOVERED, discovered = DiscoveredDevice( @@ -116,11 +132,7 @@ class JieliNativeDevicePlugin( 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"], - ), + metadata = meta, ), )) } @@ -128,6 +140,7 @@ class JieliNativeDevicePlugin( override fun onConnectionState(address: String, state: Int) { val session = _activeSession ?: return if (session.deviceId != address) return + Log.d(TAG, "onConnectionState addr=$address state=$state") when (state) { 2 -> session.setState(DeviceConnectionState.CONNECTING) 1 -> session.setState(DeviceConnectionState.LINK_CONNECTED) @@ -152,6 +165,7 @@ class JieliNativeDevicePlugin( override fun onRcspInit(address: String, code: Int) { val session = _activeSession ?: return if (session.deviceId != address) return + Log.d(TAG, "onRcspInit addr=$address code=$code") if (code == 0) { session.setState(DeviceConnectionState.READY) val pending = _pendingConnect @@ -200,6 +214,25 @@ class JieliNativeDevicePlugin( "translation: ${payload["code"]} ${payload["message"] ?: ""}", ) } + + override fun onDeviceRecordStart(payload: Map) { + _activeSession?.emitFeature("jieli.deviceRecord.start", payload) + } + + override fun onDeviceRecordAudio(payload: Map) { + _activeSession?.emitFeature("jieli.deviceRecord.audio", payload) + } + + override fun onDeviceRecordStop(payload: Map) { + _activeSession?.emitFeature("jieli.deviceRecord.stop", payload) + } + + override fun onDeviceRecordError(payload: Map) { + _activeSession?.emitError( + "device.feature_failed", + "deviceRecord: ${payload["code"]} ${payload["message"] ?: ""}", + ) + } } // ─── NativeDevicePlugin 接口 ────────────────────────────────────────── @@ -223,7 +256,13 @@ class JieliNativeDevicePlugin( override fun startScan(filter: DeviceScanFilter?, timeoutMs: Long?) { requireInit() val ms = (timeoutMs ?: 30_000L).toInt() - server.scanFeature.startScan(ms).onFailure { + val nameList = filter?.nameList?.filter { it.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + ?: filter?.namePrefix?.takeIf { it.isNotEmpty() }?.let { listOf(it) } + ?: emptyList() + val uuidList = filter?.serviceUuids?.filter { it.isNotEmpty() } ?: emptyList() + val skipUnnamed = filter?.skipUnnamed ?: true + server.scanFeature.startScan(ms, nameList, uuidList, skipUnnamed).onFailure { throw DeviceException("device.scan_failed", it.message, it) } } @@ -252,6 +291,7 @@ class JieliNativeDevicePlugin( deviceId = deviceId, initialName = (extra["name"] as? String) ?: "", capabilities = capabilities, + otaCacheDir = context.cacheDir, ) _activeSession = session @@ -259,11 +299,15 @@ class JieliNativeDevicePlugin( _pendingConnect = future _pendingConnectAddress = deviceId + val edrAddress = extra["edrAddr"] as? String + val deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0 + val connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0 + Log.d(TAG, "connect deviceId=$deviceId edr=$edrAddress dt=$deviceType cw=$connectWay") server.connectFeature.connect( bleAddress = deviceId, - edrAddress = extra["edrAddr"] as? String, - deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0, - connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0, + edrAddress = edrAddress, + deviceType = deviceType, + connectWay = connectWay, ).onFailure { future.completeExceptionally( DeviceException(DeviceErrorCode.CONNECT_FAILED, it.message, it) diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt index 438dfc42f..0a5fbd1e8 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt @@ -1,6 +1,7 @@ package com.jielihome.jielihome.integration import android.util.Log +import com.aiagent.device_plugin_interface.DeviceAssistantPort import com.aiagent.device_plugin_interface.DeviceCallTranslationPort import com.aiagent.device_plugin_interface.DeviceCapability import com.aiagent.device_plugin_interface.DeviceConnectionState @@ -8,6 +9,7 @@ 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.DeviceOtaPort import com.aiagent.device_plugin_interface.DeviceSessionEvent import com.aiagent.device_plugin_interface.DeviceSessionEventType import com.aiagent.device_plugin_interface.NativeDeviceSession @@ -17,6 +19,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import java.io.File /** * 杰理 [NativeDeviceSession] 实现 —— 包装 [JieliHomeServer] 的连接快照与事件。 @@ -32,6 +35,7 @@ class JieliNativeDeviceSession internal constructor( override val deviceId: String, initialName: String, override val capabilities: Set, + private val otaCacheDir: File, ) : NativeDeviceSession { companion object { @@ -59,6 +63,8 @@ class JieliNativeDeviceSession internal constructor( // ─── 由 plugin 在事件回调中调用 ───────────────────────────────────────── + @Volatile private var _otaPort: JieliOtaPort? = null + internal fun setState(s: DeviceConnectionState) { if (_state == s) return _state = s @@ -69,6 +75,9 @@ class JieliNativeDeviceSession internal constructor( )) if (s == DeviceConnectionState.DISCONNECTED) { _disposed = true + // 端口随 session 收尾:补一帧 FAILED + 解订阅,UI 才能跳出 OTA 锁定。 + runCatching { _otaPort?.shutdown(DeviceErrorCode.DISCONNECTED_REMOTE) } + _otaPort = null } } @@ -205,6 +214,31 @@ class JieliNativeDeviceSession internal constructor( mapOf("ok" to true) } + // ── 设备录音 ─────────────────────────────────────────────────────── + "jieli.deviceRecord.start" -> { + if (server.translationFeature.isWorking()) server.translationFeature.stop() + val sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000 + server.deviceRecordFeature.start( + mapOf("address" to deviceId, "sampleRate" to sampleRate) + ).getOrElse { + throw DeviceException( + "device.feature_failed", + "deviceRecord.start failed: ${it.message}", + it, + ) + } + mapOf("ok" to true) + } + + "jieli.deviceRecord.stop" -> { + server.deviceRecordFeature.stop() + mapOf("ok" to true) + } + + "jieli.deviceRecord.status" -> mapOf( + "recording" to server.deviceRecordFeature.isRecording() + ) + "jieli.cmd.send" -> { val opCode = (args["opCode"] as? Number)?.toInt() ?: throw DeviceException( @@ -251,6 +285,28 @@ class JieliNativeDeviceSession internal constructor( override fun callTranslationPort(): DeviceCallTranslationPort = server.callTranslationPort + override fun assistantPort(): DeviceAssistantPort = server.assistantPort + + override fun otaPort(): DeviceOtaPort? { + if (DeviceCapability.OTA !in capabilities) return null + // 懒创建:未 ready 时也允许构造(订阅事件流),但 start() 内部会再校验。 + var port = _otaPort + if (port == null) { + synchronized(this) { + port = _otaPort + if (port == null && !_disposed) { + port = JieliOtaPort( + server = server, + deviceId = deviceId, + cacheDir = File(otaCacheDir, "ota"), + ) + _otaPort = port + } + } + } + return port + } + override fun disconnect() { if (_disposed) return setState(DeviceConnectionState.DISCONNECTING) diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt new file mode 100644 index 000000000..6ed4ea71b --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt @@ -0,0 +1,280 @@ +package com.jielihome.jielihome.integration + +import android.util.Log +import com.aiagent.device_plugin_interface.DeviceErrorCode +import com.aiagent.device_plugin_interface.DeviceException +import com.aiagent.device_plugin_interface.DeviceOtaPort +import com.aiagent.device_plugin_interface.DeviceOtaProgress +import com.aiagent.device_plugin_interface.DeviceOtaRequest +import com.aiagent.device_plugin_interface.DeviceOtaState +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.io.File +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 杰理 [DeviceOtaPort] 实现 —— 桥接 [com.jielihome.jielihome.feature.ota.OtaFeature]。 + * + * 不重写 OtaFeature 的状态机,只把它派的 `otaState` / `otaError` 事件翻译成 + * [DeviceOtaProgress] 推到 [progressStream]。 + * + * 请求子类支持矩阵: + * - [DeviceOtaRequest.File] ✓ 直接 path 传给 OtaFeature + * - [DeviceOtaRequest.Bytes] ✓ 落盘到 cacheDir/ota_.ufw 再走 path + * - [DeviceOtaRequest.Url] ✗ 杰理 SDK 无 HTTP 通道,抛 NOT_SUPPORTED + * - [DeviceOtaRequest.Vendor] ✓ payload['filePath'] + payload['fileFlag'] 透传 + * + * 端口生命周期: + * - session disconnect 时由 [JieliNativeDeviceSession] 调 [shutdown] —— 主动 cancel + + * 补一帧 FAILED(disconnected_remote) 让上层取消订阅 / 跳出锁定 UI。 + */ +class JieliOtaPort internal constructor( + private val server: JieliHomeServer, + private val deviceId: String, + private val cacheDir: File, +) : DeviceOtaPort { + + companion object { + private const val TAG = "JieliOtaPort" + } + + private val _running = AtomicBoolean(false) + @Volatile private var _totalBytes: Long = 0 + @Volatile private var _tempFile: File? = null + @Volatile private var _disposed = false + + private val _progress = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val progressStream: Flow = _progress.asSharedFlow() + + override val isRunning: Boolean get() = _running.get() || server.otaFeature.isRunning() + + private val listener: JieliEventListener = object : JieliEventAdapter() { + override fun onOtaState(payload: Map) { + val stateName = payload["state"] as? String ?: return + val state = parseState(stateName) ?: return + val sent = (payload["sent"] as? Number)?.toLong() ?: 0L + val total = (payload["total"] as? Number)?.toLong() ?: _totalBytes + val percent = (payload["percent"] as? Number)?.toInt() ?: -1 + val tsMs = (payload["tsMs"] as? Number)?.toLong() ?: System.currentTimeMillis() + emit(DeviceOtaProgress( + state = state, + sentBytes = sent, + totalBytes = total, + percent = percent, + tsMs = tsMs, + )) + if (state == DeviceOtaState.DONE || + state == DeviceOtaState.CANCELLED || + state == DeviceOtaState.FAILED) { + finalize() + } + } + + override fun onOtaError(payload: Map) { + // OtaFeature 用整数 code;映射到 device.* 命名空间。 + val rawCode = (payload["code"] as? Number)?.toInt() ?: -1 + val msg = payload["message"] as? String + val mapped = mapErrorCode(rawCode) + emit(DeviceOtaProgress( + state = DeviceOtaState.FAILED, + sentBytes = -1, + totalBytes = _totalBytes, + percent = -1, + tsMs = System.currentTimeMillis(), + errorCode = mapped, + errorMessage = msg, + )) + } + } + + init { + server.addEventListener(listener) + } + + // ─── DeviceOtaPort ──────────────────────────────────────────────────── + + @Synchronized + override fun start(request: DeviceOtaRequest) { + if (_disposed) throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION) + if (!_running.compareAndSet(false, true) || server.otaFeature.isRunning()) { + _running.set(server.otaFeature.isRunning() || _running.get()) + throw DeviceException(DeviceErrorCode.OTA_BUSY, "ota already running") + } + val (path, fileFlag) = try { + resolveFirmware(request) + } catch (e: DeviceException) { + _running.set(false) + throw e + } catch (t: Throwable) { + _running.set(false) + throw DeviceException(DeviceErrorCode.OTA_FILE_INVALID, t.message ?: "firmware error", t) + } + _totalBytes = File(path).length().coerceAtLeast(0L) + + // 立即派一帧 INQUIRING,UI 不必等 OtaFeature 第一次回调。 + emit(DeviceOtaProgress( + state = DeviceOtaState.INQUIRING, + sentBytes = 0, + totalBytes = _totalBytes, + percent = 0, + tsMs = System.currentTimeMillis(), + )) + + try { + server.otaFeature.start( + address = deviceId, + firmwareFilePath = path, + blockSize = request.blockSize ?: 512, + fileFlagBytes = fileFlag, + ) + } catch (t: Throwable) { + _running.set(false) + cleanupTemp() + throw DeviceException(DeviceErrorCode.OTA_TRANSFER_FAILED, t.message, t) + } + } + + override fun cancel() { + if (!_running.get() && !server.otaFeature.isRunning()) return + server.otaFeature.cancel() + // 状态变更通过 onOtaState(CANCELLED) 回到 finalize。 + } + + /** 由 [JieliNativeDeviceSession] 在 disconnect 时调用:补 FAILED 帧并解订阅。 */ + fun shutdown(reason: String = DeviceErrorCode.DISCONNECTED_REMOTE) { + if (_disposed) return + _disposed = true + if (_running.get()) { + runCatching { server.otaFeature.cancel() } + emit(DeviceOtaProgress( + state = DeviceOtaState.FAILED, + sentBytes = -1, + totalBytes = _totalBytes, + percent = -1, + tsMs = System.currentTimeMillis(), + errorCode = reason, + errorMessage = "session terminated during ota", + )) + } + runCatching { server.removeEventListener(listener) } + cleanupTemp() + _running.set(false) + } + + // ─── 内部 ────────────────────────────────────────────────────────────── + + private fun resolveFirmware(req: DeviceOtaRequest): Pair { + val flag = ByteArray(0) // OtaFeature 默认 fileFlag + return when (req) { + is DeviceOtaRequest.File -> { + val f = File(req.filePath) + if (!f.isFile || f.length() <= 0) { + throw DeviceException( + DeviceErrorCode.OTA_FILE_INVALID, + "firmware file invalid: ${req.filePath}", + ) + } + req.filePath to flag + } + + is DeviceOtaRequest.Bytes -> { + if (req.bytes.isEmpty()) { + throw DeviceException( + DeviceErrorCode.OTA_FILE_INVALID, + "ota bytes empty", + ) + } + if (!cacheDir.isDirectory && !cacheDir.mkdirs()) { + throw DeviceException( + DeviceErrorCode.OTA_FILE_INVALID, + "cannot create ota cache dir: ${cacheDir.absolutePath}", + ) + } + val tmp = File(cacheDir, "ota_${System.currentTimeMillis()}.ufw") + tmp.writeBytes(req.bytes) + _tempFile = tmp + tmp.absolutePath to flag + } + + is DeviceOtaRequest.Url -> { + throw DeviceException( + DeviceErrorCode.NOT_SUPPORTED, + "jieli ota does not support remote url; download in app and use File request", + ) + } + + is DeviceOtaRequest.Vendor -> { + if (req.vendorKey != "jieli") { + throw DeviceException( + DeviceErrorCode.INVALID_ARGUMENT, + "vendor mismatch: expected 'jieli', got '${req.vendorKey}'", + ) + } + val path = req.payload["filePath"] as? String + ?: throw DeviceException( + DeviceErrorCode.INVALID_ARGUMENT, + "jieli ota.vendor: payload['filePath'] required", + ) + val f = File(path) + if (!f.isFile || f.length() <= 0) { + throw DeviceException( + DeviceErrorCode.OTA_FILE_INVALID, + "firmware file invalid: $path", + ) + } + @Suppress("UNCHECKED_CAST") + val customFlag = (req.payload["fileFlag"] as? ByteArray) + ?: (req.payload["fileFlag"] as? List)?.map { it.toByte() }?.toByteArray() + ?: flag + path to customFlag + } + } + } + + private fun finalize() { + _running.set(false) + cleanupTemp() + } + + private fun cleanupTemp() { + _tempFile?.let { runCatching { it.delete() } } + _tempFile = null + } + + private fun emit(p: DeviceOtaProgress) { + if (!_progress.tryEmit(p)) Log.w(TAG, "progress buffer full; dropped ${p.state}") + } + + private fun parseState(name: String): DeviceOtaState? = when (name) { + "IDLE" -> DeviceOtaState.IDLE + "INQUIRING" -> DeviceOtaState.INQUIRING + "NOTIFYING_SIZE" -> DeviceOtaState.NOTIFYING_SIZE + "ENTERING" -> DeviceOtaState.ENTERING + "TRANSFERRING" -> DeviceOtaState.TRANSFERRING + "VERIFYING" -> DeviceOtaState.VERIFYING + "REBOOTING" -> DeviceOtaState.REBOOTING + "DONE" -> DeviceOtaState.DONE + "FAILED" -> DeviceOtaState.FAILED + "CANCELLED" -> DeviceOtaState.CANCELLED + else -> null + } + + /** OtaFeature 的负数错误码映射到设备域命名空间。 */ + private fun mapErrorCode(raw: Int): String = when (raw) { + -1 -> DeviceErrorCode.OTA_BUSY + -2 -> DeviceErrorCode.NO_ACTIVE_SESSION + -3 -> DeviceErrorCode.OTA_FILE_INVALID + -100 -> DeviceErrorCode.OTA_TRANSFER_FAILED + else -> DeviceErrorCode.OTA_TRANSFER_FAILED + } +} diff --git a/local_plugins/device_jieli/lib/device_jieli.dart b/local_plugins/device_jieli/lib/device_jieli.dart index e095ac27f..4c269d178 100644 --- a/local_plugins/device_jieli/lib/device_jieli.dart +++ b/local_plugins/device_jieli/lib/device_jieli.dart @@ -17,12 +17,12 @@ class JieliDevice { this.deviceType, this.connectWay, this.rssi, - this.leftBattery, - this.rightBattery, - this.caseBattery, - this.leftCharging, - this.rightCharging, - this.caseCharging, + this.rawAdv, + this.advFlags, + this.manufacturerCompanyId, + this.manufacturerData, + this.serviceUuids = const [], + this.advRecords = const [], }); final String name; @@ -31,13 +31,24 @@ class JieliDevice { final int? deviceType; final int? connectWay; final int? rssi; - // 来自 BleScanMessage,扫到时就有;0 表示该位置未上报(如单耳设备右耳为 0) - final int? leftBattery; - final int? rightBattery; - final int? caseBattery; - final bool? leftCharging; - final bool? rightCharging; - final bool? caseCharging; + + /// 原始广播包 hex(不含 "0x" 前缀)。 + final String? rawAdv; + + /// AD Type 0x01 Flags 字节值。 + final int? advFlags; + + /// AD Type 0xFF 厂商数据的前两字节(小端)解析出的 Company ID。 + final int? manufacturerCompanyId; + + /// AD Type 0xFF 厂商数据去掉 Company ID 后剩余字节的 hex(不含 "0x")。 + final String? manufacturerData; + + /// ScanRecord.serviceUuids 列表(大写字符串)。 + final List serviceUuids; + + /// 按 BLE Core Spec §11 解析的每条 AD 记录:{len, type, data}。 + final List> advRecords; factory JieliDevice.fromMap(Map m) => JieliDevice( name: (m['name'] as String?) ?? '', @@ -46,19 +57,23 @@ class JieliDevice { deviceType: m['deviceType'] as int?, connectWay: m['connectWay'] as int?, rssi: m['rssi'] as int?, - leftBattery: (m['leftBattery'] as num?)?.toInt(), - rightBattery: (m['rightBattery'] as num?)?.toInt(), - caseBattery: (m['caseBattery'] as num?)?.toInt(), - leftCharging: m['leftCharging'] as bool?, - rightCharging: m['rightCharging'] as bool?, - caseCharging: m['caseCharging'] as bool?, + rawAdv: m['rawAdv'] as String?, + advFlags: m['advFlags'] as int?, + manufacturerCompanyId: m['manufacturerCompanyId'] as int?, + manufacturerData: m['manufacturerData'] as String?, + serviceUuids: + (m['serviceUuids'] as List?)?.cast() ?? const [], + advRecords: (m['advRecords'] as List?) + ?.whereType() + .map((e) => e.map( + (k, v) => MapEntry(k.toString(), v?.toString() ?? ''), + )) + .toList() ?? + const [], ); @override - String toString() => 'JieliDevice($name, $address, rssi=$rssi, ' - 'L=$leftBattery${leftCharging == true ? "+" : ""} ' - 'R=$rightBattery${rightCharging == true ? "+" : ""} ' - 'Case=$caseBattery${caseCharging == true ? "+" : ""})'; + String toString() => 'JieliDevice($name, $address, rssi=$rssi)'; } abstract class JieliEvent { @@ -282,6 +297,103 @@ class SpeechStopReason { static const int stop = 1; } +// ───── 设备录音事件 ───── + +/// 上行/下行通道 ID 常量(与 Kotlin DeviceRecordFeature 保持一致) +class DeviceRecordStreams { + static const String inUplink = 'in.uplink'; + static const String inDownlink = 'in.downlink'; +} + +class DeviceRecordStartEvent extends JieliEvent { + final String? address; + final int sampleRate; + final int tsMs; + const DeviceRecordStartEvent({ + this.address, + required this.sampleRate, + required this.tsMs, + }); +} + +class DeviceRecordAudioEvent extends JieliEvent { + final String? address; + + /// [DeviceRecordStreams.inUplink] 或 [DeviceRecordStreams.inDownlink] + final String streamId; + final int sampleRate; + final int channels; + final int bitsPerSample; + final int tsMs; + + /// 16bit signed PCM 字节流(小端) + final Uint8List pcm; + + const DeviceRecordAudioEvent({ + this.address, + required this.streamId, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.tsMs, + required this.pcm, + }); +} + +class DeviceRecordStopEvent extends JieliEvent { + final String? address; + final int tsMs; + const DeviceRecordStopEvent({this.address, required this.tsMs}); +} + +class DeviceRecordErrorEvent extends JieliEvent { + final String? address; + final int code; + final String? message; + const DeviceRecordErrorEvent({this.address, required this.code, this.message}); +} + +// ───── AI 助理通路事件(JieliAssistantPort:MODE_RECORD + DEVICE_ALWAYS_RECORDING)───── +// 与 SpeechXxxEvent 互斥;走 RCSP 翻译模式状态机,耳机端 OPUS → 16k PCM 自动持续上推。 + +class AssistantStartEvent extends JieliEvent { + final int sampleRate; + final int tsMs; + const AssistantStartEvent({required this.sampleRate, required this.tsMs}); +} + +class AssistantAudioEvent extends JieliEvent { + /// 'pcm16' + final String encoding; + final int sampleRate; + final int channels; + final int bitsPerSample; + final int sequence; + final int tsMs; + /// 16-bit signed PCM, mono, 16kHz, 20ms 帧 + final Uint8List pcm; + const AssistantAudioEvent({ + required this.encoding, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.sequence, + required this.tsMs, + required this.pcm, + }); +} + +class AssistantEndEvent extends JieliEvent { + final int tsMs; + const AssistantEndEvent({required this.tsMs}); +} + +class AssistantErrorEvent extends JieliEvent { + final String code; + final String? message; + const AssistantErrorEvent(this.code, this.message); +} + // ───── OTA 事件 ───── enum OtaState { @@ -405,6 +517,57 @@ class Jielihome { (raw['code'] as int?) ?? 0, raw['message'] as String?, ); + case 'assistantStart': + return AssistantStartEvent( + sampleRate: (raw['sampleRate'] as int?) ?? 16000, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + ); + case 'assistantAudio': + return AssistantAudioEvent( + encoding: (raw['encoding'] as String?) ?? 'pcm16', + sampleRate: (raw['sampleRate'] as int?) ?? 16000, + channels: (raw['channels'] as int?) ?? 1, + bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16, + sequence: (raw['sequence'] as num?)?.toInt() ?? 0, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + pcm: raw['pcm'] as Uint8List, + ); + case 'assistantEnd': + return AssistantEndEvent( + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + ); + case 'assistantError': + return AssistantErrorEvent( + (raw['code'] as String?) ?? 'device.assistant.unknown', + raw['message'] as String?, + ); + case 'deviceRecordStart': + return DeviceRecordStartEvent( + address: raw['address'] as String?, + sampleRate: (raw['sampleRate'] as int?) ?? 16000, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + ); + case 'deviceRecordAudio': + return DeviceRecordAudioEvent( + address: raw['address'] as String?, + streamId: raw['streamId'] as String, + sampleRate: (raw['sampleRate'] as int?) ?? 16000, + channels: (raw['channels'] as int?) ?? 1, + bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + pcm: raw['pcm'] as Uint8List, + ); + case 'deviceRecordStop': + return DeviceRecordStopEvent( + address: raw['address'] as String?, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + ); + case 'deviceRecordError': + return DeviceRecordErrorEvent( + address: raw['address'] as String?, + code: (raw['code'] as int?) ?? 0, + message: raw['message'] as String?, + ); case 'otaState': return OtaStateEvent( state: _parseOtaState(raw['state'] as String?), @@ -454,9 +617,22 @@ class Jielihome { }); } - Future startScan({Duration timeout = const Duration(seconds: 30)}) async { + /// 启动扫描,可选复合过滤。 + /// + /// - [nameList]:设备名命中列表(精确匹配,忽略大小写);空 = 不按名过滤 + /// - [uuidList]:UUID 命中列表 → `ScanFilter.setServiceUuid`;空 = 不按 UUID 过滤 + /// - [skipUnnamed]:跳过没有 name 的广播(环境噪声),默认 true + Future startScan({ + Duration timeout = const Duration(seconds: 30), + List nameList = const [], + List uuidList = const [], + bool skipUnnamed = true, + }) async { await _methodChannel.invokeMethod('startScan', { 'timeoutMs': timeout.inMilliseconds, + 'nameList': nameList, + 'uuidList': uuidList, + 'skipUnnamed': skipUnnamed, }); } @@ -631,6 +807,50 @@ class Jielihome { }); } + // ───── AI 助理通路(JieliAssistantPort)───── + // 与 [speechStart] 不同:本通路走 RCSP `MODE_RECORD` + + // `STRATEGY_DEVICE_ALWAYS_RECORDING`,耳机自主持续推 OPUS,APP 不需要唤醒词 + // 也不需要按键。Port 内部以 `packetSize=40` 解码 16k OPUS 帧,避免 SpeechFeature + // 路径下因 packetSize=200 导致的 80% 帧丢失。 + // + // 订阅 [AssistantAudioEvent] 拿 16k/16bit/mono/20ms PCM 帧; + // [AssistantErrorEvent] / [AssistantEndEvent] 用于错误和退出通知。 + Future assistantStart() async => + (await _methodChannel.invokeMethod('assistantStart')) ?? false; + + Future assistantStop() async { + await _methodChannel.invokeMethod('assistantStop'); + } + + Future assistantIsRunning() async => + (await _methodChannel.invokeMethod('assistantIsRunning')) ?? false; + + // ───── 设备录音 ───── + // 订阅 [DeviceRecordStartEvent] / [DeviceRecordAudioEvent] / + // [DeviceRecordStopEvent] / [DeviceRecordErrorEvent] 接收录音状态和 PCM 数据。 + + /// 启动设备录音上行。 + /// [address] 省略则取当前已连设备。 + Future deviceRecordStart({ + String? address, + int sampleRate = 16000, + }) async { + await _methodChannel.invokeMethod('deviceRecordStart', { + 'args': { + if (address != null) 'address': address, + 'sampleRate': sampleRate, + }, + }); + } + + Future deviceRecordStop() => + _methodChannel.invokeMethod('deviceRecordStop'); + + Future?> deviceRecordStatus() async { + final raw = await _methodChannel.invokeMethod('deviceRecordStatus'); + return raw?.cast(); + } + // ───── OTA ───── /// 启动 OTA 升级。订阅 [OtaStateEvent] / [OtaErrorEvent] 看进度。 diff --git a/local_plugins/device_jieli/lib/src/jieli_device_plugin.dart b/local_plugins/device_jieli/lib/src/jieli_device_plugin.dart index 1392bfa33..3ffad3c07 100644 --- a/local_plugins/device_jieli/lib/src/jieli_device_plugin.dart +++ b/local_plugins/device_jieli/lib/src/jieli_device_plugin.dart @@ -91,7 +91,25 @@ class JieliDevicePlugin implements DevicePlugin { @override Future startScan({DeviceScanFilter? filter, Duration? timeout}) async { _requireInit(); - await _home.startScan(timeout: timeout ?? const Duration(seconds: 30)); + final filterNameList = filter?.nameList + ?.where((e) => e.isNotEmpty) + .toList(growable: false); + final nameList = (filterNameList != null && filterNameList.isNotEmpty) + ? filterNameList + : [ + if (filter?.namePrefix != null && filter!.namePrefix!.isNotEmpty) + filter.namePrefix!, + ]; + final uuidList = filter?.serviceUuids + ?.where((e) => e.isNotEmpty) + .toList(growable: false) ?? + const []; + await _home.startScan( + timeout: timeout ?? const Duration(seconds: 30), + nameList: nameList, + uuidList: uuidList, + skipUnnamed: filter?.skipUnnamed ?? true, + ); _scanning = true; _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted)); } @@ -197,17 +215,26 @@ class JieliDevicePlugin implements DevicePlugin { return; } if (raw is DeviceFoundEvent) { + final d = raw.device; _emit(DevicePluginEvent( type: DevicePluginEventType.deviceDiscovered, discovered: DiscoveredDevice( - id: raw.device.address, - name: raw.device.name, - rssi: raw.device.rssi, + id: d.address, + name: d.name, + rssi: d.rssi, vendor: kVendor, metadata: { - if (raw.device.edrAddr != null) 'edrAddr': raw.device.edrAddr, - if (raw.device.deviceType != null) 'deviceType': raw.device.deviceType, - if (raw.device.connectWay != null) 'connectWay': raw.device.connectWay, + if (d.edrAddr != null) 'edrAddr': d.edrAddr, + if (d.deviceType != null) 'deviceType': d.deviceType, + if (d.connectWay != null) 'connectWay': d.connectWay, + if (d.rawAdv != null) 'rawAdv': d.rawAdv, + if (d.advFlags != null) 'advFlags': d.advFlags, + if (d.manufacturerCompanyId != null) + 'manufacturerCompanyId': d.manufacturerCompanyId, + if (d.manufacturerData != null) + 'manufacturerData': d.manufacturerData, + if (d.serviceUuids.isNotEmpty) 'serviceUuids': d.serviceUuids, + if (d.advRecords.isNotEmpty) 'advRecords': d.advRecords, }, ), )); @@ -239,6 +266,13 @@ class JieliDevicePlugin implements DevicePlugin { _activeSession?.dispatchTranslationEvent(raw); return; } + if (raw is DeviceRecordStartEvent || + raw is DeviceRecordAudioEvent || + raw is DeviceRecordStopEvent || + raw is DeviceRecordErrorEvent) { + _activeSession?.dispatchDeviceRecordEvent(raw); + return; + } if (raw is ScanStatusEvent) { _scanning = raw.started; _emit(DevicePluginEvent( diff --git a/local_plugins/device_jieli/lib/src/jieli_device_session.dart b/local_plugins/device_jieli/lib/src/jieli_device_session.dart index 6663b2f36..1deee2b33 100644 --- a/local_plugins/device_jieli/lib/src/jieli_device_session.dart +++ b/local_plugins/device_jieli/lib/src/jieli_device_session.dart @@ -60,6 +60,11 @@ class JieliDeviceSession implements DeviceSession { @override Stream get eventStream => _evt.stream; + // 该 Dart 侧 session 是历史 facade,不再走 OTA 路径——OTA 由 + // native-driven `JieliNativeDeviceSession.otaPort()` 提供,这里保持 null。 + @override + DeviceOtaPort? otaPort() => null; + /// `JieliDevicePlugin.connect` 中等待握手完成。 Future waitReady({required Duration timeout}) { if (_state == DeviceConnectionState.ready) return Future.value(this); @@ -156,6 +161,53 @@ class JieliDeviceSession implements DeviceSession { } } + /// 由 plugin 转发设备录音事件。 + void dispatchDeviceRecordEvent(JieliEvent raw) { + if (_evt.isClosed) return; + if (raw is DeviceRecordAudioEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent( + key: 'jieli.deviceRecord.audio', + data: { + 'streamId': raw.streamId, + 'sampleRate': raw.sampleRate, + 'channels': raw.channels, + 'bitsPerSample': raw.bitsPerSample, + 'tsMs': raw.tsMs, + 'pcm': raw.pcm, + }, + ), + )); + } else if (raw is DeviceRecordStartEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent( + key: 'jieli.deviceRecord.start', + data: {'sampleRate': raw.sampleRate, 'tsMs': raw.tsMs}, + ), + )); + } else if (raw is DeviceRecordStopEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent( + key: 'jieli.deviceRecord.stop', + data: {'tsMs': raw.tsMs}, + ), + )); + } else if (raw is DeviceRecordErrorEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.error, + deviceId: deviceId, + errorCode: 'device.feature_failed', + errorMessage: 'jieli.deviceRecord: ${raw.code} ${raw.message ?? ''}', + )); + } + } + /// 由 plugin 转发 RcspInitEvent。 void updateRcspInit(bool success) { if (success) { @@ -298,6 +350,22 @@ class JieliDeviceSession implements DeviceSession { ); return {'ok': ok}; + // ── 设备录音 ───────────────────────────────────────── + case 'jieli.deviceRecord.start': + await Jielihome.instance.deviceRecordStart( + address: deviceId, + sampleRate: (args['sampleRate'] as int?) ?? 16000, + ); + return {'ok': true}; + + case 'jieli.deviceRecord.stop': + await Jielihome.instance.deviceRecordStop(); + return {'ok': true}; + + case 'jieli.deviceRecord.status': + final s = await Jielihome.instance.deviceRecordStatus(); + return s ?? const {}; + // ── 自定义 RCSP 命令 ──────────────────────────────── case 'jieli.cmd.send': final opCode = args['opCode']; diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt new file mode 100644 index 000000000..81c54fb5c --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt @@ -0,0 +1,149 @@ +package com.aiagent.device_plugin_interface + +import kotlinx.coroutines.flow.Flow + +/** + * AI 助理场景的音频载荷编码。 + * + * 与 [CallAudioCodec] 并列但语义独立 —— AI 助理是单向对话(用户麦 → AI → 耳机扬声器), + * 没有通话翻译的 UPLINK/DOWNLINK 概念。 + */ +enum class AssistantAudioCodec { OPUS, PCM_S16LE } + +/** AI 助理场景的音频格式描述。 */ +data class AssistantAudioFormat( + val codec: AssistantAudioCodec, + val sampleRate: Int, + val channels: Int, + val frameMs: Int, +) { + companion object { + /** 16 kHz / mono / 20 ms 一帧 OPUS(耳机原生格式) */ + val OPUS_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.OPUS, 16000, 1, 20) + + /** 16 kHz / mono / 20 ms 一帧 PCM_S16LE = 640 字节 */ + val PCM_S16LE_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.PCM_S16LE, 16000, 1, 20) + } +} + +/** + * device → 编排器:一帧用户麦克风音频(单路上行)。 + * + * 与 [CallAudioFrame] 的区别:**无 leg 字段**。AI 助理场景只有"用户说话"一路输入, + * 不存在"远端通话音"的概念。 + */ +data class AssistantAudioFrame( + val codec: AssistantAudioCodec, + val sampleRate: Int, + val channels: Int, + val bytes: ByteArray, + /** 单调递增帧序号;丢帧后**不得**回填。 */ + val sequence: Long, + /** 单调递增的捕获时间戳(微秒)。 */ + val timestampUs: Long, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AssistantAudioFrame) return false + return sequence == other.sequence && bytes.contentEquals(other.bytes) + } + override fun hashCode(): Int { + var r = sequence.hashCode() + r = 31 * r + bytes.contentHashCode() + return r + } +} + +/** + * 编排器 → device:把 AI 的 TTS 音频回灌给耳机扬声器播放。 + * + * 与 [TranslatedAudioFrame] 的区别:**无 leg 字段**。AI 助理只有一个回放方向 + * (AI 回复 → 戴耳机的用户),不存在"回灌给对端"的概念。 + * + * @param isFinal 本帧是否为当前 utterance 的最后一帧。device 端可能据此决定是否 + * 触发整段编码 / 一次性下发(例如 jieli RCSP 场景下按"一段 = 一个 AudioData"下发)。 + */ +data class AssistantPlaybackFrame( + val codec: AssistantAudioCodec, + val sampleRate: Int, + val channels: Int, + val bytes: ByteArray, + val isFinal: Boolean = false, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AssistantPlaybackFrame) return false + return codec == other.codec && bytes.contentEquals(other.bytes) + } + override fun hashCode(): Int { + var r = codec.hashCode() + r = 31 * r + bytes.contentHashCode() + return r + } +} + +/** AI 助理场景下由 device 抛出的错误(不致命:编排器决定是否 stop)。 */ +data class AssistantError( + val code: String, + val message: String, +) + +/** + * AI 助理设备能力端口(device 侧)。 + * + * 由设备厂商插件按需实现(例如杰理基于 RCSP `MODE_CALL_TRANSLATION` 的 `UP_LINK` 上行 + + * `OUT_UPLINK` 回灌落地一份 `JieliAssistantPort`)。`assistant_server` 编排器统一通过 + * 本接口拿用户麦克风 PCM、回灌 AI TTS,不依赖具体 vendor。 + * + * 与 [DeviceCallTranslationPort] 并列但**语义完全独立**: + * - 不存在 leg 概念(通话翻译特有的上下行) + * - 上行流只有一路(用户麦) + * - 下行回灌只有一路(耳机扬声器) + * + * 生命周期: + * ``` + * idle → enter(format) → active → exit() → idle + * ``` + * + * 铁律: + * - 同一设备同一时刻**至多一个** active 端口;重复 `enter` 抛 + * `IllegalStateException("device.assistant.busy")`; + * - 协商格式([enter] 入参)必须出自 [supportedSourceFormats] 列出的范围; + * - [reportPlayback] 的 codec/sampleRate/channels 必须落在 [supportedSinkFormats] 内; + * - [exit] 后 [audioFrames]/[errors] 仍可订阅,但不再产新事件。 + */ +interface DeviceAssistantPort { + /** 该端口能向上派发的用户麦克风音频格式集合。 */ + fun supportedSourceFormats(): Set + + /** 该端口能接受的 TTS 回灌格式集合。 */ + fun supportedSinkFormats(): Set + + /** + * 进入 AI 助理模式。 + * + * @param sourceFormat 协商好的源音频格式(必须 ∈ [supportedSourceFormats])。 + * device 侧据此决定是否做 OPUS↔PCM 转换。 + */ + fun enter(sourceFormat: AssistantAudioFormat) + + /** + * 用户麦克风上行音频帧流。`enter` 之后开始派发,`exit` 后停止。 + * 必须是 broadcast / replayable 不变的实现,编排器多次订阅安全。 + */ + val audioFrames: Flow + + /** + * 把 AI 的 TTS 音频回灌给耳机扬声器。 + * + * @throws IllegalArgumentException codec 不在 [supportedSinkFormats] 内 + * @throws IllegalStateException 当前未在 active 状态 + */ + fun reportPlayback(frame: AssistantPlaybackFrame) + + /** 退出 AI 助理模式,归还设备 RCSP / 音频通道。 */ + fun exit() + + /** 错误流。**不致命**——编排器自行决定是否 [exit]。 */ + val errors: Flow +} diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt index bf3a6b408..04297b624 100644 --- a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt @@ -68,5 +68,21 @@ interface NativeDeviceSession { */ fun callTranslationPort(): DeviceCallTranslationPort? = null + /** + * 该 session 暴露的 AI 助理端口;vendor 无该能力时返回 null。 + * + * 给 native 编排器(assistant_server)vendor-agnostic 拿端口的入口—— + * 与 [callTranslationPort] 语义独立(AI 助理为单向对话,无 UPLINK/DOWNLINK 两条腿)。 + */ + fun assistantPort(): DeviceAssistantPort? = null + + /** + * 该 session 暴露的 OTA 端口;vendor 无 [DeviceCapability.OTA] 时返回 null。 + * + * 编排器 / UI 通过本接口拿到 vendor-agnostic 的 OTA 控制;不依赖具体厂商。 + * 端口生命周期与 session 绑定 —— session disconnect 时端口必须自动收尾。 + */ + fun otaPort(): DeviceOtaPort? = null + fun disconnect() } diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt index 6f7dc2ff7..d4e4f667d 100644 --- a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt @@ -54,9 +54,11 @@ data class DevicePluginConfig( /** 扫描过滤条件。 */ data class DeviceScanFilter( val namePrefix: String? = null, + val nameList: List? = null, val serviceUuids: List? = null, val vendor: String? = null, val minRssi: Int? = null, + val skipUnnamed: Boolean? = null, ) { companion object { fun fromMap(map: Map<*, *>?): DeviceScanFilter? { @@ -64,9 +66,11 @@ data class DeviceScanFilter( @Suppress("UNCHECKED_CAST") return DeviceScanFilter( namePrefix = map["namePrefix"] as? String, + nameList = (map["nameList"] as? List<*>)?.map { it.toString() }, serviceUuids = (map["serviceUuids"] as? List<*>)?.map { it.toString() }, vendor = map["vendor"] as? String, minRssi = (map["minRssi"] as? Number)?.toInt(), + skipUnnamed = map["skipUnnamed"] as? Boolean, ) } } @@ -194,4 +198,11 @@ object DeviceErrorCode { const val NO_ACTIVE_SESSION = "device.no_active_session" const val PLUGIN_NOT_INITIALIZED = "device.plugin_not_initialized" const val INVALID_ARGUMENT = "device.invalid_argument" + + // OTA + const val OTA_BUSY = "device.ota_busy" + const val OTA_INQUIRE_REFUSED = "device.ota_inquire_refused" + const val OTA_VERIFY_FAILED = "device.ota_verify_failed" + const val OTA_FILE_INVALID = "device.ota_file_invalid" + const val OTA_TRANSFER_FAILED = "device.ota_transfer_failed" } diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt new file mode 100644 index 000000000..dd9eb910c --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt @@ -0,0 +1,170 @@ +package com.aiagent.device_plugin_interface + +import kotlinx.coroutines.flow.Flow + +/** + * OTA 升级请求 —— 多态请求层级(与 Dart [DeviceOtaRequest] 对齐)。 + * + * 业务层只构造其中一种子类;插件实现按 `when` 分派。 + * 厂商不支持的子类一律抛 [DeviceException]([DeviceErrorCode.NOT_SUPPORTED]), + * **禁止**静默退化(业务侧才有"是否回退到本地下载"的语义)。 + */ +sealed class DeviceOtaRequest { + /** 单块大小。null = 走厂商默认。 */ + abstract val blockSize: Int? + + /** 整段 OTA 的超时;null = 不限。 */ + abstract val timeoutMs: Long? + + /** 本地文件:app 自己下载好后放在沙盒里。 */ + data class File( + val filePath: String, + override val blockSize: Int? = null, + override val timeoutMs: Long? = null, + ) : DeviceOtaRequest() + + /** 内存字节流:固件已经在内存里。 */ + data class Bytes( + val bytes: ByteArray, + override val blockSize: Int? = null, + override val timeoutMs: Long? = null, + ) : DeviceOtaRequest() { + override fun equals(other: Any?): Boolean = + this === other || (other is Bytes && bytes.contentEquals(other.bytes)) + override fun hashCode(): Int = bytes.contentHashCode() + } + + /** 远程 URL:让 native 自己下载;不支持时抛 NOT_SUPPORTED 由调用方退回。 */ + data class Url( + val url: String, + val headers: Map = emptyMap(), + override val blockSize: Int? = null, + override val timeoutMs: Long? = null, + ) : DeviceOtaRequest() + + /** 厂商扩展:差分包 / 双备份 / fileFlag 等私有参数。 */ + data class Vendor( + val vendorKey: String, + val payload: Map, + override val blockSize: Int? = null, + override val timeoutMs: Long? = null, + ) : DeviceOtaRequest() + + companion object { + /** + * 从 method channel map 反序列化(kind 字段做 tag)。 + * 不识别的 kind 抛 [DeviceException]([DeviceErrorCode.INVALID_ARGUMENT])。 + */ + @Suppress("UNCHECKED_CAST") + fun fromMap(map: Map<*, *>): DeviceOtaRequest { + val kind = map["kind"] as? String + ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota: kind required") + val blockSize = (map["blockSize"] as? Number)?.toInt() + val timeoutMs = (map["timeoutMs"] as? Number)?.toLong() + return when (kind) { + "file" -> File( + filePath = map["filePath"] as? String + ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.file: filePath required"), + blockSize = blockSize, + timeoutMs = timeoutMs, + ) + "bytes" -> Bytes( + bytes = (map["bytes"] as? ByteArray) + ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.bytes: bytes required"), + blockSize = blockSize, + timeoutMs = timeoutMs, + ) + "url" -> Url( + url = map["url"] as? String + ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.url: url required"), + headers = (map["headers"] as? Map) ?: emptyMap(), + blockSize = blockSize, + timeoutMs = timeoutMs, + ) + "vendor" -> Vendor( + vendorKey = map["vendorKey"] as? String + ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.vendor: vendorKey required"), + payload = (map["payload"] as? Map) ?: emptyMap(), + blockSize = blockSize, + timeoutMs = timeoutMs, + ) + else -> throw DeviceException( + DeviceErrorCode.INVALID_ARGUMENT, + "ota: unknown kind \"$kind\"", + ) + } + } + } +} + +/** OTA 全局状态机(与 Dart [DeviceOtaState] 一一对应)。 */ +enum class DeviceOtaState { + IDLE, + /** 容器层下载远程固件中(Url 请求专属,厂商插件看不到)。 */ + DOWNLOADING, + INQUIRING, + NOTIFYING_SIZE, + ENTERING, + TRANSFERRING, + VERIFYING, + REBOOTING, + DONE, + FAILED, + CANCELLED, +} + +/** OTA 进度事件。 */ +data class DeviceOtaProgress( + val state: DeviceOtaState, + val sentBytes: Long, + val totalBytes: Long, + /** 0..100;未知或非 transferring 阶段可为 -1。 */ + val percent: Int, + val tsMs: Long, + /** 终态时携带错误码(state ∈ {FAILED});其它阶段为 null。 */ + val errorCode: String? = null, + val errorMessage: String? = null, +) { + fun toMap(): Map = mapOf( + "state" to state.name, + "sentBytes" to sentBytes, + "totalBytes" to totalBytes, + "percent" to percent, + "tsMs" to tsMs, + "errorCode" to errorCode, + "errorMessage" to errorMessage, + ) + + val isTerminal: Boolean + get() = state == DeviceOtaState.DONE || + state == DeviceOtaState.FAILED || + state == DeviceOtaState.CANCELLED +} + +/** + * OTA 端口(同 [DeviceCallTranslationPort] 的设计风格)。 + * + * 单设备 OTA 互斥:同时只能跑一个 [start],重复 start 抛 + * [DeviceException]([DeviceErrorCode.OTA_BUSY])。 + * + * 生命周期与铁律: + * 1. `start` 之后 [progressStream] 必有终态(DONE/FAILED/CANCELLED),不得悬挂; + * 2. 设备掉线时端口须主动派 FAILED 收尾; + * 3. `cancel` 后短时间内(≤ 5s)须收到 CANCELLED;超时强制收尾; + * 4. **不**要求支持断点续传,但若实现了须在 SDK 内部完成,对外只露统一进度。 + */ +interface DeviceOtaPort { + fun start(request: DeviceOtaRequest) + fun cancel() + val isRunning: Boolean + val progressStream: Flow +} + +/** OTA 错误码补充(与 Dart 镜像)。 */ +object OtaErrorCode { + const val BUSY = DeviceErrorCode.OTA_BUSY + const val INQUIRE_REFUSED = DeviceErrorCode.OTA_INQUIRE_REFUSED + const val VERIFY_FAILED = DeviceErrorCode.OTA_VERIFY_FAILED + const val FILE_INVALID = DeviceErrorCode.OTA_FILE_INVALID + const val TRANSFER_FAILED = DeviceErrorCode.OTA_TRANSFER_FAILED +} diff --git a/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart b/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart index 3db4b1dba..adf0f2984 100644 --- a/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart +++ b/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart @@ -4,5 +4,6 @@ export 'src/device_event.dart'; export 'src/device_exception.dart'; export 'src/device_info.dart'; export 'src/device_manager.dart'; +export 'src/device_ota.dart'; export 'src/device_plugin.dart'; export 'src/device_session.dart'; diff --git a/local_plugins/device_plugin_interface/lib/src/device_exception.dart b/local_plugins/device_plugin_interface/lib/src/device_exception.dart index 4928cd5da..9d13a5ea6 100644 --- a/local_plugins/device_plugin_interface/lib/src/device_exception.dart +++ b/local_plugins/device_plugin_interface/lib/src/device_exception.dart @@ -26,6 +26,13 @@ class DeviceErrorCode { static const String noActiveSession = 'device.no_active_session'; static const String invalidArgument = 'device.invalid_argument'; static const String pluginNotInitialized = 'device.plugin_not_initialized'; + + // OTA + static const String otaBusy = 'device.ota_busy'; + static const String otaInquireRefused = 'device.ota_inquire_refused'; + static const String otaVerifyFailed = 'device.ota_verify_failed'; + static const String otaFileInvalid = 'device.ota_file_invalid'; + static const String otaTransferFailed = 'device.ota_transfer_failed'; } /// 设备域统一异常。 diff --git a/local_plugins/device_plugin_interface/lib/src/device_info.dart b/local_plugins/device_plugin_interface/lib/src/device_info.dart index 482585ad0..04daa7b6f 100644 --- a/local_plugins/device_plugin_interface/lib/src/device_info.dart +++ b/local_plugins/device_plugin_interface/lib/src/device_info.dart @@ -109,19 +109,32 @@ enum DeviceConnectionState { } /// 扫描过滤条件。所有字段均可选;`null` 表示不过滤。 +/// +/// - [namePrefix]:设备名前缀(单串,跨厂商);与 [nameList] 同时存在时各厂商 +/// 实现可自行选择优先级,杰里实现优先 [nameList]。 +/// - [nameList]:设备名命中列表(精确匹配);空 = 不按名过滤。 +/// - [serviceUuids]:UUID 命中列表;杰理实现按 `BleScanMessage.flagContent` +/// 做忽略大小写的 contains 匹配。 @immutable class DeviceScanFilter { const DeviceScanFilter({ this.namePrefix, + this.nameList, this.serviceUuids, this.vendor, this.minRssi, + this.skipUnnamed, }); final String? namePrefix; + final List? nameList; final List? serviceUuids; final String? vendor; final int? minRssi; + + /// 是否跳过没有 `name` 的广播(环境 BLE 噪声过滤);`null` = 由实现选默认。 + /// 杰理实现默认 `true`。 + final bool? skipUnnamed; } /// 连接选项。 diff --git a/local_plugins/device_plugin_interface/lib/src/device_ota.dart b/local_plugins/device_plugin_interface/lib/src/device_ota.dart new file mode 100644 index 000000000..feb73a130 --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_ota.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// OTA 升级请求 —— 多态请求层级。 +/// +/// 业务层只构造其中一种子类;插件实现按 `runtimeType` 分派。 +/// 厂商不支持的子类一律抛 `DeviceException(DeviceErrorCode.notSupported)`, +/// **禁止**静默退化(业务侧才有"是否回退到本地下载"的语义)。 +@immutable +sealed class DeviceOtaRequest { + const DeviceOtaRequest({this.blockSize, this.timeout}); + + /// 单块大小。null = 走厂商默认(杰理 RCSP 当前 512)。 + final int? blockSize; + + /// 整段 OTA 的超时;null = 不限。 + final Duration? timeout; +} + +/// 本地文件:app 自己下载好后放在沙盒里。 +class DeviceOtaFileRequest extends DeviceOtaRequest { + const DeviceOtaFileRequest({ + required this.filePath, + super.blockSize, + super.timeout, + }); + + final String filePath; +} + +/// 内存字节流:固件已经在 dart 内存里。 +/// +/// 实现方可以选择直接喂给 SDK,也可以落盘后走文件路径——通过 method +/// channel 传字节较大固件不经济,建议 < 4MB 用本子类,更大的用文件请求。 +class DeviceOtaBytesRequest extends DeviceOtaRequest { + const DeviceOtaBytesRequest({ + required this.bytes, + super.blockSize, + super.timeout, + }); + + final Uint8List bytes; +} + +/// 远程 URL:让 native 自己下载。 +/// +/// 厂商插件**不支持**时抛 `device.not_supported`,由调用方退回到 +/// "app 端下载 → 用 [DeviceOtaFileRequest]" 路径。 +class DeviceOtaUrlRequest extends DeviceOtaRequest { + const DeviceOtaUrlRequest({ + required this.url, + this.headers = const {}, + super.blockSize, + super.timeout, + }); + + final String url; + final Map headers; +} + +/// 厂商扩展逃生口(差分包 / 双备份 / fileFlag 等私有参数)。 +/// +/// 使用时业务层就明确放弃了"厂商无关"的承诺。`vendorKey` 必须等于当前 active +/// vendor,否则插件抛 `device.invalid_argument`。 +class DeviceOtaVendorRequest extends DeviceOtaRequest { + const DeviceOtaVendorRequest({ + required this.vendorKey, + required this.payload, + super.blockSize, + super.timeout, + }); + + final String vendorKey; + final Map payload; +} + +/// OTA 全局状态机。 +/// +/// ``` +/// idle +/// → downloading // [仅 Url 请求] 由容器层下载固件到本地,转 file 请求 +/// → inquiring // 询问设备能否升级 +/// → notifyingSize // 告知固件大小 +/// → entering // 设备进入升级模式 +/// → transferring(*progress*) +/// → verifying +/// → rebooting +/// → done | failed | cancelled +/// ``` +/// +/// `failed` / `cancelled` 是终态,回到 idle 之前不应再派进度事件。 +/// +/// 注:`downloading` 由 `device_manager` 容器层产生(不在厂商插件内)—— +/// Url 请求统一由容器下载到沙盒后转换为 file 请求再分派给 vendor port, +/// 厂商插件只看到 `inquiring` 起始的标准流程。 +enum DeviceOtaState { + idle, + downloading, + inquiring, + notifyingSize, + entering, + transferring, + verifying, + rebooting, + done, + failed, + cancelled, +} + +/// OTA 进度事件。 +/// +/// - `transferring` 阶段进度上报频率应控制在 ≥ 5Hz / ≤ 20Hz; +/// - 非 transferring 阶段允许 sentBytes / totalBytes 不准确(可置 0), +/// UI 一律以 [state] 为主; +/// - `done` 时 [percent] = 100,`failed` / `cancelled` 时 [percent] 可能为 -1。 +@immutable +class DeviceOtaProgress { + const DeviceOtaProgress({ + required this.state, + required this.sentBytes, + required this.totalBytes, + required this.percent, + required this.tsMs, + this.errorCode, + this.errorMessage, + }); + + final DeviceOtaState state; + final int sentBytes; + final int totalBytes; + + /// 0..100;未知或非 transferring 阶段可为 -1。 + final int percent; + + /// 事件时间戳(毫秒,挂墙时间),调试用。 + final int tsMs; + + /// 终态时携带错误码(state ∈ {failed});其它阶段为 null。 + /// 命名空间 `device.`,参见 [DeviceErrorCode]。 + final String? errorCode; + final String? errorMessage; + + bool get isTerminal => + state == DeviceOtaState.done || + state == DeviceOtaState.failed || + state == DeviceOtaState.cancelled; +} + +/// OTA 端口(同 [DeviceCallTranslationPort] 的设计风格)。 +/// +/// 单设备 OTA 互斥:同时只能跑一个 [start],重复 start 抛 +/// `DeviceException('device.ota_busy')`。 +/// +/// 生命周期与铁律: +/// 1. `start` 之后 [progressStream] 必有终态(done/failed/cancelled),不得悬挂; +/// 2. 设备掉线(`disconnected*`)时端口须主动派 `failed` 收尾; +/// 3. `cancel` 后短时间内(≤ 5s)须收到 `cancelled`,超时强制收尾; +/// 4. **不**要求支持断点续传,但若实现了须在 `progress.metadata` 里给出。 +abstract class DeviceOtaPort { + /// 启动 OTA。已在跑时抛 `device.ota_busy`。 + Future start(DeviceOtaRequest request); + + /// 取消当前 OTA;空跑时 no-op。 + Future cancel(); + + /// 当前是否在跑。 + bool get isRunning; + + /// 进度事件流(broadcast / multi-subscribe)。 + Stream get progressStream; +} diff --git a/local_plugins/device_plugin_interface/lib/src/device_session.dart b/local_plugins/device_plugin_interface/lib/src/device_session.dart index 7136c5ca9..9a37e1283 100644 --- a/local_plugins/device_plugin_interface/lib/src/device_session.dart +++ b/local_plugins/device_plugin_interface/lib/src/device_session.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'device_capability.dart'; import 'device_event.dart'; import 'device_info.dart'; +import 'device_ota.dart'; /// 已连接设备的会话句柄。生命周期: /// @@ -50,6 +51,15 @@ abstract class DeviceSession { Map args = const {}, ]); + // -------- OTA 端口 -------- + + /// 该 session 暴露的 OTA 端口;vendor 无 [DeviceCapability.ota] 时返回 null。 + /// + /// 编排器 / UI 通过本接口拿到 vendor-agnostic 的 OTA 控制;不依赖具体厂商。 + /// 端口生命周期与 session 绑定 —— session disconnect 时端口必须自动收尾 + /// (`failed` 事件 + `isRunning=false`)。 + DeviceOtaPort? otaPort() => null; + // -------- 关闭 -------- Future disconnect();