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.
 
 
 
 
 
 

342 lines
13 KiB

import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import '../../core/utils/logger.dart';
import '../models/assistant_directive.dart';
import 'bailian_multimodal_service.dart';
/// 耳机唤醒那条 EMAI 链路的**原生**百炼客户端句柄。
///
/// 真正的 WebSocket 与音频收发在 `azure_speech` 插件里
/// (`BailianDialogClient.swift` / `.kt`);上行 PCM 由 `bluetooth_manager` 直接
/// 交过去,合成音频原路回耳机,**一个字节都不经过 Dart**。这里只做三件事:
/// 起停、把原生事件翻译成 [BailianEvent]、把门控开关推下去。
///
/// ## 为什么保留 Dart 版
/// [BailianMultimodalService] 仍然服务两种情况:
/// 1. **手机麦那条链路**(EMAI 页按住说话)——音频本来就在 Dart 侧采集,没有下沉的意义;
/// 2. **退路**——插件是老版本(`MissingPluginException`)、或设备不是恒玄(AI PCM 桥
/// 只存在于 bluetooth_manager)时,整条链路退回 Dart,功能不受影响。
///
/// ## ⚠️ 这不修「锁屏断连」
/// iOS 挂起的是整个进程,原生和 Dart 一起停。锁屏能不能活着取决于
/// `UIBackgroundModes=audio` + 确实有音频在播(`BackgroundKeepalive` 干的事),
/// 与代码在哪一层无关。本类解决的是 event loop 抖动与跨界开销。
class BailianNativeDialog {
BailianNativeDialog({String? appId, String? workspaceId})
: _appIdOverride = appId,
_workspaceIdOverride = workspaceId;
static const String _tag = 'BailianNative';
static const MethodChannel _channel =
MethodChannel('azure_speech/bailian_dialog');
static const EventChannel _events =
EventChannel('azure_speech/bailian_dialog_events');
final String? _appIdOverride;
final String? _workspaceIdOverride;
final _eventCtl = StreamController<BailianEvent>.broadcast();
final _downBytesCtl = StreamController<int>.broadcast();
final _micActivityCtl = StreamController<void>.broadcast();
StreamSubscription? _sub;
bool _running = false;
/// 本次会话解出来的指令记到哪个来源名下(与 Dart 版同义)
String directiveSource = 'earphone';
/// 业务事件流。与 [BailianMultimodalService.events] 同一套 [BailianEvent],
/// 所以 `DeviceAiSessionService` 的事件处理一行都不用改。
Stream<BailianEvent> get events => _eventCtl.stream;
/// 下行已写入的**累计**字节数(约 100ms 一条)。
///
/// 原生直连之后 Dart 拿不到下行 PCM,但它要靠「首包到达」开门控、靠累计字节数
/// 估算播完时刻。这两件事都只依赖长度,所以只报数字。
Stream<int> get downstreamBytes => _downBytesCtl.stream;
/// 设备上行有声(原生按 500ms 节流上报)。
///
/// ⚠️ **不能把它当成 `speechStarted`**:那个事件在会话服务里会丢弃待播队列、
/// 取消播完计时(「真人插话」的语义)。这里只是给空闲计时续命,
/// 混在一起会让 AI 每说一句就被自己的回声打断一次。
Stream<void> get micActivity => _micActivityCtl.stream;
bool get isRunning => _running;
bool get isConfigured =>
BailianMultimodalService.resolveApiKey().trim().isNotEmpty;
String get configHint => isConfigured ? '' : '未配置 ALIBABA_OPENSPEECH_APP_KEY';
/// 这台机器/这个包能不能用原生链路。
///
/// 老包没有这个方法通道 → `MissingPluginException` → 返回 false,调用方退回 Dart。
/// Web 直接排除(压根没有原生插件)。
static Future<bool> isAvailable() async {
if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return false;
try {
await _channel.invokeMethod<Map>('stats');
return true;
} on MissingPluginException {
return false;
} catch (e) {
Logger.w(_tag, '原生百炼客户端探测失败(退回 Dart): $e');
return false;
}
}
/// 建立会话。参数与 [BailianMultimodalService.start] 一一对应。
Future<bool> start({
String mode = 'duplex',
String? voice,
String? userId,
bool enableWebSearch = true,
int downstreamRate = 16000,
String source = 'earphone',
String? mcpToken,
}) async {
if (!isConfigured) {
Logger.e(_tag, '配置不全,无法启动会话:$configHint');
_eventCtl.add(BailianEvent(BailianEventType.error,
text: configHint, code: 'NOT_CONFIGURED', fatal: true));
return false;
}
await stop();
directiveSource = source;
_sub = _events.receiveBroadcastStream().listen(
_onNativeEvent,
onError: (e) {
Logger.e(_tag, '原生事件流错误: $e');
_eventCtl.add(
BailianEvent(BailianEventType.error, text: '$e', fatal: true));
},
);
try {
final appId = BailianMultimodalService.resolveAppId(_appIdOverride);
// 地址和日期的文案只在 Dart 算一份。原生负责写进 Start,不在这里等定位。
final ctx = BailianMultimodalService.promptContext(appId);
final rawLoc = ctx?['clientLocation'];
final locMap = rawLoc is Map
? rawLoc.map((k, v) => MapEntry('$k', '$v'))
: null;
if (ctx != null) {
Logger.i(
_tag,
'会话上下文: location=${ctx['location']}, date=${ctx['date']}, '
'client_info.location=${locMap == null ? '无' : '有'}');
}
final ok = await _channel.invokeMethod<bool>('start', {
'apiKey': BailianMultimodalService.resolveApiKey(),
'appId': appId,
'workspaceId':
BailianMultimodalService.resolveWorkspaceId(_workspaceIdOverride),
'mode': mode,
if (voice != null && voice.trim().isNotEmpty) 'voice': voice,
'userId': userId ?? '',
'enableWebSearch': enableWebSearch,
'downstreamRate': downstreamRate,
if (mcpToken != null && mcpToken.trim().isNotEmpty) 'mcpToken': mcpToken,
// 工具名只在 Dart 维护一份:服务端加工具时改那一处就够了
'mcpAuthTools': BailianMultimodalService.mcpAuthTools,
if (ctx != null) 'promptLocation': ctx['location'],
if (ctx != null) 'promptDate': ctx['date'],
if (locMap != null) 'latitude': locMap['latitude'],
if (locMap != null) 'longitude': locMap['longitude'],
if ((locMap?['city_name'] ?? '').isNotEmpty) 'cityName': locMap!['city_name'],
});
_running = ok == true;
if (!_running) await _cancelSub();
return _running;
} on MissingPluginException {
Logger.w(_tag, '原生不支持,退回 Dart 链路');
await _cancelSub();
return false;
} catch (e) {
Logger.e(_tag, '启动原生会话失败: $e');
await _cancelSub();
return false;
}
}
Future<void> stop() async {
if (!_running) {
await _cancelSub();
return;
}
_running = false;
try {
await _channel.invokeMethod<void>('stop');
} catch (e) {
Logger.w(_tag, '停止原生会话失败: $e');
}
await _cancelSub();
}
/// AI 是否正在耳机里播报。播报期间原生把上行换成等长静音。
///
/// **什么时候开关门由 Dart 决定**(`DeviceAiSessionService` 里那套带兜底计时、
/// 非致命错误也要关门的逻辑),这里只是把结论推下去。
Future<void> setGated(bool gated) => _invoke('setGated', {'gated': gated});
Future<void> notifyLocalRespondingStarted() =>
_invoke('localRespondingStarted');
Future<void> notifyLocalRespondingEnded() => _invoke('localRespondingEnded');
Future<void> interrupt() => _invoke('interrupt');
Future<void> beginSpeech() => _invoke('beginSpeech');
Future<void> endSpeech() => _invoke('endSpeech');
Future<void> cancelSpeech() => _invoke('cancelSpeech');
Future<void> sendText(String text, {bool speakDirectly = false}) =>
_invoke('sendText', {'text': text, 'speakDirectly': speakDirectly});
/// 诊断:上/下行字节、出包、丢包、门控状态。桥开着却没声音时先问它。
Future<Map<String, dynamic>> stats() async {
try {
final raw = await _channel.invokeMethod<Map>('stats');
return raw == null ? const {} : Map<String, dynamic>.from(raw);
} catch (e) {
return {'error': '$e'};
}
}
Future<void> _invoke(String method, [Map<String, dynamic>? args]) async {
if (!_running) return;
try {
await _channel.invokeMethod<void>(method, args);
} catch (e) {
Logger.w(_tag, '$method 失败: $e');
}
}
Future<void> _cancelSub() async {
await _sub?.cancel();
_sub = null;
}
// ---------- 事件翻译 ----------
void _onNativeEvent(dynamic raw) {
if (raw is! Map) return;
final name = raw['event']?.toString() ?? '';
final text = raw['text']?.toString() ?? '';
final isFinal = raw['isFinal'] == true;
switch (name) {
case 'started':
_emit(const BailianEvent(BailianEventType.started));
break;
case 'stateChanged':
final s = raw['state']?.toString() ?? '';
_emit(BailianEvent(BailianEventType.stateChanged, state: _mapState(s)));
break;
case 'speechStarted':
_emit(const BailianEvent(BailianEventType.speechStarted));
break;
case 'speechContent':
_emit(BailianEvent(BailianEventType.speechContent,
text: text, isFinal: isFinal));
break;
case 'speechEnded':
_emit(BailianEvent(BailianEventType.speechEnded,
text: text, isFinal: true));
break;
case 'requestAccepted':
_emit(const BailianEvent(BailianEventType.requestAccepted));
break;
case 'respondingStarted':
_emit(const BailianEvent(BailianEventType.respondingStarted));
break;
case 'respondingContent':
_emit(BailianEvent(
BailianEventType.respondingContent,
text: text,
isFinal: isFinal,
finishReason: raw['finishReason']?.toString() ?? '',
directives: _parseDirectives(raw['extraInfo']?.toString(), text),
));
break;
case 'respondingEnded':
_emit(BailianEvent(BailianEventType.respondingEnded,
text: text, isFinal: true));
break;
case 'error':
_emit(BailianEvent(BailianEventType.error,
text: text,
code: raw['code']?.toString(),
fatal: raw['fatal'] == true));
break;
case 'closed':
_running = false;
_emit(const BailianEvent(BailianEventType.closed));
break;
case 'downlink':
final n = (raw['bytes'] as num?)?.toInt() ?? 0;
if (!_downBytesCtl.isClosed) _downBytesCtl.add(n);
break;
case 'micActivity':
// 只走自己的流,不冒充任何协议事件(见 micActivity 的说明)
if (!_micActivityCtl.isClosed) _micActivityCtl.add(null);
break;
case 'overflow':
// 上行拥塞丢包。warn 级:这是「说了没反应」时唯一的证据
Logger.w(_tag, '[AI-BRIDGE] 上行拥塞,原生累计丢包=${raw['dropped']}');
break;
default:
Logger.d(_tag, '未处理的原生事件: $name');
}
}
/// 指令解析**仍然走 Dart 这一份**:`extra_info.tool_calls` 的那些坑
/// (参数是字符串套 JSON、值恒为中文、下午时间要校正)有一批真实 payload 的
/// 金测试守着,原生再写一遍只会让那批测试失去意义。原生把 `extra_info`
/// 原样转成字符串带上来,这里拼回 output 的形状交给同一个解析器。
List<AssistantDirective> _parseDirectives(String? extraInfoJson, String text) {
if (extraInfoJson == null || extraInfoJson.isEmpty) return const [];
try {
final extra = json.decode(extraInfoJson);
final parsed = AssistantDirective.parseFromOutput(
{'extra_info': extra, 'text': text},
source: directiveSource,
);
if (parsed.isNotEmpty) {
// warn 级、且只在真的带了指令时打——「闹钟没定上」只有两种可能
// (百炼没下发 / 端侧没落地),这一行是把两者分开的证据
Logger.w(_tag, '[指令帧] 原生链路解析出 ${parsed.length} 条');
}
return parsed;
} catch (e) {
Logger.w(_tag, 'extra_info 解析失败: $e');
return const [];
}
}
static BailianDialogState _mapState(String s) => switch (s) {
'listening' => BailianDialogState.listening,
'thinking' => BailianDialogState.thinking,
'responding' => BailianDialogState.responding,
_ => BailianDialogState.idle,
};
void _emit(BailianEvent e) {
if (!_eventCtl.isClosed) _eventCtl.add(e);
}
Future<void> dispose() async {
await stop();
await _eventCtl.close();
await _downBytesCtl.close();
await _micActivityCtl.close();
}
}