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.
608 lines
23 KiB
608 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 '../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',
|
|
);
|
|
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();
|
|
}
|
|
|