From 3cb4dc95eb153edd32225767b4db5db3d105ab93 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 20 Mar 2026 09:45:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=A7=BB=E5=8A=A8=E7=B2=BE?= =?UTF-8?q?=E7=81=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/mobile_elf_controller.dart | 57 +++++++++++++++---- .../mobile_elf/views/mobile_elf_view.dart | 34 ++++++++++- .../yunqiinnovation/agent_service/StsAgent.kt | 6 +- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart index a673cf443..6393fc975 100644 --- a/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart +++ b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart @@ -3,8 +3,10 @@ import 'package:agent_service/agent_service.dart'; import 'package:chat_storage/chat_storage.dart'; import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../../../data/models/appconfig.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/permission_util.dart'; enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error } @@ -43,6 +45,7 @@ class MobileElfController extends GetxController { Timer? _callTimer; Timer? _pttTimer; bool _inCallMode = false; + bool _callStarting = false; // 防止并发 startCall // ═══════════════════════════════════════════════════════════════════════════ // 生命周期 @@ -104,19 +107,24 @@ class MobileElfController extends GetxController { void _onAgentEvent(AgentServiceEvent event) { Logger.info('$_tag event: ${event.type} data: ${event.data}'); + // 已挂断/停止时忽略原生层残留的状态事件 + final sessionActive = _inCallMode || pttRecording.value; switch (event.type) { case AgentServiceEventType.recognitionStarted: + if (!sessionActive) break; status.value = MobileElfStatus.recording; realtimeText.value = ''; micAlive.value = true; break; case AgentServiceEventType.recognizing: + if (!sessionActive) break; realtimeText.value = event.data['text']?.toString() ?? ''; status.value = MobileElfStatus.recording; break; case AgentServiceEventType.recognitionResult: + if (!sessionActive) break; final text = event.data['text']?.toString() ?? ''; realtimeText.value = ''; micAlive.value = false; @@ -126,14 +134,15 @@ class MobileElfController extends GetxController { case AgentServiceEventType.recognitionStopped: case AgentServiceEventType.recognitionCanceled: + if (!sessionActive) break; realtimeText.value = ''; micAlive.value = false; break; case AgentServiceEventType.assistantResponse: + if (!sessionActive) break; final text = event.data['response']?.toString() ?? ''; if (text.isNotEmpty) _updateOrAddAssistantMessage(text); - // 静音模式下收到回复后直接恢复状态(不等 TTS 事件) if (!ttsEnabled.value) { status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; @@ -142,12 +151,13 @@ class MobileElfController extends GetxController { break; case AgentServiceEventType.ttsStarted: + if (!sessionActive) break; status.value = MobileElfStatus.speaking; break; case AgentServiceEventType.ttsCompleted: + if (!sessionActive) break; if (_inCallMode) { - // 通话模式:TTS 播完后自动回到录音等待状态 status.value = MobileElfStatus.recording; micAlive.value = true; } else { @@ -158,11 +168,13 @@ class MobileElfController extends GetxController { case AgentServiceEventType.ttsCanceled: case AgentServiceEventType.ttsStopped: + if (!sessionActive) break; status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; micAlive.value = _inCallMode; break; case AgentServiceEventType.error: + if (!sessionActive) break; final msg = event.data['message']?.toString() ?? '未知错误'; status.value = MobileElfStatus.error; errorMsg.value = msg; @@ -185,18 +197,32 @@ class MobileElfController extends GetxController { // ═══════════════════════════════════════════════════════════════════════════ Future startCall() async { - if (inCall.value) return; - _inCallMode = true; - inCall.value = true; - callSeconds.value = 0; - status.value = MobileElfStatus.connecting; - _callTimer?.cancel(); - _callTimer = Timer.periodic( - const Duration(seconds: 1), (_) => callSeconds.value++); - await AgentService.startConversation(mode: 'phone_call'); + if (inCall.value || _callStarting) return; + _callStarting = true; + try { + final granted = await PermissionUtil.instance.requestPermission( + permissionType: Permission.microphone, + permissionName: '麦克风', + explanationText: '移动精灵需要麦克风权限才能进行语音对话', + permanentDenialText: '请在设置中开启麦克风权限以使用语音功能', + ); + if (!granted || inCall.value) return; // 权限等待期间可能已被挂断或重复调用 + _inCallMode = true; + inCall.value = true; + callSeconds.value = 0; + micAlive.value = true; + status.value = MobileElfStatus.connecting; + _callTimer?.cancel(); + _callTimer = Timer.periodic( + const Duration(seconds: 1), (_) => callSeconds.value++); + await AgentService.startConversation(mode: 'phone_call'); + } finally { + _callStarting = false; + } } Future disconnect() async { + _callStarting = false; _inCallMode = false; inCall.value = false; _callTimer?.cancel(); @@ -211,8 +237,15 @@ class MobileElfController extends GetxController { await AgentService.stopConversation(); } - void startPtt() { + Future startPtt() async { if (pttRecording.value) return; + final granted = await PermissionUtil.instance.requestPermission( + permissionType: Permission.microphone, + permissionName: '麦克风', + explanationText: '移动精灵需要麦克风权限才能进行语音对话', + permanentDenialText: '请在设置中开启麦克风权限以使用语音功能', + ); + if (!granted) return; pttRecording.value = true; pttSeconds.value = 0; status.value = MobileElfStatus.connecting; diff --git a/lib/modules/mobile_elf/views/mobile_elf_view.dart b/lib/modules/mobile_elf/views/mobile_elf_view.dart index 5dc2d89a1..da76aa701 100644 --- a/lib/modules/mobile_elf/views/mobile_elf_view.dart +++ b/lib/modules/mobile_elf/views/mobile_elf_view.dart @@ -768,7 +768,7 @@ class MobileElfView extends GetView { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const _MiniWave(color: Colors.white), + Obx(() => _RmsBars(rms: controller.micRms.value, color: Colors.white)), SizedBox(width: 8.w), Obx(() => Text('松开发送 · ${controller.pttSeconds.value}s', style: TextStyle(fontSize: 14.sp, color: Colors.white, fontWeight: FontWeight.w500))), @@ -941,3 +941,35 @@ class _VolumeIndicator extends StatelessWidget { ); } } + +/// PTT 录音时响应麦克风音量的波形条 +class _RmsBars extends StatelessWidget { + final double rms; + final Color color; + const _RmsBars({required this.rms, required this.color}); + + @override + Widget build(BuildContext context) { + // rms 范围约 0~6000,归一化到 0~1 + final level = (rms / 5000).clamp(0.0, 1.0); + const barCount = 3; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(barCount, (i) { + final threshold = (i + 1) / barCount; + final h = level >= threshold ? 10.0 : 3.0; + return AnimatedContainer( + duration: const Duration(milliseconds: 80), + width: 2.5, + height: h, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ); + } +} diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt index 01f938ac8..8454d3eab 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt @@ -472,7 +472,7 @@ class StsAgent( override fun onMessage(ws: WebSocket, bytes: okio.ByteString) {} override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { - webSocket = null + if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS if (!resolved) { resolved = true; cont.resumeWithException(t) } else launch { handleWsDone() } } @@ -480,7 +480,7 @@ class StsAgent( override fun onClosing(ws: WebSocket, code: Int, reason: String) { ws.close(1000, null) } override fun onClosed(ws: WebSocket, code: Int, reason: String) { - webSocket = null + if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS if (!resolved) { resolved = true; cont.resumeWithException(IOException("WS closed")) } else launch { handleWsDone() } } @@ -499,7 +499,7 @@ class StsAgent( } private fun handleWsDone() { - webSocket = null; forwardingAudio = false; wsConnecting = false + forwardingAudio = false; wsConnecting = false when { sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() } sessionEnded -> { /* PTT 结束,等 TTS 播完 */ }