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
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();
|
|
}
|
|
}
|
|
|