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.
 
 
 
 
 
 

610 lines
23 KiB

import 'dart:async';
import 'dart:typed_data';
import 'package:bluetooth_manager/bluetooth_manager.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import '../models/emai_message.dart';
import '../models/user_Info.dart' as user_info;
import 'assistant_directive_service.dart';
import 'bailian_multimodal_service.dart';
import 'mcp_token_service.dart';
import '../utils/cue_tone.dart';
import 'bes_bluetooth_service.dart';
import 'emai_chat_store.dart';
/// 恒玄(BES)耳机的 AI 实时对话会话。
///
/// 把「耳机按键唤醒」接到阿里百炼多模态上:耳机麦的 G.722 上行由原生解码成
/// 16k PCM 后推给百炼,百炼回的 16k PCM 再交给原生的 `AiAudioDownlink`
/// 编成 G.722 按 20ms 节奏发回耳机播放。整条链路上 Dart 只搬运 PCM。
///
/// **握手是耳机主导的**,App 只能应答(时序来自 deepvoice `AgentSessionCoordinator`):
/// ```
/// 耳机按键 ──► BB 64 (startAI)
/// App ──► AA 6A 必须 3 秒内,否则耳机停止送音频
/// …会话进行中,耳机持续上行 mic,App 持续下行 AI 语音…
/// App ──► AA 65 请求退出
/// 耳机 ──► BB E5 (stopAI) 正常收尾;2 秒不回就本地强制结束
/// ```
///
/// 常驻服务:耳机按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。
class BesAiSessionService extends GetxService {
static const String _tag = 'BesAiSession';
static BesAiSessionService get to => Get.find<BesAiSessionService>();
/// 耳机侧的会话与手机 EMAI 页各用一条独立的 WebSocket。
///
/// 共用一个 [BailianMultimodalService] 单例的话,两边会互相把对方的会话
/// `stop()` 掉(`start()` 里第一件事就是 stop),而且下行音频会串到对方的
/// 播放器上。独立实例最省心,代价只是极端情况下两条会话并存——
/// 而这种情况已经由 [_yieldPhonePage] 挡掉了。
final BailianMultimodalService _svc = BailianMultimodalService();
final BluetoothManager _bt = BluetoothManager();
/// 耳机上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致,不用转换
static const int _earphoneRate = 16000;
/// 上行攒够这么多字节再发一包:3200B = 100ms @16k/16bit/mono。
/// 对齐 deepvoice `HeadsetAudioLink.MIC_BATCH_BYTES`,也正好是百炼文档
/// 建议的上行包大小。耳机每 20ms 上来一包,逐包转发就是 50 条 WS 消息/秒,
/// 攒成 100ms 一条能把消息数降到 1/5。
static const int _micBatchBytes = 3200;
final BytesBuilder _micAccum = BytesBuilder(copy: false);
/// 收到 `0x64` 后必须在这个时间内回 `AA 6A`。我们是立即回,
/// 这个常量只是把固件约束写在代码里,改动时别超过它。
static const Duration _ackDeadline = Duration(seconds: 3);
/// 发出 `AA 65` 后等耳机回 `0xE5` 的时间,超时就本地强制结束
static const Duration _stopAckTimeout = Duration(seconds: 2);
StreamSubscription? _aiSub;
StreamSubscription? _eventSub;
StreamSubscription? _audioSub;
Timer? _stopAckTimer;
Timer? _playoutTimer;
/// 会话是否在跑(已回过 `AA 6A`,还没收到/发出结束)
final RxBool isActive = false.obs;
/// 手机 EMAI 页(打开时由它自己 attach 上来)。
///
/// 这里刻意**不**用 `Get.put<EmaiSessionOwner>(controller)` 注册:
/// GetX 的 put 会对实例调一次 onStart→onInit,而页面正是在自己的 onInit 里
/// 注册的,等于 onInit 递归调 onInit —— 实测直接 Stack Overflow。
EmaiSessionOwner? _phonePage;
/// 本轮回复累计推给耳机的 PCM 字节数,用来估算播完的时刻
int _respondingBytes = 0;
DateTime? _respondingStartedAt;
/// 上行是否被门控住(AI 正在耳机里播报期间)。
///
/// 耳机没有把 SPP 下发的音频接进自己的 AEC 参考路径,播出去的 AI 声音会被
/// 自己的麦克风原样收回来;duplex 模式下服务端 VAD 把它当成用户插话,
/// 于是每次 AI 一开口就被自己打断——实测 respondingStarted 之后 0.9s
/// 必定收到 speechStarted,一轮回复被切成碎片,听起来就是杂音。
///
/// 没有 AEC 就只能做半双工:播报期间不把耳机麦的音频交上去。
/// 代价是这段时间没法插话打断,但在没有回声消除的前提下本来也做不到。
bool _uplinkGated = false;
Timer? _gateTimer;
/// teardown 重入保护。结束路径不止一条(空闲超时、耳机回 0xE5、
/// requestStop 超时、百炼断开),而 teardown 里要 await 播完「咚」,
/// 这段时间足够第二条路径挤进来,不挡的话会放两声提示音、收两次尾。
bool _tearingDown = false;
/// 播报结束后再多门控一会儿:耳机自己还有一小段抖动缓冲要播完
static const Duration _gateTail = Duration(milliseconds: 500);
/// 双方都没出声超过这个时长就自动结束会话。
/// "没出声"指:用户没说话(无 ASR 事件)且 AI 也不在播报(门控已解除)。
static const Duration _idleTimeout = Duration(seconds: 5);
Timer? _idleTimer;
/// 本轮**首包音频**到达的时刻。用它而不是 respondingStarted:
/// 实测服务端会先回 RespondingContent 再回 RespondingStarted,
/// 音频也可能先于 RespondingStarted 到,用后者算时长会偏。
DateTime? _firstAudioAt;
/// 上行/下行的包计数,只为排查用:
/// 会话起来了却没有任何语音事件时,第一件要确认的就是耳机到底有没有在送音频。
int _micPackets = 0;
int _downPackets = 0;
/// 耳机这轮对话也要落进共享的聊天记录里。
/// 用户明确要求:不管是耳机直接跟 AI 聊的还是在 App 里聊的,
/// 打开 EMAI 界面都得能看到内容——所以这里不能只做音频转发。
EmaiChatStore get _store => EmaiChatStore.to;
/// 当前正在流式接收的两条消息
EmaiMessage? _streamingUser;
EmaiMessage? _streamingAssistant;
@override
void onInit() {
super.onInit();
_aiSub = _bt.onDeviceAiEvent().listen(_onAiEvent, onError: (e) {
Logger.e(_tag, 'AI 事件流错误: $e');
});
Logger.i(_tag, '耳机 AI 会话服务已就绪,等待耳机按键唤醒');
}
@override
void onClose() {
_aiSub?.cancel();
_teardown(notifyEarphone: true, playEndTone: false);
super.onClose();
}
// ---------------- 耳机事件 ----------------
void _onAiEvent(Map event) {
switch (event['type']) {
case 'startAI':
_handleStartAI();
break;
case 'stopAI':
Logger.i(_tag, '耳机通知结束 AI(0xE5)');
_teardown(notifyEarphone: false);
break;
case 'micData':
final pcm = event['data'];
if (!isActive.value || pcm is! Uint8List) break;
_micPackets++;
// 20ms 一包,50 包 = 1s
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());
}
break;
case 'deviceDisconnected':
if (isActive.value) {
Logger.w(_tag, '耳机断连,结束 AI 会话');
_teardown(notifyEarphone: false, playEndTone: false);
}
break;
}
}
Future<void> _handleStartAI() async {
if (isActive.value) {
Logger.w(_tag, '已有会话在跑,忽略重复的 0x64');
return;
}
Logger.i(_tag, '耳机按键唤醒 AI(0x64)');
// 起不来就别应答,直接让耳机退出 AI 模式——回了 AA 6A 之后耳机会一直
// 送音频,而我们这边没有会话接,它会白等到超时
if (!_svc.isConfigured) {
Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒');
await _sendExitAi();
return;
}
if (BesBluetoothService.to.deviceStatus.value != 2) {
Logger.e(_tag, '耳机未处于已连接状态,拒绝本次唤醒');
return;
}
// 固件要求 3 秒内确认,先回 AA 6A 再做后面的初始化。
// 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。
final ackAt = DateTime.now();
await BesBluetoothService.to.sendData([0xAA, 0x6A]);
isActive.value = true;
_micPackets = 0;
_downPackets = 0;
_uplinkGated = false;
_micAccum.clear();
await _yieldPhonePage();
_eventSub = _svc.events.listen(_onBailianEvent);
// 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样
_audioSub = _svc.audioOut.listen(_onDownstreamPcm);
await _bt.startAiDownlink();
// 先给一声「叮」,让用户立刻知道已经在听了——
// 百炼握手还要一次 WebSocket 往返,不出声的话这段空档很像没反应
await _playCue(CueTone.ding(sampleRate: _earphoneRate));
_touchActivity();
final ok = await _svc.start(
mode: 'duplex',
downstreamRate: _earphoneRate,
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 预算(AA 6A 已提前回,通常无碍)');
}
if (!ok) {
Logger.e(_tag, '百炼会话启动失败,结束');
await _teardown(notifyEarphone: 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 自己的回声
// 触发的,这时候 flush 等于自己把自己的话掐断——之前 45% 的下行音频
// (push 5336 帧只发出 2917 包)就是这么丢掉的。
if (!_uplinkGated) {
_bt.flushAiDownlink();
_cancelPlayoutTimer();
}
// 气泡等第一个 speechContent 再懒建:ASR 一句都没识别出来时
// 服务端回的是 Error 而不是 SpeechEnded,预建的空气泡没人清理
_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);
}
// 纯指令帧没有文本,别建空气泡(详见 BailianEvent.finishReason)
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(notifyEarphone: true);
break;
case BailianEventType.closed:
if (isActive.value) {
Logger.w(_tag, '百炼会话关闭,结束耳机 AI');
_teardown(notifyEarphone: 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)');
}
_bt.pushAiPcm(pcm);
}
/// 有动静就把静默计时重新拨回 5 秒。
///
/// AI 正在耳机里播报([_uplinkGated] 为真)时不计时——那期间本来就有声音,
/// 而且长回复可能播好几分钟,一计时准误杀。门控解除时会再调一次把表开起来。
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 下行通道(原生会 G.722 编码后按节奏发),所以必须在
/// [BluetoothManager.startAiDownlink] 之后、[BluetoothManager.stopAiDownlink]
/// 之前调用。播放期间开门控,免得提示音被耳机麦收回去触发 VAD。
Future<void> _playCue(Uint8List pcm, {bool waitForPlayout = false}) async {
if (BesBluetoothService.to.deviceStatus.value != 2) return;
_openGate();
await _bt.pushAiPcm(pcm);
final ms = pcm.length * 1000 ~/ (_earphoneRate * 2);
if (waitForPlayout) {
// 结束音要等它真的发完+播完,否则紧接着 stopAiDownlink 会把它清掉
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();
}
/// 把这一轮下行音频的字节数/时长打出来。
///
/// 用来核对服务端到底按哪个采样率在发:我们请求的是 16k
/// (16000 × 2 = 32000 B/s),如果实测速率接近 48000 B/s,
/// 说明 `sample_rate` 没被采纳、实际还是 24k —— 那样喂给 16k 的
/// G.722 编码器出来就是变速变调的声音,必须在这里重采样。
void _logResponseAudioStats() {
final start = _firstAudioAt;
if (start == null || _respondingBytes == 0) return;
final ms = DateTime.now().difference(start).inMilliseconds;
final audioMs = _respondingBytes * 1000 ~/ (_earphoneRate * 2);
Logger.i(
_tag,
'本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算) '
',实际用时 ${ms}ms');
}
/// 估算耳机什么时候把这一轮播完,然后回 `LocalRespondingEnded`。
///
/// 拿不到耳机的真实播放进度,只能按实时速率算:16k/16bit/mono = 32000 B/s,
/// 服务端下发通常快于实时,所以「已推字节对应的时长 − 从开播到现在的时长」
/// 就是还没播完的部分。多等一点没坏处(服务端只是晚一点回到 Listening),
/// 早回则会让它以为已经播完而提前接话。
void _schedulePlayoutDone() {
_cancelPlayoutTimer();
const bytesPerSecond = _earphoneRate * 2;
final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond;
final elapsedMs = DateTime.now()
.difference(_respondingStartedAt ?? DateTime.now())
.inMilliseconds;
// 上限给 10 分钟,跟原生下行队列的容量对齐。
// 原先 clamp 到 30 秒,遇到 158 秒的长回复时门控提前 84 秒解除,
// 耳机还在播故事,回环声又被服务端当成用户插话。
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);
});
}
void _cancelPlayoutTimer() {
_playoutTimer?.cancel();
_playoutTimer = null;
}
// ---------------- 结束 ----------------
/// 主动结束(比如 App 侧要收回控制权):发 `AA 65`,等耳机回 `0xE5`;
/// 超时就本地强制收尾,避免卡在「耳机不理我」的状态。
Future<void> requestStop() async {
if (!isActive.value) return;
Logger.i(_tag, '请求耳机退出 AI 模式(AA 65)');
await _sendExitAi();
_stopAckTimer?.cancel();
_stopAckTimer = Timer(_stopAckTimeout, () {
if (!isActive.value) return;
Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到 0xE5,本地强制结束');
_teardown(notifyEarphone: false);
});
}
Future<void> _sendExitAi() async {
try {
await BesBluetoothService.to.sendData([0xAA, 0x65]);
} catch (e) {
Logger.w(_tag, '发送 AA 65 失败: $e');
}
}
/// [notifyEarphone] 为 true 时补发 `AA 65`:不是耳机主导的结束
/// (百炼断开、配置失败、服务销毁),必须告诉耳机退出,
/// 否则它会继续朝一个已经没人接的会话推流。
/// [playEndTone] 为 false 用于耳机已经断连的场景——那时候放「咚」没有意义,
/// 还会白等一次超时。
Future<void> _teardown({
required bool notifyEarphone,
bool playEndTone = true,
}) async {
if (_tearingDown) return;
_tearingDown = true;
try {
await _doTeardown(
notifyEarphone: notifyEarphone, playEndTone: playEndTone);
} finally {
_tearingDown = false;
}
}
Future<void> _doTeardown({
required bool notifyEarphone,
bool playEndTone = true,
}) async {
_stopAckTimer?.cancel();
_stopAckTimer = null;
_idleTimer?.cancel();
_idleTimer = null;
_cancelPlayoutTimer();
_micAccum.clear();
if (isActive.value && playEndTone) {
// 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾
await _bt.flushAiDownlink();
await _playCue(CueTone.dong(sampleRate: _earphoneRate),
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 _bt.stopAiDownlink();
await _svc.stop();
if (wasActive && notifyEarphone) await _sendExitAi();
if (wasActive) {
Logger.i(_tag, '耳机 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)');
await _resumePhonePage();
}
}
// ---------------- 与手机 EMAI 页互斥 ----------------
/// 耳机会话优先:手机上的 EMAI 页正开着就让它先让出去。
///
/// 用动态查找而不是直接 import EmaiController,是为了不让 data 层反向依赖
/// modules 层——这个服务是常驻的,EMAI 页只是它的可选邻居。
/// EMAI 页打开时把自己挂上来
void attachPhonePage(EmaiSessionOwner page) => _phonePage = page;
/// EMAI 页销毁时摘掉。带实例校验:页面快速重建时新页可能已经 attach,
/// 旧页的 detach 不该把新页摘掉。
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 里
/// 调 [BesAiSessionService.attachPhonePage] / [BesAiSessionService.detachPhonePage]。
abstract class EmaiSessionOwner {
/// 耳机接管:停掉本页的播放与会话,并且不要自动重连
Future<void> yieldToEarphone();
/// 耳机结束:把本页的会话接回来
Future<void> resumeFromEarphone();
}