From 4ec8932aef623c18635d32245347311a7279ccd8 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Fri, 25 Sep 2026 21:32:49 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BD=A6=E8=BD=BD=E9=A6=99=E8=96=B0=20AI=20?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E5=94=A4=E9=86=92=E9=97=AE=E5=80=99?= =?UTF-8?q?=E3=80=81=E6=8C=89=E9=94=AE=E6=8F=92=E8=AF=9D=E3=80=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E8=BF=9E=E3=80=81=E9=80=80=E5=87=BA=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E3=80=81=E5=89=8D=E5=90=8E=E5=8F=B0=E4=B8=8E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=90=8C=E6=AD=A5=EF=BC=88=E5=9D=87=E5=8F=AA=E5=AF=B9?= =?UTF-8?q?=E9=A6=99=E8=96=B0=E7=94=9F=E6=95=88=EF=BC=8C=E6=81=92=E7=8E=84?= =?UTF-8?q?=E8=80=B3=E6=9C=BA=E6=B5=81=E7=A8=8B=E4=B8=8D=E5=8F=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话:服务端放弃回复(requestAccepted)时停播解门控,修掉互等 90s 的卡死;会话中再按唤醒键=打断重听; 唤醒后用应用音色随机回一句「我在」;退出(0x07 03)无应答不再干等 2s;设备上线复位 AI 状态, 防 App 被杀后设备停在 AI 模式、再唤醒 0 帧上行;Android 会话期间开前台服务,防切后台被冻结断 WS - 指令:extra_info.commands 为 JSON 字符串时也能解出 quit(新车载应用下发形状) - 连接:香薰断线/冷启动自动回连(带退避、OTA 与主动断开不回连);回连带上记住的 MAC; 设备断开后界面显示最后连接的那台(DeviceHub 落盘 lastUsedVendor) - iOS 经典蓝牙判断按 MAC 首字节±1/设备名匹配,连着就不再弹配对提示 - Android 播放器:车载会话(playbackOnly)回复间垫静音,防 A2DP 流挂起吞掉「我在」 - 聊天页:设备对话中底部按钮可「改用手机」;离开正显示香薰对话的页面时结束会话并通知设备 - 车载助手百炼应用 ID 改为 mm_127ddb82ba1244a99d2feaef0c1f Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/data/models/assistant_directive.dart | 19 +- .../services/device_ai_session_service.dart | 207 +++++++++++++++++- .../lib/data/utils/pcm_stream_player.dart | 7 +- apps/client/lib/devices/device_hub.dart | 33 ++- .../devices/eaicar/eaicar_device_plugin.dart | 176 ++++++++++++++- .../lib/devices/eaicar/eaicar_ota_port.dart | 3 + .../eaicar/bindings/eaicar_chat_binding.dart | 2 +- apps/client/lib/modules/eaicar/module.dart | 3 + .../emai/controllers/emai_controller.dart | 47 +++- .../lib/modules/emai/views/emai_view.dart | 28 +++ .../azure_speech/AzureSpeechPlugin.kt | 3 +- .../azure_speech/PcmSpeakerPlayer.kt | 48 +++- .../client/test/assistant_directive_test.dart | 16 ++ ...½¦载香薰-接入设计与落地文档.md | 6 +- 14 files changed, 572 insertions(+), 26 deletions(-) diff --git a/apps/client/lib/data/models/assistant_directive.dart b/apps/client/lib/data/models/assistant_directive.dart index 40867aa5..df470b7e 100644 --- a/apps/client/lib/data/models/assistant_directive.dart +++ b/apps/client/lib/data/models/assistant_directive.dart @@ -94,9 +94,10 @@ class AssistantDirective { final extra = output['extra_info']; if (extra is! Map) return const []; final calls = extra['tool_calls']; - final commands = extra['commands']; + final rawCommands = _decodeIfString(extra['commands']); + final List commands = rawCommands is List ? rawCommands : const []; final hasCalls = calls is List && calls.isNotEmpty; - final hasCommands = commands is List && commands.isNotEmpty; + final hasCommands = commands.isNotEmpty; if (!hasCalls && !hasCommands) return const []; final dialogId = (output['dialog_id'] ?? '').toString(); @@ -159,6 +160,20 @@ class AssistantDirective { return result; } + /// `commands` 有时是数组,有时是**数组的 JSON 字符串**。 + /// + /// ⚠️ 2026-09-25 真机:换成车载助手新应用(mm_127ddb82…)后,同样的 quit 指令 + /// 下发成了 `"commands":"[{\"intent_info\":…,\"name\":\"quit\"}]"`, + /// 只认 List 就解析出 0 条——用户说「退出聊天」,AI 回「好的」,会话却不结束。 + static Object? _decodeIfString(Object? v) { + if (v is! String || v.trim().isEmpty) return v; + try { + return json.decode(v); + } catch (_) { + return v; + } + } + /// 解析 `extra_info.commands` —— **另一种指令形状**。 /// /// ⚠️ 2026-09-11 真机发现:EaiCar 车载助手下发的指令不在 `tool_calls` 里, diff --git a/apps/client/lib/data/services/device_ai_session_service.dart b/apps/client/lib/data/services/device_ai_session_service.dart index 3c8684d1..39d95da9 100644 --- a/apps/client/lib/data/services/device_ai_session_service.dart +++ b/apps/client/lib/data/services/device_ai_session_service.dart @@ -4,8 +4,10 @@ import 'dart:typed_data'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; +import '../../core/utils/audio_foreground_service.dart'; import '../../core/utils/logger.dart'; import '../../devices/device_hub.dart'; +import '../../devices/device_vendors.dart'; import '../models/agent_module.dart'; import '../models/emai_message.dart'; import '../models/user_Info.dart' as user_info; @@ -181,6 +183,10 @@ class DeviceAiSessionService extends GetxService { int _micPackets = 0; int _downPackets = 0; + /// 本次会话开了 Android 前台服务(结束时要关,别关掉别人开的) + bool _holdsForeground = false; + int _micPeakRms = 0; + EmaiChatStore get _store => EmaiChatStore.to; EmaiMessage? _streamingUser; EmaiMessage? _streamingAssistant; @@ -192,6 +198,7 @@ class DeviceAiSessionService extends GetxService { _wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) { Logger.e(_tag, '唤醒事件流错误: $e'); }); + _onlineWorker = ever>(hub.sessions, _resetAiStateOnOnline); _hubSub = hub.sessionEvents.listen((e) { if (e.type != DeviceSessionEventType.connectionStateChanged) return; if (!isActive.value || _session?.deviceId != e.deviceId) return; @@ -221,12 +228,37 @@ class DeviceAiSessionService extends GetxService { @override void onClose() { + _onlineWorker?.dispose(); _wakeSub?.cancel(); _hubSub?.cancel(); _teardown(notifyDevice: true, playEndTone: false); super.onClose(); } + // ---------------- 设备上线复位 ---------------- + // + // ⚠️ 2026-09-25 真机:对话中 App 被划掉(或被系统杀掉)时来不及通知设备退出 AI, + // 设备一直停在 AI 模式;App 重开回连后再按唤醒键,设备照样报 0xCC 0x11, + // 却一帧麦克风都不发(原生统计 framesIn=0),用户说什么都没反应。 + // 所以设备一上线、且此刻没有会话在跑,就先把它的 AI 状态复位成「关闭」。 + // 只对车载香薰做:它的退出是单向的 0x07 03(无副作用);恒玄的 AA 65 是另一套流程,不碰。 + Worker? _onlineWorker; + final Set _onlineSeen = {}; + + void _resetAiStateOnOnline(List list) { + final ids = list.map((s) => s.deviceId).toSet(); + _onlineSeen.removeWhere((id) => !ids.contains(id)); + for (final s in list) { + if (!_onlineSeen.add(s.deviceId)) continue; + if (s.vendor != DeviceVendors.eaicar || isActive.value) continue; + Logger.i(_tag, '设备上线,复位它的 AI 状态(防上次会话没收尾)'); + s.invokeFeature(DeviceFeatures.aiExit).catchError((Object e) { + Logger.w(_tag, '上线复位 AI 状态失败: $e'); + return {}; + }); + } + } + // ---------------- 设备事件 ---------------- void _onWake(DeviceWakeEvent e) { @@ -321,8 +353,10 @@ class DeviceAiSessionService extends GetxService { void _onMicPcm(Uint8List pcm) { if (!isActive.value || pcm.isEmpty) return; _micPackets++; + final rms = rmsOfPcm16(pcm); + if (rms > _micPeakRms) _micPeakRms = rms; // 用户在说话 = 有活动。只看能量、带节流,静音帧不续命 - if (!_uplinkGated && rmsOfPcm16(pcm) >= _micActivityRms) { + if (!_uplinkGated && rms >= _micActivityRms) { final now = DateTime.now(); if (_lastMicActivityAt == null || now.difference(_lastMicActivityAt!) >= _micActivityThrottle) { @@ -331,8 +365,10 @@ class DeviceAiSessionService extends GetxService { } } if (_micPackets == 1 || _micPackets % 50 == 0) { + // 带上这段时间的峰值音量:排查「在说话却没识别」时,先分清是没声音还是服务端没认 Logger.i(_tag, - '设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}'); + '设备上行 #$_micPackets (${pcm.length}B/包, 峰值RMS=$_micPeakRms)${_uplinkGated ? ' [门控中]' : ''}'); + _micPeakRms = 0; } // 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是 // "真的没人说话",而不是一段数据缺口 @@ -344,7 +380,15 @@ class DeviceAiSessionService extends GetxService { Future _handleStart(String deviceId) async { if (isActive.value) { - Logger.w(_tag, '已有会话在跑,忽略重复唤醒'); + // 会话中又按了一次唤醒键 = 用户要插话(「别说了,听我说」)。 + // ⚠️ 2026-09-25 以前一律忽略:AI 正在念一段 60 多秒的回答,用户连按两次设备键 + // 都没反应,设备以为进了新一轮、App 还在念旧的——两边状态对不上,看着就是卡死。 + // 只在 Dart 链路这么做(车载香薰);恒玄原生直连的行为保持不变。 + if (_native == null && _isEaiCar && _session?.deviceId == deviceId) { + _bargeIn(); + } else { + Logger.w(_tag, '已有会话在跑,忽略重复唤醒'); + } return; } final hub = DeviceHub.to; @@ -385,6 +429,9 @@ class DeviceAiSessionService extends GetxService { // 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持) _aiStateUnsupported = false; _quitAfterPlayout = false; + _greetPending = false; + _ownRequestPending = false; + _dropStaleDownlink = false; _micPackets = 0; _downPackets = 0; _uplinkGated = false; @@ -398,6 +445,14 @@ class DeviceAiSessionService extends GetxService { // ★ 先试原生直连:上行 PCM 与合成音频都不经过 Dart。 // 插件老 / 设备不是恒玄 → 返回 null,整条链路退回下面的 Dart 路径。 _native = await _tryOpenNativeDialog(session); + // Android 的等价物:前台服务把进程钉住。 + // ⚠️ 2026-09-25 华为 ALN-AL80 实测:车载香薰对话中按 Home,约 2s 后进程被 Pged-Freezer + // 冻住(上行 PCM 攒了 2s 才一次性冲出来),百炼 WS 随即断开,重连两次又各活 3s, + // 最后放弃——用户看到的就是「切后台就聊不了了」。恒玄耳机走原生链路,不在这里管。 + if (_native == null && _isEaiCar) { + unawaited(AudioForegroundService.start()); + _holdsForeground = true; + } try { if (_useNative) { _eventSub = _native!.events.listen(_onBailianEvent); @@ -424,6 +479,9 @@ class DeviceAiSessionService extends GetxService { await _playCue(CueTone.ding(sampleRate: _deviceRate)); _touchActivity(); + // 握手一完成就让助手用自己的音色说一句「我在」(见 _greetOnWake)。 + // 只在 Dart 链路做:原生直连(恒玄耳机)不经过 _svc,行为保持不变。 + _greetPending = _native == null && _isEaiCar; final ok = await _startDialog(); final elapsed = DateTime.now().difference(ackAt); if (elapsed > _ackDeadline) { @@ -455,6 +513,33 @@ class DeviceAiSessionService extends GetxService { break; } switch (e.type) { + case BailianEventType.started: + if (_greetPending) { + _greetPending = false; + _greetOnWake(); + } + break; + + case BailianEventType.requestAccepted: + // 我们自己发的唤醒问候也会回一个 requestAccepted,那不是「服务端放弃回复」 + if (_ownRequestPending) { + _ownRequestPending = false; + break; + } + // 服务端受理了一次新请求 = 它已经放弃正在回的那一轮(这一轮不会再有 + // respondingEnded)。门若还开着,上行就一直是静音、服务端在 listening + // 里干等,双方互等到 _gateSafety 的 90s 才解开(2026-09-25 车载香薰实测)。 + // 所以:丢掉那一轮没播完的音频,立刻关门,把耳朵还给用户。 + if (_uplinkGated && _isEaiCar) { + Logger.w(_tag, '服务端放弃了本轮回复(requestAccepted),停播并解除门控'); + _spk?.discardPending(); + _cancelPlayoutTimer(); + _store.finalize(_streamingAssistant); + _streamingAssistant = null; + _closeGate(); + } + break; + case BailianEventType.speechStarted: // 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的 if (!_uplinkGated) { @@ -483,6 +568,7 @@ class DeviceAiSessionService extends GetxService { break; case BailianEventType.respondingStarted: + _dropStaleDownlink = false; _respondingBytes = 0; _firstAudioAt = null; _respondingStartedAt = DateTime.now(); @@ -576,15 +662,14 @@ class DeviceAiSessionService extends GetxService { _teardown(notifyDevice: true); } break; - - default: - break; } } /// 退回 Dart 链路时才会跑(原生直连时 PCM 不经过这里) void _onDownstreamPcm(Uint8List pcm) { if (pcm.isEmpty) return; + // 被按键打断的那一轮,服务端可能还在往下发尾巴,丢掉,等下一轮 respondingStarted + if (_dropStaleDownlink) return; _noteDownstream(pcm.length); _spk?.write(pcm); } @@ -612,6 +697,79 @@ class DeviceAiSessionService extends GetxService { /// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`) static const String _quitDirective = 'quit'; + /// 当前会话是不是车载香薰。2026-09-25 为香薰加的会话行为(唤醒问候、按键插话、 + /// 服务端放弃回复时解门控、前台服务、0 包诊断)**一律只对它生效**, + /// 恒玄耳机的会话流程保持原样、一个分支都不走。 + bool get _isEaiCar => _session?.vendor == DeviceVendors.eaicar; + + /// 按键插话后,丢弃被打断那一轮剩下的下行音频(见 [_bargeIn])。 + bool _dropStaleDownlink = false; + + /// 会话中再次按唤醒键:停掉正在播的回复、立刻重新聆听,并把状态同步给设备。 + void _bargeIn() { + Logger.i(_tag, '会话中再次按键唤醒:打断当前播报,重新聆听'); + // respondingEnded 已到 = 服务端发完了,只剩本地在播(_playoutTimer 在等播完) + final serverStillSending = + _svc.state.value == BailianDialogState.responding && + _playoutTimer == null; + final localPlaying = _uplinkGated || _playoutTimer != null; + _spk?.discardPending(); + _cancelPlayoutTimer(); + _quitAfterPlayout = false; + if (serverStillSending) { + // 服务端还在生成:请它停下,并丢掉这一轮之后到的尾巴 + _dropStaleDownlink = true; + _svc.interrupt(); + } + if (serverStillSending || localPlaying) { + // 告诉服务端本地已经播完,它才会回到 Listening + _notifyLocalRespondingEnded(); + } + _store.finalize(_streamingAssistant); + _streamingAssistant = null; + _closeGate(); + _pushAiState(DeviceAiState.listening); + _touchActivity(); + } + + // ---------------- 唤醒问候 ---------------- + // + // 2026-09-25 用户要求:唤醒后马上随机回一句「我在 / 有什么可以帮你」, + // 让人知道唤醒成功了。走 RequestToRespond(type=transcript):服务端用**应用自己的音色** + // 直接合成这段文字,不经过大模型,所以快、也不会被模型改写。 + // 它走的是正常的回复链路(respondingStarted → 下行 → respondingEnded), + // 门控、播完估算、LocalRespondingEnded 都不用另写。 + + /// 本次会话握手完成后要不要问候(每次唤醒只一次;WS 重连后的 Started 不再问候)。 + bool _greetPending = false; + + /// 刚发出问候、等它的 requestAccepted——那一条不能当成「服务端放弃了本轮回复」。 + bool _ownRequestPending = false; + + static const List _greetingsZh = [ + '我在', + '我在呢', + '你好呀,有什么可以帮你?', + '在的,请说', + '嗨,我在听', + ]; + static const List _greetingsEn = [ + "I'm here", + 'Hi, how can I help?', + "Yes? I'm listening", + ]; + + void _greetOnWake() { + final zh = (Get.locale?.languageCode ?? 'zh') == 'zh'; + final list = zh ? _greetingsZh : _greetingsEn; + final text = list[DateTime.now().microsecond % list.length]; + Logger.i(_tag, '唤醒问候: $text'); + _ownRequestPending = true; + _svc.sendText(text, speakDirectly: true); + // 服务端万一不回 requestAccepted,别让这个标记吞掉之后真正的那一次 + Timer(const Duration(seconds: 3), () => _ownRequestPending = false); + } + /// 收到 quit 指令,等这一轮告别语播完就收尾 bool _quitAfterPlayout = false; @@ -781,7 +939,13 @@ class DeviceAiSessionService extends GetxService { if (!isActive.value) return; Logger.i(_tag, '请求设备退出 AI 模式'); final s = _session; - if (s != null) await _sendExit(s); + final noAck = s != null && await _sendExit(s); + if (noAck) { + // 车载香薰的「退出」是单向的 0x07 03,设备从不回应答—— + // 干等 2s 只会让 App 比设备晚 2s 结束(2026-09-25 日志里每次都是超时收尾) + await _teardown(notifyDevice: false); + return; + } _stopAckTimer?.cancel(); _stopAckTimer = Timer(_stopAckTimeout, () { if (!isActive.value) return; @@ -790,12 +954,16 @@ class DeviceAiSessionService extends GetxService { }); } - Future _sendExit(DeviceSession s) async { - if (s.state != DeviceConnectionState.ready) return; + /// 发 ai.exit。返回 true 表示该设备**不会回结束应答**(插件回了 `noAck`), + /// 调用方可以直接收尾。 + Future _sendExit(DeviceSession s) async { + if (s.state != DeviceConnectionState.ready) return false; try { - await s.invokeFeature(DeviceFeatures.aiExit); + final r = await s.invokeFeature(DeviceFeatures.aiExit); + return r['noAck'] == true; } catch (e) { Logger.w(_tag, '发送 ai.exit 失败: $e'); + return false; } } @@ -883,9 +1051,21 @@ class DeviceAiSessionService extends GetxService { await _svc.stop(); final s = _session; + // 整场一帧设备上行都没有(2026-09-25 真机出现过「上行 0 包」): + // 趁会话还在,把原生解码统计打出来,分清是设备没发、BLE 没收到还是没解出来 + if (wasActive && _micPackets == 0 && s != null && _isEaiCar) { + try { + final st = await s.invokeFeature('debug.audioStats'); + Logger.w(_tag, '本场设备上行 0 包,原生音频统计: $st'); + } catch (_) {} + } if (wasActive && notifyDevice && s != null) await _sendExit(s); _session = null; await BackgroundKeepalive.release(_tag); + if (_holdsForeground) { + _holdsForeground = false; + unawaited(AudioForegroundService.stop()); + } if (wasActive) { Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); await _resumePhonePage(); @@ -896,6 +1076,13 @@ class DeviceAiSessionService extends GetxService { void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; + /// [page] 是不是当前登记的那个聊天页(同一时刻可能有新旧两个实例: + /// 新页面 onInit 先登记,旧页面 onClose 后到)。 + bool isAttachedPage(EmaiSessionOwner page) => identical(_phonePage, page); + + /// 正在进行的是不是车载香薰的会话(聊天页据此决定要不要用香薰那套交互)。 + bool get activeIsEaiCar => isActive.value && _isEaiCar; + void detachPhonePage(EmaiSessionOwner page) { if (identical(_phonePage, page)) _phonePage = null; } diff --git a/apps/client/lib/data/utils/pcm_stream_player.dart b/apps/client/lib/data/utils/pcm_stream_player.dart index 42a157c4..af7d6086 100644 --- a/apps/client/lib/data/utils/pcm_stream_player.dart +++ b/apps/client/lib/data/utils/pcm_stream_player.dart @@ -238,7 +238,12 @@ class PcmStreamPlayer { try { _nativeChannel.setMethodCallHandler(_onNativeCall); final ok = - await _nativeChannel.invokeMethod('start', {'sampleRate': sampleRate}); + await _nativeChannel.invokeMethod('start', { + 'sampleRate': sampleRate, + // 只有车载香薰的设备会话用 playbackOnly(声音走 A2DP)。Android 原生据此在 + // 回复之间垫静音,别让 A2DP 流被挂起吞掉下一句的开头;其它调用方不受影响。 + 'keepAlive': playbackOnly, + }); if (ok != true) { Logger.e(_tag, '原生播放器启动失败'); return false; diff --git a/apps/client/lib/devices/device_hub.dart b/apps/client/lib/devices/device_hub.dart index ea099cf8..e6e8185a 100644 --- a/apps/client/lib/devices/device_hub.dart +++ b/apps/client/lib/devices/device_hub.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import '../core/utils/logger.dart'; @@ -114,14 +115,19 @@ class DeviceHub extends GetxService { /// AI 页的默认编排按厂商取,两者指向不同设备就会出现「图是耳机、 /// 功能却是香薰的」这种自相矛盾的界面。 /// - /// ⚠️ 已知口径局限:没有活跃会话时按**插件注册顺序**取第一个连过的, - /// 不是按「最近连接时间」。同时连过耳机和香薰、然后都断开时, - /// 显示的是注册在前的那家(当前是恒玄),而不是真正最近用的那台。 + /// 没有活跃会话时,先认**最后一次上线的那家**([lastUsedVendor],落盘,跨冷启动), + /// 它没有记住的设备才退回按插件注册顺序找。 + /// ⚠️ 2026-09-25 以前只有后一条:连过耳机又连了香薰、香薰一断, + /// 界面就跳回耳机(恒玄注册在前),与「最后连的是哪台就显示哪台」相反。 ({String name, String vendor}) get _lastKnown { final p = primary; if (p != null && p.info.name.isNotEmpty) { return (name: p.info.name, vendor: p.vendor); } + final last = _plugins[lastUsedVendor]?.lastKnownDevice; + if (last != null && last.name.isNotEmpty) { + return (name: last.name, vendor: lastUsedVendor); + } for (final plugin in _plugins.values) { final d = plugin.lastKnownDevice; if (d != null && d.name.isNotEmpty) { @@ -131,6 +137,26 @@ class DeviceHub extends GetxService { return (name: '', vendor: ''); } + static const String _lastVendorKey = 'device_hub_last_vendor'; + + /// 最后一次上线的设备出自哪家插件(落盘)。从没连过为空串。 + /// 自动回连也按它决定该回连哪一家,避免冷启动时两家互相顶掉。 + String get lastUsedVendor { + try { + return GetStorage().read(_lastVendorKey) ?? ''; + } catch (_) { + return ''; + } + } + + void _rememberLastVendor(String vendor) { + try { + if (lastUsedVendor != vendor) GetStorage().write(_lastVendorKey, vendor); + } catch (e) { + Logger.w(_tag, '记录最后连接的设备厂商失败: $e'); + } + } + /// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。 /// 从没连过任何设备返回空串。 String get lastKnownName => _lastKnown.name; @@ -212,6 +238,7 @@ class DeviceHub extends GetxService { }); } sessions.insert(0, s); + _rememberLastVendor(s.vendor); _sessionSubs[s.vendor] = s.eventStream.listen((e) { _events.add(e); _onSessionEvent(s, e); diff --git a/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart b/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart index 6dd61ac2..2940f1ba 100644 --- a/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart +++ b/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart @@ -10,9 +10,11 @@ import 'package:get_storage/get_storage.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/utils/logger.dart'; +import '../../data/services/background_launch_consent.dart'; import '../../data/utils/pcm_stream_player.dart'; import '../../core/utils/permission_util.dart'; import '../device_auth.dart'; +import '../device_hub.dart'; import '../device_vendors.dart'; import 'eaicar_device_auth.dart'; import 'eaicar_link.dart'; @@ -91,8 +93,14 @@ class EaiCarDevicePlugin implements DevicePlugin { _connSub?.cancel(); _connSub = link.connected.listen((up) { if (!up) { + final wasOnline = _session != null; _session?._markDisconnected(); _session = null; + // 不是用户/切设备主动断的(设备走远、关机、信号抖)→ 自动回连 + if (wasOnline && !_userDisconnected) { + _reconnectAttempt = 0; + _scheduleReconnect('设备意外断开'); + } } _emit(DevicePluginEvent( type: DevicePluginEventType.connectionStateChanged, @@ -103,6 +111,112 @@ class EaiCarDevicePlugin implements DevicePlugin { )); }); _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); + // 冷启动回连:等 CoreBluetooth / 蓝牙适配器就绪、DeviceHub 注册完其它插件再动 + Timer(const Duration(seconds: 3), _startupReconnect); + } + + // ─────────────────────────── 自动回连 ─────────────────────────── + // + // 2026-09-25 以前 [tryAutoConnect] 写好了但**全项目没有调用方**(DeviceHub 的 + // tryAutoConnectAll 也没人调),于是香薰断了就断了,冷启动也不连。现在两处触发: + // 1. 冷启动:最后一次用的设备是香薰([DeviceHub.lastUsedVendor]),且用户同意过 + // 后台自动连接(与恒玄同一个开关); + // 2. 意外断开:不是用户点断开 / 切到别的设备 / 解绑造成的,就按退避重试。 + // 两个平台都能按 id 直连已知外设,不需要先扫描(iOS retrievePeripherals、 + // Android getRemoteDevice),设备回到范围内就能接上。 + // 有别的设备在线时不回连:DeviceHub 是单活动设备,连上香薰会把用户正在用的那台顶掉。 + + /// 用户(或 DeviceHub 切设备)主动断开 / 解绑后置位,挡住自动回连;下次 connect 清掉。 + bool _userDisconnected = false; + + /// OTA 进行中([EaiCarOtaPort] 置位、close 时清掉):设备会断开重启,别去回连。 + bool _otaActive = false; + set otaActive(bool v) { + _otaActive = v; + if (v) _cancelReconnect(); + } + + Timer? _reconnectTimer; + int _reconnectAttempt = 0; + static const List _reconnectDelaysSec = [2, 5, 10, 20, 30]; + + /// 意外断开后最多试这么多次(约 10 分钟),之后等下次冷启动或手动连接。 + static const int _reconnectMaxAttempts = 22; + + void _startupReconnect() { + if (_disposed || _session != null || link.isConnected) return; + if (rememberedDevices.isEmpty) return; + String last = ''; + try { + last = DeviceHub.to.lastUsedVendor; + } catch (_) {} + if (last.isNotEmpty && last != vendorKey) return; + if (!BackgroundLaunchConsent.granted) { + Logger.i(_tag, '用户未开启设备自动连接,跳过香薰冷启动回连'); + return; + } + _reconnectAttempt = 0; + _scheduleReconnect('冷启动', immediate: true); + } + + bool _otherDeviceOnline() { + try { + return DeviceHub.to.sessions.any((x) => x.vendor != vendorKey); + } catch (_) { + return false; + } + } + + void _cancelReconnect() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } + + void _scheduleReconnect(String why, {bool immediate = false}) { + _cancelReconnect(); + if (_disposed || _userDisconnected || _otaActive || rememberedDevices.isEmpty) { + return; + } + if (_reconnectAttempt >= _reconnectMaxAttempts) { + Logger.i(_tag, '香薰自动回连已试 $_reconnectAttempt 次,停止(等手动连接或下次启动)'); + return; + } + final sec = immediate + ? 0 + : _reconnectDelaysSec[ + math.min(_reconnectAttempt, _reconnectDelaysSec.length - 1)]; + _reconnectAttempt++; + Logger.i(_tag, '$why,${sec}s 后第 $_reconnectAttempt 次自动回连'); + _reconnectTimer = Timer(Duration(seconds: sec), () async { + _reconnectTimer = null; + if (_disposed || + _userDisconnected || + _otaActive || + _session != null || + link.isConnected) { + return; + } + if (_otherDeviceOnline()) { + Logger.i(_tag, '已有其它设备在线,停止香薰自动回连'); + return; + } + final first = rememberedDevices.isEmpty ? null : rememberedDevices.first; + if (first == null) return; + try { + // 回连不经过扫描,拿不到广播里的 MAC——带上记住的那份, + // 否则确权跳过、经典蓝牙状态也查不了(日志里的「广播里没解出 MAC」) + final mac = first.metadata['mac']; + await connect(first.id, + options: DeviceConnectOptions( + timeout: const Duration(seconds: 45), + extra: {if (mac is String && mac.isNotEmpty) 'mac': mac}, + )); + Logger.i(_tag, '香薰自动回连成功(第 $_reconnectAttempt 次)'); + _reconnectAttempt = 0; + } catch (e) { + _scheduleReconnect('自动回连未成功($e)'); + } + }); } /// 自持 GATT 要的运行时权限(Android 12+)。iOS 由系统在首次用 CoreBluetooth 时弹框。 @@ -203,6 +317,8 @@ class EaiCarDevicePlugin implements DevicePlugin { Future connect(String deviceId, {DeviceConnectOptions? options}) async { _checkAlive(); + _userDisconnected = false; + _cancelReconnect(); await stopScan(); final adv = options?.extra['advertisement']; @@ -311,6 +427,10 @@ class EaiCarDevicePlugin implements DevicePlugin { @override Future forgetDevice(String deviceId) async { + if (link.deviceId == deviceId) { + _userDisconnected = true; + _cancelReconnect(); + } final list = _remembered..removeWhere((m) => m['id'] == deviceId); await _storage.write(_rememberedKey, list); if (link.deviceId == deviceId) await link.disconnect(); @@ -333,6 +453,7 @@ class EaiCarDevicePlugin implements DevicePlugin { Future dispose() async { if (_disposed) return; _disposed = true; + _cancelReconnect(); await _scanSub?.cancel(); await _connSub?.cancel(); await _diagSub?.cancel(); @@ -881,10 +1002,54 @@ class EaiCarSession implements DeviceSession { /// 但只有扫描时才能拿到,连上之后就不再更新 Future isClassicAudioConnected() async { final mac = classicMacCandidate; + if (Platform.isIOS) return _iosClassicAudioConnected(mac); if (mac == null || mac.isEmpty) return false; return ClassicBluetooth.isA2dpConnected(mac); } + /// iOS:看当前音频输出里有没有这台设备。 + /// + /// ⚠️ 2026-09-25 真机:系统设置里经典蓝牙明明连着,这里却恒判「未连接」, + /// 于是每次连上都弹「请到 设置 → 蓝牙 里配对」、AI 会话也打「A2DP 未连接」。 + /// 原因是只拿广播 MAC 去比,而经典蓝牙地址可能与它差首字节(杰理 BLE/经典 + /// 两个地址的约定,见 [classicMacCandidate]),名字也没参与比较。 + /// 现在广播 MAC、首字节 ±1、设备名任一对上即算,并把系统看到的输出打出来, + /// 下次真机一眼就能看出经典地址和广播地址的关系。 + Future _iosClassicAudioConnected(String? mac) async { + try { + final outs = await ClassicBluetooth.getConnectedDevices(); + final macs = { + for (final m in [mac, _mac, _shiftFirstByte(_mac, -1), _shiftFirstByte(_mac, 1)]) + if (m != null && m.isNotEmpty) m.toUpperCase(), + }; + final name = _info.name.trim().toUpperCase(); + final hit = outs.any((d) { + final a = d.address.toUpperCase(); + final n = d.name.toUpperCase(); + return macs.contains(a) || + (name.isNotEmpty && n.contains(name)) || + n.contains('EAICAR'); + }); + Logger.i('EaiCarSession', + 'iOS 蓝牙音频输出: ${outs.map((d) => '${d.name}(${d.address})').join(', ')}' + ' | 广播MAC=$_mac 设备名=${_info.name} → ${hit ? '已连' : '未连'}'); + return hit; + } catch (e) { + Logger.w('EaiCarSession', '查询 iOS 音频输出失败: $e'); + return false; + } + } + + static String? _shiftFirstByte(String? mac, int delta) { + if (mac == null) return null; + final parts = mac.split(':'); + if (parts.length != 6) return null; + final first = int.tryParse(parts[0], radix: 16); + if (first == null) return null; + parts[0] = ((first + delta) & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase(); + return parts.join(':'); + } + EaiCarOtaPort? _otaPort; /// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。 @@ -965,7 +1130,8 @@ class EaiCarSession implements DeviceSession { case DeviceFeatures.aiExit: await _setAiState(EaiCarProtocol.aiStateClose); await _link.audioStop(); - return {'ok': true}; + // 0x07 是单向命令,设备不回结束应答;告诉会话服务别等了 + return {'ok': true, 'noAck': true}; case DeviceFeatures.aiState: // 通用语义 → C2 的 0x07。**设备的表情动画全靠这条**:不发的话 // 整轮对话里设备只在开头收到一次"聆听中",之后一直是那个表情 @@ -1007,6 +1173,8 @@ class EaiCarSession implements DeviceSession { .map((d) => {'name': d.name, 'address': d.address}) .toList(), }; + case 'debug.audioStats': + return await _link.audioStats(); case 'eaicar.a2dp_connect': return await connectClassicAudio(); @@ -1038,6 +1206,9 @@ class EaiCarSession implements DeviceSession { /// 去「设置 → 蓝牙」手动配一次。返回 `{'ok': false, 'status': 'unsupported_ios'}` Future> connectClassicAudio() async { final mac = classicMacCandidate; + if (Platform.isIOS && await isClassicAudioConnected()) { + return {'ok': true, 'status': 'already_connected', 'mac': mac}; + } if (mac == null || mac.isEmpty) { return {'ok': false, 'status': 'no_mac', 'hint': '广播里没解出 MAC'}; } @@ -1086,6 +1257,9 @@ class EaiCarSession implements DeviceSession { @override Future disconnect() async { + // 主动断开(用户点断开 / DeviceHub 切到别的设备)不触发自动回连 + _plugin._userDisconnected = true; + _plugin._cancelReconnect(); _state = DeviceConnectionState.disconnecting; await _link.audioStop(); await _link.disconnect(); diff --git a/apps/client/lib/devices/eaicar/eaicar_ota_port.dart b/apps/client/lib/devices/eaicar/eaicar_ota_port.dart index ccfe2d56..fb5b5d7a 100644 --- a/apps/client/lib/devices/eaicar/eaicar_ota_port.dart +++ b/apps/client/lib/devices/eaicar/eaicar_ota_port.dart @@ -55,6 +55,8 @@ class EaiCarOtaPort implements DeviceOtaPort { _running = true; _terminal = false; _succeeded = false; + // 升级中设备会断开/重启进 stage2,这期间插件的断线自动回连必须让路 + _plugin.otaActive = true; _lastPercent = -1; _sub ??= _link.otaEvents.listen(_onEvent, onError: (Object e) { _fail(DeviceErrorCode.otaTransferFailed, '$e'); @@ -135,6 +137,7 @@ class EaiCarOtaPort implements DeviceOtaPort { // 升级成功设备会重启,广播回来要几秒;失败/取消时原生已把链路断开,一样回连 final delay = _succeeded ? const Duration(seconds: 8) : const Duration(seconds: 2); Future.delayed(delay, () async { + _plugin.otaActive = false; try { await _plugin.tryAutoConnect(); } catch (e) { diff --git a/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart b/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart index 67e0bdd2..44203f2d 100644 --- a/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart +++ b/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart @@ -45,4 +45,4 @@ const String kEaiCarTag = 'eaicar'; /// 等运营提出「要后台随时改」时,再改成从 `DBAgent.serviceData` 下发—— /// 那时要给客户端手写镜像补 `source` / `serviceData` 两个字段,且**必须 nullable**, /// 否则老后端不下发就整个 `fromJson` 抛 TypeError、应用配置全空白。 -const String kEaiCarBailianAppId = 'mm_dd1eb7cb06484aafb94a0b6b2dff'; +const String kEaiCarBailianAppId = 'mm_127ddb82ba1244a99d2feaef0c1f'; diff --git a/apps/client/lib/modules/eaicar/module.dart b/apps/client/lib/modules/eaicar/module.dart index de5292d5..a6e9cbc0 100644 --- a/apps/client/lib/modules/eaicar/module.dart +++ b/apps/client/lib/modules/eaicar/module.dart @@ -51,6 +51,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( translations: const { 'zh_CN': { 'agentEaiCar': 'EaiCar', + 'emaiDeviceTalkingTapPhone': '设备对话中 · 点此改用手机', 'agentEaiCarDesc': '您的车载助手', 'eaicarWelcome': '欢迎回来', 'eaicarA2dpTitle': '需要连接蓝牙音频', @@ -73,6 +74,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( }, 'zh_TW': { 'agentEaiCar': 'EaiCar', + 'emaiDeviceTalkingTapPhone': '裝置對話中 · 點此改用手機', 'agentEaiCarDesc': '您的車載助手', 'eaicarWelcome': '歡迎回來', 'eaicarA2dpTitle': '需要連接藍牙音訊', @@ -117,6 +119,7 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( }, 'en_US': { 'agentEaiCar': 'EaiCar', + 'emaiDeviceTalkingTapPhone': 'On device · tap to use phone', 'agentEaiCarDesc': 'Your in-car assistant', 'eaicarWelcome': 'Welcome back', 'eaicarA2dpTitle': 'Bluetooth audio not connected', diff --git a/apps/client/lib/modules/emai/controllers/emai_controller.dart b/apps/client/lib/modules/emai/controllers/emai_controller.dart index 01330cf7..3c775fb6 100644 --- a/apps/client/lib/modules/emai/controllers/emai_controller.dart +++ b/apps/client/lib/modules/emai/controllers/emai_controller.dart @@ -194,6 +194,10 @@ class EmaiController extends GetxController })); _jumpToBottomOnEnter(); + if (Get.isRegistered()) { + Logger.i(_tag, + '聊天页打开 (设备对话${DeviceAiSessionService.to.isActive.value ? '进行中,本页只显示、由设备收音' : '未进行'})'); + } connect(); } @@ -201,7 +205,24 @@ class EmaiController extends GetxController void onClose() { WidgetsBinding.instance.removeObserver(this); if (Get.isRegistered()) { - DeviceAiSessionService.to.detachPhonePage(this); + final device = DeviceAiSessionService.to; + // 离开聊天页 = 结束这次对话。设备唤醒的会话此刻正显示在本页上(onEarphone), + // 也一并结束并通知设备退出 AI 模式——否则 App 这边聊天页没了, + // 设备还停在「聆听中」、AI 还在后台说话,两边状态对不上(2026-09-25 反馈)。 + // + // ⚠️ 必须是「正在显示这场设备对话的那个页面」才能结束它。只判 isActive 的话, + // 进 EaiCar 页时被顶掉的旧页面实例一 onClose 就把刚唤醒的设备会话掐了—— + // 用户在主界面唤醒、点进 EaiCar 页,接着对设备说话就再也没回应(2026-09-25 真机)。 + // 只对车载香薰:恒玄耳机的会话离开页面后照旧继续(保持原有行为) + final showingDevice = device.activeIsEaiCar && + device.isAttachedPage(this) && + linkState.value == EmaiLinkState.onEarphone; + device.detachPhonePage(this); + Logger.i(_tag, + '聊天页关闭 (设备会话${device.isActive.value ? '进行中' : '未进行'},本页${showingDevice ? '正显示它,一并结束' : '未显示它,不动'})'); + if (showingDevice && device.isActive.value) { + unawaited(device.requestStop()); + } } _handshakeTimer?.cancel(); _cancelIdle(); @@ -315,7 +336,26 @@ class EmaiController extends GetxController /// 收尾是异步的——[DeviceAiSessionService.requestStop] 发完 `AA 65` 要等耳机回 /// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来, /// 所以这里不用自己 connect(),否则会和耳机会话抢。 + /// 设备对话中点了本页的说话按钮:结束设备对话,接回来后直接进连续对话。 + /// + /// ⚠️ 2026-09-25 以前设备对话中这个按钮是灰的、写着「离线」,用户在主界面唤醒后 + /// 点进 EaiCar 页想接着聊,按了没有任何反应。 + Future takeBackAndTalk() async { + _talkAfterTakeBack = true; + await takeBackFromEarphone(); + } + + bool _talkAfterTakeBack = false; + + /// 设备对话中底部按钮要不要换成「改用手机」:只对车载香薰。 + /// 恒玄耳机保持原样(按钮置灰,顶部状态条上有「收回控制」)。 + bool get showDeviceTakeOverButton => + linkState.value == EmaiLinkState.onEarphone && + Get.isRegistered() && + DeviceAiSessionService.to.activeIsEaiCar; + Future takeBackFromEarphone() async { + Logger.i(_tag, '用户点「收回」:结束设备对话,改在手机上聊'); if (!Get.isRegistered()) { // 服务没注册(理论上不会),至少别让页面卡在 onEarphone linkState.value = EmaiLinkState.idle; @@ -334,6 +374,11 @@ class EmaiController extends GetxController linkState.value != EmaiLinkState.connecting) { return; } + if (_talkAfterTakeBack) { + _talkAfterTakeBack = false; + // 连续对话:Started 一到就开手机麦(见 _onEvent 的 started 分支) + isContinuous.value = true; + } linkState.value = EmaiLinkState.idle; _autoRetried = false; await connect(); diff --git a/apps/client/lib/modules/emai/views/emai_view.dart b/apps/client/lib/modules/emai/views/emai_view.dart index 3884e6f8..dd2d29c5 100644 --- a/apps/client/lib/modules/emai/views/emai_view.dart +++ b/apps/client/lib/modules/emai/views/emai_view.dart @@ -430,6 +430,34 @@ class EmaiView extends GetView { ); } + // 设备对话中:直接对设备说话就行;点这里 = 改用手机聊(结束设备对话) + if (controller.showDeviceTakeOverButton) { + return GestureDetector( + onTap: controller.takeBackAndTalk, + child: Container( + height: 40.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppPalette.accent, + borderRadius: BorderRadius.circular(20.r), + ), + padding: EdgeInsets.symmetric(horizontal: 12.w), + // 按钮夹在两个图标之间,宽度有限:只许一行,放不下就整体缩小,别折行溢出 + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'emaiDeviceTalkingTapPhone'.tr, + maxLines: 1, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: Colors.white), + ), + ), + ), + ); + } + final talking = controller.isTalking.value; final cancelling = controller.isCancelPending.value; diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 1e100b57..667ada40 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -233,7 +233,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, when (call.method) { "start" -> { val rate = (call.argument("sampleRate"))?.toInt() ?: 24000 - result.success(pcmSpeakerPlayer.start(rate)) + val keepAlive = call.argument("keepAlive") ?: false + result.success(pcmSpeakerPlayer.start(rate, keepAlive)) } "feed" -> { call.argument("data")?.let { pcmSpeakerPlayer.feed(it) } diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/PcmSpeakerPlayer.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/PcmSpeakerPlayer.kt index b7ef052d..335bd7e4 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/PcmSpeakerPlayer.kt +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/PcmSpeakerPlayer.kt @@ -50,6 +50,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) { * 这时也必须报完成,否则服务端会一直停在 Responding。 */ private const val DRAIN_TIMEOUT_MS = 5_000L + + /** 空闲垫静音:缓冲低于这个值才垫(毫秒) */ + private const val KEEPALIVE_LOW_MS = 40L + + /** 每次垫多长的静音(毫秒) */ + private const val KEEPALIVE_CHUNK_MS = 20 } private val lock = Any() @@ -70,8 +76,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) { // ---------- 对外接口(与 iOS 逐字对齐)---------- - fun start(sampleRate: Int): Boolean { + /** 本轮是否在空闲时垫静音(只有车载香薰的设备会话传 true,见 [keepAlive]) */ + @Volatile private var keepAliveEnabled = false + + fun start(sampleRate: Int, keepAlive: Boolean = false): Boolean { synchronized(lock) { + keepAliveEnabled = keepAlive generation++ val gen = generation stopWriterLocked() @@ -156,8 +166,12 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) { writeFully(chunk, gen) continue } - // 队列空了。没收到 end() 就继续等下一块(服务端还在合成) - if (!ended) continue + // 队列空了。没收到 end() 就继续等下一块(服务端还在合成), + // 同时补一小段静音,别让 AudioTrack 欠载停下来(见 [keepAlive]) + if (!ended) { + keepAlive(gen) + continue + } if (awaitDrained(gen)) { finish(gen) return @@ -189,6 +203,34 @@ class PcmSpeakerPlayer(private val onCompleted: () -> Unit) { framesWritten += chunk.size / 2 } + /** + * 空闲时给 AudioTrack 垫静音,保持输出流不断。 + * + * ⚠️ 2026-09-25 车载香薰(华为 ALN-AL80)实测:设备会话里 AI 回复之间是空档, + * AudioTrack 一欠载就被系统判停(`PLAYER_STATE_TIMEOUT`),紧接着蓝牙 A2DP 流被挂起; + * 下一句来时要重新 `A2DP START`,这要 0.5~0.7 秒——唤醒问候「我在呢」只有 1.2 秒, + * 前半截被吞,用户听上去就是「没有默认回复」。iOS 靠 BackgroundKeepalive 循环播静音 + * 保住了这条流,Android 这里补上等价的做法。 + * + * 只在缓冲快见底(< [KEEPALIVE_LOW_MS])时写一块 [KEEPALIVE_CHUNK_MS] 的静音, + * 所以积在真实音频前面的静音最多几十毫秒,不会拖慢开口。 + * 调了 end() 之后不再垫(要等播完报完成)。 + */ + private fun keepAlive(gen: Int) { + if (!keepAliveEnabled) return + val t = track ?: return + if (trackRate <= 0) return + val head = try { + t.playbackHeadPosition + } catch (e: Exception) { + return + } + val pendingMs = (framesWritten - head).toLong() * 1000 / trackRate + if (pendingMs >= KEEPALIVE_LOW_MS) return + val frames = trackRate * KEEPALIVE_CHUNK_MS / 1000 + writeFully(ByteArray(frames * 2), gen) + } + /** * 等已写入的帧真正播完。返回 false 表示这一轮被 stop() 掀掉了。 * diff --git a/apps/client/test/assistant_directive_test.dart b/apps/client/test/assistant_directive_test.dart index 81ff0f8c..f3c7afa8 100644 --- a/apps/client/test/assistant_directive_test.dart +++ b/apps/client/test/assistant_directive_test.dart @@ -399,5 +399,21 @@ void main() { (f['extra_info'] as Map)['commands'] = []; expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty); }); + + // 2026-09-25 真机:车载助手新应用 mm_127ddb82… 把 commands 下发成 JSON 字符串 + test('commands 是 JSON 字符串时也解得出 quit', () { + final f = frame(); + (f['extra_info'] as Map)['commands'] = + '[{"intent_info":{"domain":"general_command","intent":"quit"},"name":"quit","params":[]}]'; + final list = AssistantDirective.parseFromOutput(f, source: 'earphone'); + expect(list.map((d) => d.name).toList(), ['quit']); + expect(list.first.args['intent'], 'quit'); + }); + + test('commands 是畸形字符串时不抛、不产生指令', () { + final f = frame(); + (f['extra_info'] as Map)['commands'] = '[{oops'; + expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty); + }); }); } diff --git a/docs/Smartcar车载香薰-接入设计与落地文档.md b/docs/Smartcar车载香薰-接入设计与落地文档.md index c4a927de..c6e60e25 100644 --- a/docs/Smartcar车载香薰-接入设计与落地文档.md +++ b/docs/Smartcar车载香薰-接入设计与落地文档.md @@ -428,7 +428,7 @@ FE DC BA 80 01 00 8E | seq(1) | 04 | opus payload … | 尾字节(1,丢弃) - **接哪个助手**:`AgentModule.bailianAppId`(放在 **data 层基类**,不是 modules 层的 `AgentModuleDescriptor`——`DeviceAiSessionService` 在 data 层,**data 层不能反向依赖 modules 层**)。 `DeviceAiSessionService` 走「厂商 → 品类 → 默认编排第一个模块 → 该模块的 app_id」, - 车载香薰因此接的是 Smartcar 车载助手(`mm_dd1eb7cb06484aafb94a0b6b2dff`)而不是 EMAI。 + 车载香薰因此接的是 Smartcar 车载助手(`mm_127ddb82ba1244a99d2feaef0c1f`)而不是 EMAI。 耳机/支架没声明就仍用全局默认,**行为不变**。 ### 5.11 ✅ 命令表复核(2026-09-11 样机实测) @@ -460,7 +460,7 @@ FE DC BA 80 01 00 8E | seq(1) | 04 | opus payload … | 尾字节(1,丢弃) ## 6. AI 侧:新增 Smartcar agent(按新模块系统重写) 需求:**在广场新增一个 agent**(不复用 EMAI),同一百炼 workspace 下换应用 ID, -例如 `mm_dd1eb7cb06484aafb94a0b6b2dff`;连上设备后 AI 页默认只显示它。主功能是随时开聊。 +例如 `mm_127ddb82ba1244a99d2feaef0c1f`;连上设备后 AI 页默认只显示它。主功能是随时开聊。 ### 6.1 模块系统已经重构,工作量大幅下降 @@ -558,7 +558,7 @@ DeviceCategories.carAroma: [smartcarChat], // ← 加这一行 | 名字 | **Smartcar** | | 副标题 | **您的车载助手** | | 欢迎语 | **欢迎回来** | -| 百炼 app_id | `mm_dd1eb7cb06484aafb94a0b6b2dff`(workspace 同 EMAI) | +| 百炼 app_id | `mm_127ddb82ba1244a99d2feaef0c1f`(workspace 同 EMAI) | ⚠️ **图标只能用 Material 图标,不能直接用产品图。** `AgentModule.icon` 的类型是 `IconData`,`module_card.dart` 里是 `Icon(module.icon, ...)` 套一个渐变色块,**不支持图片**。