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.broadcast(); final _downBytesCtl = StreamController.broadcast(); final _micActivityCtl = StreamController.broadcast(); StreamSubscription? _sub; bool _running = false; /// 本次会话解出来的指令记到哪个来源名下(与 Dart 版同义) String directiveSource = 'earphone'; /// 业务事件流。与 [BailianMultimodalService.events] 同一套 [BailianEvent], /// 所以 `DeviceAiSessionService` 的事件处理一行都不用改。 Stream get events => _eventCtl.stream; /// 下行已写入的**累计**字节数(约 100ms 一条)。 /// /// 原生直连之后 Dart 拿不到下行 PCM,但它要靠「首包到达」开门控、靠累计字节数 /// 估算播完时刻。这两件事都只依赖长度,所以只报数字。 Stream get downstreamBytes => _downBytesCtl.stream; /// 设备上行有声(原生按 500ms 节流上报)。 /// /// ⚠️ **不能把它当成 `speechStarted`**:那个事件在会话服务里会丢弃待播队列、 /// 取消播完计时(「真人插话」的语义)。这里只是给空闲计时续命, /// 混在一起会让 AI 每说一句就被自己的回声打断一次。 Stream 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 isAvailable() async { if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return false; try { await _channel.invokeMethod('stats'); return true; } on MissingPluginException { return false; } catch (e) { Logger.w(_tag, '原生百炼客户端探测失败(退回 Dart): $e'); return false; } } /// 建立会话。参数与 [BailianMultimodalService.start] 一一对应。 Future 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 ok = await _channel.invokeMethod('start', { 'apiKey': BailianMultimodalService.resolveApiKey(), 'appId': BailianMultimodalService.resolveAppId(_appIdOverride), '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, }); _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 stop() async { if (!_running) { await _cancelSub(); return; } _running = false; try { await _channel.invokeMethod('stop'); } catch (e) { Logger.w(_tag, '停止原生会话失败: $e'); } await _cancelSub(); } /// AI 是否正在耳机里播报。播报期间原生把上行换成等长静音。 /// /// **什么时候开关门由 Dart 决定**(`DeviceAiSessionService` 里那套带兜底计时、 /// 非致命错误也要关门的逻辑),这里只是把结论推下去。 Future setGated(bool gated) => _invoke('setGated', {'gated': gated}); Future notifyLocalRespondingStarted() => _invoke('localRespondingStarted'); Future notifyLocalRespondingEnded() => _invoke('localRespondingEnded'); Future interrupt() => _invoke('interrupt'); Future beginSpeech() => _invoke('beginSpeech'); Future endSpeech() => _invoke('endSpeech'); Future cancelSpeech() => _invoke('cancelSpeech'); Future sendText(String text, {bool speakDirectly = false}) => _invoke('sendText', {'text': text, 'speakDirectly': speakDirectly}); /// 诊断:上/下行字节、出包、丢包、门控状态。桥开着却没声音时先问它。 Future> stats() async { try { final raw = await _channel.invokeMethod('stats'); return raw == null ? const {} : Map.from(raw); } catch (e) { return {'error': '$e'}; } } Future _invoke(String method, [Map? args]) async { if (!_running) return; try { await _channel.invokeMethod(method, args); } catch (e) { Logger.w(_tag, '$method 失败: $e'); } } Future _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 _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 dispose() async { await stop(); await _eventCtl.close(); await _downBytesCtl.close(); await _micActivityCtl.close(); } }