You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

666 lines
23 KiB

import 'dart:async';
import 'dart:typed_data';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import '../models/agent_module.dart';
import '../models/emai_message.dart';
import '../models/user_Info.dart' as user_info;
import '../utils/cue_tone.dart';
import 'agent_module_defaults.dart';
import 'assistant_directive_service.dart';
import 'bailian_multimodal_service.dart';
import 'emai_chat_store.dart';
import 'mcp_token_service.dart';
/// 设备按键唤醒的 AI 实时对话会话——**厂商无关**,要求设备具备
/// [DeviceCapability.aiWake]。
///
/// 把「设备按键唤醒」接到阿里百炼多模态上:设备麦的音频由插件解码成 16k PCM
/// (`openMic(ai)`)推给百炼,百炼回的 16k PCM 交给 `openSpeaker(ai)` 由插件/原生
/// 编码后按节奏发回设备播放。整条链路上这里只搬运 PCM。
///
/// **握手是设备主导的**,App 只能应答(恒玄时序,其它厂商在插件里对齐到同一语义):
/// ```
/// 设备按键 ──► wakeEvents(ptt) 恒玄: BB 64
/// App ──► ai.accept 恒玄: AA 6A,必须 3 秒内,否则设备停止送音频
/// …会话进行中…
/// App ──► ai.exit 恒玄: AA 65
/// 设备 ──► wakeEvents(hangup) 恒玄: BB E5;2 秒不回就本地强制结束
/// ```
///
/// 常驻服务:按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。
class DeviceAiSessionService extends GetxService {
static const String _tag = 'DeviceAiSession';
static DeviceAiSessionService get to => Get.find<DeviceAiSessionService>();
/// 设备侧的会话与手机 EMAI 页各用一条独立的 WebSocket。
/// 共用一个单例的话两边会互相 stop 掉对方、下行音频也会串。
///
/// ⚠️ **接哪个百炼应用要看是哪台设备唤醒的**:耳机/支架接 EMAI,车载香薰接
/// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例,
/// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。
BailianMultimodalService _svc = BailianMultimodalService();
String? _svcAppId;
/// 厂商 → 该接哪个 agent 的 app_id。走的是「厂商 → 品类 → 默认编排第一个模块
/// → 模块声明的 bailianAppId」这条链,**设备层不认识 agent、agent 层不认识厂商**。
/// 查不到返回 null = 用全局默认(EMAI)。
String? _resolveAgentAppId(String vendor) {
try {
return bailianAppIdForModule(AgentDefaults.primaryModuleForVendor(vendor));
} catch (e) {
Logger.w(_tag, '解析 agent app_id 失败(回退默认): $e');
return null;
}
}
/// 按需换一个指向目标 app_id 的服务实例。
void _ensureService(String? appId) {
final want = (appId ?? '').trim();
final cur = (_svcAppId ?? '').trim();
if (want == cur) return;
Logger.i(_tag,
'切换设备侧百炼应用: ${cur.isEmpty ? '(默认)' : cur} → ${want.isEmpty ? '(默认)' : want}');
// 旧实例可能还挂着 WebSocket,先停掉再丢
unawaited(_svc.stop());
_svc = BailianMultimodalService(appId: want.isEmpty ? null : want);
_svcAppId = want;
}
/// 设备上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致
static const int _deviceRate = 16000;
/// 上行攒够这么多字节再发一包:3200B = 100ms @16k。设备每 20ms 上来一包,
/// 逐包转发就是 50 条 WS 消息/秒,攒成 100ms 一条降到 1/5。
static const int _micBatchBytes = 3200;
final BytesBuilder _micAccum = BytesBuilder(copy: false);
/// 收到唤醒后必须在这个时间内应答。我们是立即回,这个常量只是把固件约束写在代码里。
static const Duration _ackDeadline = Duration(seconds: 3);
/// 发出 ai.exit 后等设备回 hangup 的时间,超时就本地强制结束
static const Duration _stopAckTimeout = Duration(seconds: 2);
StreamSubscription? _wakeSub;
StreamSubscription? _hubSub;
StreamSubscription? _micSub;
StreamSubscription? _eventSub;
StreamSubscription? _audioSub;
DeviceSession? _session;
DeviceAudioSource? _mic;
DeviceAudioSink? _spk;
Timer? _stopAckTimer;
Timer? _playoutTimer;
/// 会话是否在跑(已应答,还没收到/发出结束)
final RxBool isActive = false.obs;
/// 手机 EMAI 页(打开时由它自己 attach 上来)。
/// 不用 `Get.put<EmaiSessionOwner>(controller)`:GetX 的 put 会对实例调
/// onInit,而页面正是在自己的 onInit 里注册的,会递归到 Stack Overflow。
EmaiSessionOwner? _phonePage;
int _respondingBytes = 0;
DateTime? _respondingStartedAt;
/// 上行是否被门控住(AI 正在设备里播报期间)。
/// 设备没有把下发的音频接进自己的 AEC,播出去的 AI 声音会被麦克风原样收回,
/// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。
bool _uplinkGated = false;
Timer? _gateTimer;
bool _tearingDown = false;
static const Duration _gateTail = Duration(milliseconds: 500);
static const Duration _idleTimeout = Duration(seconds: 5);
Timer? _idleTimer;
DateTime? _firstAudioAt;
int _micPackets = 0;
int _downPackets = 0;
EmaiChatStore get _store => EmaiChatStore.to;
EmaiMessage? _streamingUser;
EmaiMessage? _streamingAssistant;
@override
void onInit() {
super.onInit();
final hub = DeviceHub.to;
_wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) {
Logger.e(_tag, '唤醒事件流错误: $e');
});
_hubSub = hub.sessionEvents.listen((e) {
if (e.type == DeviceSessionEventType.connectionStateChanged &&
e.connectionState == DeviceConnectionState.disconnected &&
isActive.value &&
_session?.deviceId == e.deviceId) {
Logger.w(_tag, '设备断连,结束 AI 会话');
_teardown(notifyDevice: false, playEndTone: false);
}
});
Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒');
}
@override
void onClose() {
_wakeSub?.cancel();
_hubSub?.cancel();
_teardown(notifyDevice: true, playEndTone: false);
super.onClose();
}
// ---------------- 设备事件 ----------------
void _onWake(DeviceWakeEvent e) {
switch (e.reason) {
case WakeReason.ptt:
case WakeReason.voiceWake:
_handleStart(e.deviceId);
break;
case WakeReason.hangup:
Logger.i(_tag, '设备通知结束 AI');
_teardown(notifyDevice: false);
break;
default:
break;
}
}
void _onMicPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return;
_micPackets++;
if (_micPackets == 1 || _micPackets % 50 == 0) {
Logger.i(_tag,
'设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}');
}
// 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是
// "真的没人说话",而不是一段数据缺口
_micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm);
if (_micAccum.length >= _micBatchBytes) {
_svc.pushAudio(_micAccum.takeBytes());
}
}
Future<void> _handleStart(String deviceId) async {
if (isActive.value) {
Logger.w(_tag, '已有会话在跑,忽略重复唤醒');
return;
}
final hub = DeviceHub.to;
final session =
hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId) ??
hub.sessionWith(DeviceCapability.aiWake);
if (session == null || session.state != DeviceConnectionState.ready) {
Logger.e(_tag, '设备未处于已连接状态,拒绝本次唤醒');
return;
}
// ★ 先按厂商选中该说话的那个 agent(车载香薰 → Smartcar 车载助手),
// 再去看 isConfigured —— 反过来的话检查的是上一台设备的应用
_ensureService(_resolveAgentAppId(session.vendor));
Logger.i(_tag,
'设备按键唤醒 AI (${session.vendor},应用=${_svcAppId?.isNotEmpty == true ? _svcAppId : '默认'})');
// 起不来就别应答,直接让设备退出 AI 模式——应答之后设备会一直送音频,
// 而我们这边没有会话接,它会白等到超时
if (!_svc.isConfigured) {
Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒');
await _sendExit(session);
return;
}
// 固件要求 3 秒内确认,先应答再做后面的初始化。
// 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。
final ackAt = DateTime.now();
_session = session;
try {
await session.invokeFeature(DeviceFeatures.aiAccept);
} catch (e) {
Logger.e(_tag, '应答唤醒失败: $e');
_session = null;
return;
}
isActive.value = true;
// 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持)
_aiStateUnsupported = false;
_quitAfterPlayout = false;
_micPackets = 0;
_downPackets = 0;
_uplinkGated = false;
_micAccum.clear();
await _yieldPhonePage();
try {
_mic = await session.openMic(route: AudioRoute.ai);
_micSub = _mic!.pcm.listen(_onMicPcm);
_eventSub = _svc.events.listen(_onBailianEvent);
// 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样
_audioSub = _svc.audioOut.listen(_onDownstreamPcm);
_spk = await session.openSpeaker(route: AudioRoute.ai);
} catch (e) {
Logger.e(_tag, '打开设备音频通路失败: $e');
await _teardown(notifyDevice: true, playEndTone: false);
return;
}
// 先给一声「叮」,让用户立刻知道已经在听了——百炼握手还要一次往返
await _playCue(CueTone.ding(sampleRate: _deviceRate));
_touchActivity();
final ok = await _svc.start(
mode: 'duplex',
downstreamRate: _deviceRate,
userId: _currentUserId(),
source: 'earphone',
mcpToken: await McpTokenService.get(),
);
final elapsed = DateTime.now().difference(ackAt);
if (elapsed > _ackDeadline) {
Logger.w(
_tag,
'会话初始化耗时 ${elapsed.inMilliseconds}ms,已超过固件 '
'${_ackDeadline.inSeconds}s 预算(应答已提前回,通常无碍)');
}
if (!ok) {
Logger.e(_tag, '百炼会话启动失败,结束');
await _teardown(notifyDevice: true);
}
}
// ---------------- 百炼事件 ----------------
void _onBailianEvent(BailianEvent e) {
Logger.i(_tag, '百炼事件 $e');
switch (e.type) {
case BailianEventType.speechStarted:
case BailianEventType.speechContent:
case BailianEventType.speechEnded:
case BailianEventType.respondingStarted:
case BailianEventType.respondingContent:
case BailianEventType.respondingEnded:
_touchActivity();
break;
default:
break;
}
switch (e.type) {
case BailianEventType.speechStarted:
// 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的
if (!_uplinkGated) {
_spk?.discardPending();
_cancelPlayoutTimer();
}
_streamingUser = null;
break;
case BailianEventType.speechContent:
_streamingUser ??= _store
.append(EmaiMessage(isUser: true, text: '', fromEarphone: true));
_store.applyText(_streamingUser!, e.text);
if (e.isFinal) {
_store.finalize(_streamingUser);
_streamingUser = null;
}
break;
case BailianEventType.speechEnded:
if (e.text.isNotEmpty && _streamingUser != null) {
_store.applyText(_streamingUser!, e.text);
}
_store.finalize(_streamingUser);
_streamingUser = null;
break;
case BailianEventType.respondingStarted:
_respondingBytes = 0;
_firstAudioAt = null;
_respondingStartedAt = DateTime.now();
_cancelPlayoutTimer();
_streamingAssistant = null;
break;
case BailianEventType.respondingContent:
if (e.directives.isNotEmpty &&
Get.isRegistered<AssistantDirectiveService>()) {
AssistantDirectiveService.to.handle(e.directives);
}
// 「关闭对话」:模型会下发 quit 指令并同时回一句告别语。
// 不能立刻结束——那样告别语还没播完就被掐了;标记下来,等播完再收尾。
if (e.directives.any((d) => d.name == _quitDirective)) {
Logger.i(_tag, '收到 quit 指令,本轮播完后结束会话');
_quitAfterPlayout = true;
}
// 纯指令帧没有文本,别建空气泡
if (e.text.isEmpty && e.finishReason == 'command_calls') break;
_streamingAssistant ??= _store
.append(EmaiMessage(isUser: false, text: '', fromEarphone: true));
_store.applyText(_streamingAssistant!, e.text);
break;
case BailianEventType.respondingEnded:
if (e.text.isNotEmpty && _streamingAssistant != null) {
_store.applyText(_streamingAssistant!, e.text);
}
_store.finalize(_streamingAssistant);
_streamingAssistant = null;
_logResponseAudioStats();
_schedulePlayoutDone();
break;
case BailianEventType.error:
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}');
_store.dropIfEmpty(_streamingUser);
_streamingUser = null;
_store.dropIfEmpty(_streamingAssistant);
_streamingAssistant = null;
if (e.text.isNotEmpty) {
_store.append(EmaiMessage(
isUser: false,
text: e.text,
isFinal: true,
isError: true,
fromEarphone: true));
_store.persist();
}
if (e.fatal) _teardown(notifyDevice: true);
break;
case BailianEventType.stateChanged:
// 把会话状态同步给设备,驱动它的指示灯/表情动画。
// ⚠️ 只有声明了这个 feature 的厂商会真发字节,其余抛 not_supported 被吞掉。
switch (e.state) {
case BailianDialogState.listening:
_pushAiState(DeviceAiState.listening);
break;
case BailianDialogState.thinking:
_pushAiState(DeviceAiState.thinking);
break;
case BailianDialogState.responding:
_pushAiState(DeviceAiState.speaking);
break;
default:
break;
}
break;
case BailianEventType.closed:
if (isActive.value) {
Logger.w(_tag, '百炼会话关闭,结束设备 AI');
_teardown(notifyDevice: true);
}
break;
default:
break;
}
}
void _onDownstreamPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return;
if (_firstAudioAt == null) {
_firstAudioAt = DateTime.now();
_openGate();
}
_respondingBytes += pcm.length;
_downPackets++;
if (_downPackets == 1 || _downPackets % 50 == 0) {
Logger.i(_tag, '下行给设备 #$_downPackets (${pcm.length}B)');
}
_spk?.write(pcm);
}
/// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。
/// 设备是否支持 [DeviceFeatures.aiState]。不支持就别每次都试、别每次都打日志。
bool _aiStateUnsupported = false;
/// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`)
static const String _quitDirective = 'quit';
/// 收到 quit 指令,等这一轮告别语播完就收尾
bool _quitAfterPlayout = false;
/// 把 AI 会话状态推给设备(驱动指示灯/表情)。
///
/// **fire-and-forget**:设备不支持、或这条命令失败,都不该影响对话本身,
/// 所以既不 await 也不往上抛。
void _pushAiState(DeviceAiState state) {
if (_aiStateUnsupported) return;
final s = _session;
if (s == null || s.state != DeviceConnectionState.ready) return;
s.invokeFeature(DeviceFeatures.aiState, {'state': state}).catchError(
(Object e) {
if (e is DeviceException && e.code == DeviceErrorCode.notSupported) {
// 这台设备没有表情/指示灯,记一次就够了
_aiStateUnsupported = true;
Logger.i(_tag, '设备不支持 ai.state,后续不再推送会话状态');
} else {
Logger.w(_tag, '推送 ai.state($state) 失败: $e');
}
return <String, Object?>{};
},
);
}
void _touchActivity() {
_idleTimer?.cancel();
_idleTimer = null;
if (!isActive.value || _uplinkGated) return;
_idleTimer = Timer(_idleTimeout, () {
if (!isActive.value) return;
Logger.i(_tag, '双方 ${_idleTimeout.inSeconds}s 无声,自动结束 AI 会话');
requestStop();
});
}
/// 在设备里放一段提示音(走 AI 下行通道)。播放期间开门控。
Future<void> _playCue(Uint8List pcm, {bool waitForPlayout = false}) async {
final spk = _spk;
if (spk == null || _session?.state != DeviceConnectionState.ready) return;
_openGate();
await spk.write(pcm);
final ms = pcm.length * 1000 ~/ (_deviceRate * 2);
if (waitForPlayout) {
// 结束音要等它真的发完+播完,否则紧接着关通道会把它清掉
await Future.delayed(Duration(milliseconds: ms + 400));
} else {
_gateTimer?.cancel();
_gateTimer = Timer(Duration(milliseconds: ms + 300), _closeGate);
}
}
void _openGate() {
_gateTimer?.cancel();
_gateTimer = null;
if (_uplinkGated) return;
_uplinkGated = true;
_idleTimer?.cancel();
_idleTimer = null;
Logger.i(_tag, '上行门控开启(AI 播报中)');
}
void _closeGate() {
_gateTimer?.cancel();
_gateTimer = null;
if (!_uplinkGated) return;
_uplinkGated = false;
Logger.i(_tag, '上行门控解除');
_touchActivity();
}
void _logResponseAudioStats() {
final start = _firstAudioAt;
if (start == null || _respondingBytes == 0) return;
final ms = DateTime.now().difference(start).inMilliseconds;
final audioMs = _respondingBytes * 1000 ~/ (_deviceRate * 2);
Logger.i(_tag,
'本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算),实际用时 ${ms}ms');
}
/// 估算设备什么时候把这一轮播完,然后回 `LocalRespondingEnded`。
void _schedulePlayoutDone() {
_cancelPlayoutTimer();
const bytesPerSecond = _deviceRate * 2;
final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond;
final elapsedMs = DateTime.now()
.difference(_respondingStartedAt ?? DateTime.now())
.inMilliseconds;
// 上限 10 分钟,跟原生下行队列的容量对齐
final remainMs = (totalMs - elapsedMs).clamp(0, 600000);
Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)');
_playoutTimer = Timer(Duration(milliseconds: remainMs), () {
if (!isActive.value) return;
_svc.notifyLocalRespondingEnded();
_gateTimer = Timer(_gateTail, _closeGate);
if (_quitAfterPlayout) {
_quitAfterPlayout = false;
Logger.i(_tag, '告别语已播完,按 quit 指令结束会话');
_teardown(notifyDevice: true, playEndTone: false);
}
});
}
void _cancelPlayoutTimer() {
_playoutTimer?.cancel();
_playoutTimer = null;
}
// ---------------- 结束 ----------------
/// 主动结束:发 ai.exit,等设备回 hangup;超时就本地强制收尾。
Future<void> requestStop() async {
if (!isActive.value) return;
Logger.i(_tag, '请求设备退出 AI 模式');
final s = _session;
if (s != null) await _sendExit(s);
_stopAckTimer?.cancel();
_stopAckTimer = Timer(_stopAckTimeout, () {
if (!isActive.value) return;
Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到设备的结束应答,本地强制结束');
_teardown(notifyDevice: false);
});
}
Future<void> _sendExit(DeviceSession s) async {
if (s.state != DeviceConnectionState.ready) return;
try {
await s.invokeFeature(DeviceFeatures.aiExit);
} catch (e) {
Logger.w(_tag, '发送 ai.exit 失败: $e');
}
}
/// [notifyDevice] 为 true 时补发 ai.exit:不是设备主导的结束,必须告诉设备退出。
/// [playEndTone] 为 false 用于设备已经断连的场景。
Future<void> _teardown({
required bool notifyDevice,
bool playEndTone = true,
}) async {
if (_tearingDown) return;
_tearingDown = true;
try {
await _doTeardown(notifyDevice: notifyDevice, playEndTone: playEndTone);
} finally {
_tearingDown = false;
}
}
Future<void> _doTeardown({
required bool notifyDevice,
bool playEndTone = true,
}) async {
_stopAckTimer?.cancel();
_stopAckTimer = null;
_idleTimer?.cancel();
_idleTimer = null;
_cancelPlayoutTimer();
_micAccum.clear();
if (isActive.value && playEndTone) {
// 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾
await _spk?.discardPending();
await _playCue(CueTone.dong(sampleRate: _deviceRate), waitForPlayout: true);
}
_closeGate();
final wasActive = isActive.value;
isActive.value = false;
_store.dropIfEmpty(_streamingUser);
_streamingUser = null;
_store.dropIfEmpty(_streamingAssistant);
_streamingAssistant = null;
if (wasActive) _store.persist();
await _eventSub?.cancel();
_eventSub = null;
await _audioSub?.cancel();
_audioSub = null;
await _micSub?.cancel();
_micSub = null;
await _spk?.close();
_spk = null;
await _mic?.close();
_mic = null;
await _svc.stop();
final s = _session;
if (wasActive && notifyDevice && s != null) await _sendExit(s);
_session = null;
if (wasActive) {
Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)');
await _resumePhonePage();
}
}
// ---------------- 与手机 EMAI 页互斥 ----------------
void attachPhonePage(EmaiSessionOwner page) => _phonePage = page;
void detachPhonePage(EmaiSessionOwner page) {
if (identical(_phonePage, page)) _phonePage = null;
}
Future<void> _yieldPhonePage() async {
final ctrl = _phonePage;
if (ctrl == null) return;
Logger.i(_tag, '手机 EMAI 页正开着,让它让出会话');
try {
await ctrl.yieldToEarphone();
} catch (e) {
Logger.w(_tag, '通知 EMAI 页让出失败: $e');
}
}
Future<void> _resumePhonePage() async {
final ctrl = _phonePage;
if (ctrl == null) return;
try {
await ctrl.resumeFromEarphone();
} catch (e) {
Logger.w(_tag, '通知 EMAI 页恢复失败: $e');
}
}
String _currentUserId() {
try {
return user_info.User.isLoggedIn()
? user_info.User.instance.uid
: 'eaimar-guest';
} catch (_) {
return 'eaimar-guest';
}
}
}
/// 手机 EMAI 页实现这个接口,让设备会话能在不反向依赖 modules 层的前提下
/// 通知它让出/恢复。EmaiController 在 onInit/onClose 里
/// 调 [DeviceAiSessionService.attachPhonePage] / [DeviceAiSessionService.detachPhonePage]。
abstract class EmaiSessionOwner {
/// 设备接管:停掉本页的播放与会话,并且不要自动重连
Future<void> yieldToEarphone();
/// 设备结束:把本页的会话接回来
Future<void> resumeFromEarphone();
}