Browse Source

优化移动精灵

newdev_shunjiawei
liwei1dao 7 months ago
parent
commit
3cb4dc95eb
  1. 57
      lib/modules/mobile_elf/controllers/mobile_elf_controller.dart
  2. 34
      lib/modules/mobile_elf/views/mobile_elf_view.dart
  3. 6
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt

57
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:chat_storage/chat_storage.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/permission_util.dart';
enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error } enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error }
@ -43,6 +45,7 @@ class MobileElfController extends GetxController {
Timer? _callTimer; Timer? _callTimer;
Timer? _pttTimer; Timer? _pttTimer;
bool _inCallMode = false; bool _inCallMode = false;
bool _callStarting = false; // 防止并发 startCall
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// 生命周期 // 生命周期
@ -104,19 +107,24 @@ class MobileElfController extends GetxController {
void _onAgentEvent(AgentServiceEvent event) { void _onAgentEvent(AgentServiceEvent event) {
Logger.info('$_tag event: ${event.type} data: ${event.data}'); Logger.info('$_tag event: ${event.type} data: ${event.data}');
// 已挂断/停止时忽略原生层残留的状态事件
final sessionActive = _inCallMode || pttRecording.value;
switch (event.type) { switch (event.type) {
case AgentServiceEventType.recognitionStarted: case AgentServiceEventType.recognitionStarted:
if (!sessionActive) break;
status.value = MobileElfStatus.recording; status.value = MobileElfStatus.recording;
realtimeText.value = ''; realtimeText.value = '';
micAlive.value = true; micAlive.value = true;
break; break;
case AgentServiceEventType.recognizing: case AgentServiceEventType.recognizing:
if (!sessionActive) break;
realtimeText.value = event.data['text']?.toString() ?? ''; realtimeText.value = event.data['text']?.toString() ?? '';
status.value = MobileElfStatus.recording; status.value = MobileElfStatus.recording;
break; break;
case AgentServiceEventType.recognitionResult: case AgentServiceEventType.recognitionResult:
if (!sessionActive) break;
final text = event.data['text']?.toString() ?? ''; final text = event.data['text']?.toString() ?? '';
realtimeText.value = ''; realtimeText.value = '';
micAlive.value = false; micAlive.value = false;
@ -126,14 +134,15 @@ class MobileElfController extends GetxController {
case AgentServiceEventType.recognitionStopped: case AgentServiceEventType.recognitionStopped:
case AgentServiceEventType.recognitionCanceled: case AgentServiceEventType.recognitionCanceled:
if (!sessionActive) break;
realtimeText.value = ''; realtimeText.value = '';
micAlive.value = false; micAlive.value = false;
break; break;
case AgentServiceEventType.assistantResponse: case AgentServiceEventType.assistantResponse:
if (!sessionActive) break;
final text = event.data['response']?.toString() ?? ''; final text = event.data['response']?.toString() ?? '';
if (text.isNotEmpty) _updateOrAddAssistantMessage(text); if (text.isNotEmpty) _updateOrAddAssistantMessage(text);
// 静音模式下收到回复后直接恢复状态(不等 TTS 事件)
if (!ttsEnabled.value) { if (!ttsEnabled.value) {
status.value = status.value =
_inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle;
@ -142,12 +151,13 @@ class MobileElfController extends GetxController {
break; break;
case AgentServiceEventType.ttsStarted: case AgentServiceEventType.ttsStarted:
if (!sessionActive) break;
status.value = MobileElfStatus.speaking; status.value = MobileElfStatus.speaking;
break; break;
case AgentServiceEventType.ttsCompleted: case AgentServiceEventType.ttsCompleted:
if (!sessionActive) break;
if (_inCallMode) { if (_inCallMode) {
// 通话模式:TTS 播完后自动回到录音等待状态
status.value = MobileElfStatus.recording; status.value = MobileElfStatus.recording;
micAlive.value = true; micAlive.value = true;
} else { } else {
@ -158,11 +168,13 @@ class MobileElfController extends GetxController {
case AgentServiceEventType.ttsCanceled: case AgentServiceEventType.ttsCanceled:
case AgentServiceEventType.ttsStopped: case AgentServiceEventType.ttsStopped:
if (!sessionActive) break;
status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle;
micAlive.value = _inCallMode; micAlive.value = _inCallMode;
break; break;
case AgentServiceEventType.error: case AgentServiceEventType.error:
if (!sessionActive) break;
final msg = event.data['message']?.toString() ?? '未知错误'; final msg = event.data['message']?.toString() ?? '未知错误';
status.value = MobileElfStatus.error; status.value = MobileElfStatus.error;
errorMsg.value = msg; errorMsg.value = msg;
@ -185,18 +197,32 @@ class MobileElfController extends GetxController {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
Future<void> startCall() async { Future<void> startCall() async {
if (inCall.value) return; if (inCall.value || _callStarting) return;
_inCallMode = true; _callStarting = true;
inCall.value = true; try {
callSeconds.value = 0; final granted = await PermissionUtil.instance.requestPermission(
status.value = MobileElfStatus.connecting; permissionType: Permission.microphone,
_callTimer?.cancel(); permissionName: '麦克风',
_callTimer = Timer.periodic( explanationText: '移动精灵需要麦克风权限才能进行语音对话',
const Duration(seconds: 1), (_) => callSeconds.value++); permanentDenialText: '请在设置中开启麦克风权限以使用语音功能',
await AgentService.startConversation(mode: 'phone_call'); );
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<void> disconnect() async { Future<void> disconnect() async {
_callStarting = false;
_inCallMode = false; _inCallMode = false;
inCall.value = false; inCall.value = false;
_callTimer?.cancel(); _callTimer?.cancel();
@ -211,8 +237,15 @@ class MobileElfController extends GetxController {
await AgentService.stopConversation(); await AgentService.stopConversation();
} }
void startPtt() { Future<void> startPtt() async {
if (pttRecording.value) return; if (pttRecording.value) return;
final granted = await PermissionUtil.instance.requestPermission(
permissionType: Permission.microphone,
permissionName: '麦克风',
explanationText: '移动精灵需要麦克风权限才能进行语音对话',
permanentDenialText: '请在设置中开启麦克风权限以使用语音功能',
);
if (!granted) return;
pttRecording.value = true; pttRecording.value = true;
pttSeconds.value = 0; pttSeconds.value = 0;
status.value = MobileElfStatus.connecting; status.value = MobileElfStatus.connecting;

34
lib/modules/mobile_elf/views/mobile_elf_view.dart

@ -768,7 +768,7 @@ class MobileElfView extends GetView<MobileElfController> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const _MiniWave(color: Colors.white), Obx(() => _RmsBars(rms: controller.micRms.value, color: Colors.white)),
SizedBox(width: 8.w), SizedBox(width: 8.w),
Obx(() => Text('松开发送 · ${controller.pttSeconds.value}s', Obx(() => Text('松开发送 · ${controller.pttSeconds.value}s',
style: TextStyle(fontSize: 14.sp, color: Colors.white, fontWeight: FontWeight.w500))), 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),
),
);
}),
);
}
}

6
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 onMessage(ws: WebSocket, bytes: okio.ByteString) {}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
webSocket = null if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS
if (!resolved) { resolved = true; cont.resumeWithException(t) } if (!resolved) { resolved = true; cont.resumeWithException(t) }
else launch { handleWsDone() } else launch { handleWsDone() }
} }
@ -480,7 +480,7 @@ class StsAgent(
override fun onClosing(ws: WebSocket, code: Int, reason: String) { ws.close(1000, null) } override fun onClosing(ws: WebSocket, code: Int, reason: String) { ws.close(1000, null) }
override fun onClosed(ws: WebSocket, code: Int, reason: String) { 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")) } if (!resolved) { resolved = true; cont.resumeWithException(IOException("WS closed")) }
else launch { handleWsDone() } else launch { handleWsDone() }
} }
@ -499,7 +499,7 @@ class StsAgent(
} }
private fun handleWsDone() { private fun handleWsDone() {
webSocket = null; forwardingAudio = false; wsConnecting = false forwardingAudio = false; wsConnecting = false
when { when {
sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() } sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() }
sessionEnded -> { /* PTT 结束,等 TTS 播完 */ } sessionEnded -> { /* PTT 结束,等 TTS 播完 */ }

Loading…
Cancel
Save