|
|
|
@ -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<List<DeviceSession>>(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<String> _onlineSeen = {}; |
|
|
|
|
|
|
|
void _resetAiStateOnOnline(List<DeviceSession> 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 <String, Object?>{}; |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ---------------- 设备事件 ---------------- |
|
|
|
|
|
|
|
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<void> _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<String> _greetingsZh = [ |
|
|
|
'我在', |
|
|
|
'我在呢', |
|
|
|
'你好呀,有什么可以帮你?', |
|
|
|
'在的,请说', |
|
|
|
'嗨,我在听', |
|
|
|
]; |
|
|
|
static const List<String> _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<void> _sendExit(DeviceSession s) async { |
|
|
|
if (s.state != DeviceConnectionState.ready) return; |
|
|
|
/// 发 ai.exit。返回 true 表示该设备**不会回结束应答**(插件回了 `noAck`), |
|
|
|
/// 调用方可以直接收尾。 |
|
|
|
Future<bool> _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; |
|
|
|
} |
|
|
|
|