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.

980 lines
38 KiB

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/widgets.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:just_audio/just_audio.dart';
import 'package:record/record.dart';
import 'package:uuid/uuid.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../../core/utils/logger.dart';
enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error }
class MobileElfMessage {
final bool isUser;
final String text;
final DateTime time;
final String? ttsUrl;
MobileElfMessage({required this.isUser, required this.text, required this.time, this.ttsUrl});
}
class MobileElfController extends GetxController {
// ── 可观察状态 ─────────────────────────────────────────────────────────────
final status = MobileElfStatus.idle.obs;
final messages = <MobileElfMessage>[].obs;
final realtimeText = ''.obs;
final errorMsg = ''.obs;
final inCall = false.obs;
final callSeconds = 0.obs;
final recordingSeconds = 0.obs;
final micRms = 0.0.obs; // 实时麦克风音量(RMS),供 UI 显示
final micAlive = false.obs; // 麦克风流是否存活
final pttRecording = false.obs; // 按住说话录音中
final pttSeconds = 0.obs; // 按住说话计时
final ScrollController scrollController = ScrollController();
// ── WebSocket ──────────────────────────────────────────────────────────────
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Completer<void>? _readyCompleter;
bool _sessionEnded = true;
String _dialogRequestId = '';
int _currentAiMsgIndex = -1;
// ── 录音 & 本地 VAD ────────────────────────────────────────────────────────
final AudioRecorder _recorder = AudioRecorder();
StreamSubscription<Uint8List>? _micSubscription;
// 音频发送缓冲(凑帧用)
final List<int> _sendBuffer = [];
int _sendBufferOffset = 0;
static const int _frameSize = 5120; // 160ms @ 16kHz 16bit mono
// 预缓存:滚动保留最近 ~1.5 秒音频,用于在 WS 连接前缓存用户已说的话
final List<Uint8List> _preBuffer = [];
static const int _preBufferMaxChunks = 75; // ~1.5s (按每 chunk 20ms 算)
// 本地 VAD(仅在空闲等待状态工作,TTS 播放中不做 VAD)
bool _speechDetected = false;
static const int _vadThreshold = 800;
static const int _vadOnsetChunks = 3; // 连续 3 chunk (~60ms) → 检测到说话
int _vadOnsetCount = 0;
// 状态标记
bool _forwardingAudio = false; // 是否正在转发音频到 WS
bool _wsConnecting = false; // WS 正在连接中(防止重复触发)
int _micChunkCount = 0; // 麦克风 chunk 计数(调试用)
// ── 播放 & TTS 队列 ──────────────────────────────────────────────────────
// 关闭 just_audio 的音频会话管理,防止它与 record 争抢音频硬件:
// - handleInterruptions: false → 不自动暂停/恢复
// - handleAudioSessionActivation: false → 不自动调用 session.setActive()
// - androidApplyAudioAttributes: false → 不监听 audio_session 配置变更
final AudioPlayer _player = AudioPlayer(
handleInterruptions: false,
handleAudioSessionActivation: false,
androidApplyAudioAttributes: false,
);
final List<String> _ttsQueue = [];
bool _ttsPlaying = false;
bool _ttsInterrupted = false;
Completer<void>? _ttsPlayCompleter; // 用于等待当前段播完或被打断
// ── 配置 ───────────────────────────────────────────────────────────────────
late String _deviceNo, _deviceId, _productId, _productKey, _deviceSecret;
final credentialReady = false.obs;
final credentialError = ''.obs;
final _storage = GetStorage();
static const _kDeviceId = 'mobile_elf_device_id';
static const _kDeviceSecret = 'mobile_elf_device_secret';
static const _kDeviceNo = 'mobile_elf_device_no';
final _uuid = const Uuid();
// 计时器
Timer? _callTimer;
Timer? _recordingTimer;
Timer? _micWatchdog;
int _lastWatchdogChunkCount = 0;
Timer? _idleTimer;
Timer? _pttTimer;
static const int _idleTimeoutSeconds = 15; // TTS 播完后等待说话超时秒数
// ═══════════════════════════════════════════════════════════════════════════
// 生命周期
// ═══════════════════════════════════════════════════════════════════════════
@override
void onInit() {
super.onInit();
_loadConfig();
_fetchA80CredentialsIfNeeded();
}
@override
void onClose() {
disconnect();
_recorder.dispose();
_player.dispose();
scrollController.dispose();
super.onClose();
}
void _loadConfig() {
_productId = dotenv.env['MOBILE_ELF_PRODUCT_ID'] ?? '1899007766144614402';
_productKey = dotenv.env['MOBILE_ELF_PRODUCT_KEY'] ?? 'd0aI2yidbUUXS';
_deviceNo = _storage.read(_kDeviceNo) ?? dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? '';
_deviceId = _storage.read(_kDeviceId) ?? '';
_deviceSecret = _storage.read(_kDeviceSecret) ?? '';
credentialReady.value = _deviceSecret.isNotEmpty;
}
Future<void> _fetchA80CredentialsIfNeeded() async {
try {
final deviceNo = dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? '';
if (deviceNo.isEmpty) return;
credentialError.value = '';
final dio = Dio(BaseOptions(
baseUrl: 'https://ivs.chinamobiledevice.com:11443',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
final resp = await dio.post('/v2/customer/device/secret/info', data: {
'deviceNoType': 'SN', 'deviceNo': deviceNo,
'productId': _productId, 'productKey': _productKey,
});
final body = resp.data as Map<String, dynamic>;
if (body['code'].toString() == '200' && body['success'] == true) {
final data = body['data'] as Map<String, dynamic>;
_deviceNo = data['deviceNo'] as String? ?? deviceNo;
_deviceId = data['deviceId'] as String? ?? '';
_deviceSecret = data['deviceSecret'] as String? ?? '';
await _storage.write(_kDeviceNo, _deviceNo);
await _storage.write(_kDeviceId, _deviceId);
await _storage.write(_kDeviceSecret, _deviceSecret);
credentialReady.value = _deviceSecret.isNotEmpty;
} else {
credentialError.value = body['message']?.toString() ?? '凭证请求失败';
}
} catch (e) {
credentialError.value = '凭证请求异常: $e';
}
}
Future<void> retryCredentials() async {
credentialError.value = '';
await _fetchA80CredentialsIfNeeded();
}
// ═══════════════════════════════════════════════════════════════════════════
// 开始/结束通话
// ═══════════════════════════════════════════════════════════════════════════
/// 配置音频会话:playAndRecord 模式,允许录音和播放同时进行
Future<void> _configureAudioSession() async {
try {
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
));
await session.setActive(true);
Logger.info('yiding----------- [音频会话] 已配置 playAndRecord 模式');
} catch (e) {
Logger.error('yiding----------- [音频会话] 配置失败: $e');
}
}
/// 点击通话按钮 → 开麦,进入等待说话状态(不连接服务器)
Future<void> startCall() async {
if (inCall.value) return;
// PTT 模式下 AI 正在播报 → 先打断 TTS,再进入通话
if (_ttsPlaying || status.value == MobileElfStatus.speaking) {
_interruptTts();
_ttsInterrupted = false;
// 等一帧让 player.stop() 完成
await Future.delayed(const Duration(milliseconds: 50));
}
// 如果 PTT 正在录音中 → 先取消
if (pttRecording.value) {
await stopPtt(cancel: true);
}
final hasPermission = await _recorder.hasPermission();
if (!hasPermission) { _setError('无麦克风权限'); return; }
await _configureAudioSession();
// 为播放器单独设置 voiceCommunication 音频属性,与录音器使用同一音频路由
// 这样 ExoPlayer 和 AudioRecord 可以像 VoIP 应用一样同时工作
if (!kIsWeb) {
await _player.setAndroidAudioAttributes(const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.voiceCommunication,
));
}
await _openMic();
_startMicWatchdog();
inCall.value = true;
status.value = MobileElfStatus.idle; // 等待用户说话
callSeconds.value = 0;
_callTimer?.cancel();
_callTimer = Timer.periodic(const Duration(seconds: 1), (_) => callSeconds.value++);
_startIdleTimer(); // 开始等待说话超时
Logger.info('yiding----------- [通话] 已开始,等待用户说话');
}
/// 挂断
void disconnect() {
Logger.info('yiding----------- [通话] 挂断');
_stopRecordingTimer();
_stopMicWatchdog();
_cancelIdleTimer();
_callTimer?.cancel(); _callTimer = null;
_forwardingAudio = false;
_wsConnecting = false;
_speechDetected = false;
_micSubscription?.cancel(); _micSubscription = null;
_recorder.stop();
// 清空 TTS 队列 & 停播
_ttsInterrupted = true;
_ttsQueue.clear();
_ttsPlaying = false;
if (!(_ttsPlayCompleter?.isCompleted ?? true)) _ttsPlayCompleter?.complete();
_player.stop();
_closeWs();
_preBuffer.clear();
_sendBuffer.clear(); _sendBufferOffset = 0;
micRms.value = 0; micAlive.value = false;
inCall.value = false;
status.value = MobileElfStatus.idle;
}
// ═══════════════════════════════════════════════════════════════════════════
// 麦克风 & 本地 VAD
// ═══════════════════════════════════════════════════════════════════════════
Future<void> _openMic() async {
_micSubscription?.cancel();
_micSubscription = null;
try { await _recorder.stop(); } catch (_) {}
final stream = await _recorder.startStream(
const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 16000,
numChannels: 1,
echoCancel: true,
noiseSuppress: true,
androidConfig: AndroidRecordConfig(
audioSource: AndroidAudioSource.voiceCommunication,
),
),
);
_speechDetected = false;
_vadOnsetCount = 0;
_micChunkCount = 0;
micAlive.value = false;
_micSubscription = stream.listen(_onMicAudio);
Logger.info('yiding----------- [麦克风] 已开启 (voiceCommunication + echoCancel)');
}
/// 启动麦克风看门狗:每 1.5 秒检测一次,如果无新 chunk 则标记 micAlive=false
void _startMicWatchdog() {
_micWatchdog?.cancel();
_lastWatchdogChunkCount = _micChunkCount;
_micWatchdog = Timer.periodic(const Duration(milliseconds: 1500), (_) {
if (!inCall.value && !pttRecording.value) { _micWatchdog?.cancel(); return; }
if (_micChunkCount == _lastWatchdogChunkCount) {
micAlive.value = false;
if (status.value == MobileElfStatus.speaking || _ttsPlaying) {
// TTS 播放中麦克风已关闭,不需要重启
} else {
Logger.info('yiding----------- [看门狗] 麦克风无数据! 尝试重启');
_openMic();
}
}
_lastWatchdogChunkCount = _micChunkCount;
});
}
void _stopMicWatchdog() {
_micWatchdog?.cancel();
_micWatchdog = null;
}
void _onMicAudio(Uint8List chunk) {
if (!inCall.value && !pttRecording.value) return;
_micChunkCount++;
// 计算 RMS(PCM 16-bit little-endian)
final rms = _calcRms(chunk);
// 更新 UI 可观察值
micAlive.value = true;
micRms.value = rms;
// 每 100 个 chunk (~2s) 输出一次调试日志,确认麦克风存活
if (_micChunkCount % 100 == 0) {
Logger.info('yiding----------- [麦克风] chunk#$_micChunkCount rms=${rms.toStringAsFixed(0)} '
'status=${status.value} speechDetected=$_speechDetected fwd=$_forwardingAudio wsConn=$_wsConnecting');
}
// ── 当前正在转发音频到 WS ──
if (_forwardingAudio && _channel != null) {
_feedSendBuffer(chunk);
return;
}
// ── WS 正在连接中,先存入预缓存 ──
if (_wsConnecting) {
_addToPreBuffer(chunk);
return;
}
// ── TTS 播放中 / thinking 中不做 VAD ──
if (status.value == MobileElfStatus.speaking || _ttsPlaying ||
status.value == MobileElfStatus.thinking) {
return;
}
// ── 空闲/等待状态:连续 chunk VAD ──
_addToPreBuffer(chunk);
if (rms >= _vadThreshold) {
_vadOnsetCount++;
if (_vadOnsetCount >= _vadOnsetChunks && !_speechDetected) {
_speechDetected = true;
_cancelIdleTimer();
Logger.info('yiding----------- [VAD] 检测到说话 rms=${rms.toStringAsFixed(0)} '
'onset=$_vadOnsetCount/$_vadOnsetChunks,启动连接');
_onSpeechDetected();
}
} else {
_vadOnsetCount = 0;
}
}
double _calcRms(Uint8List pcm) {
if (pcm.length < 2) return 0;
final samples = pcm.length ~/ 2;
double sum = 0;
for (int i = 0; i < pcm.length - 1; i += 2) {
int sample = pcm[i] | (pcm[i + 1] << 8);
if (sample >= 32768) sample -= 65536; // signed
sum += sample * sample;
}
return math.sqrt(sum / samples);
}
void _addToPreBuffer(Uint8List chunk) {
_preBuffer.add(Uint8List.fromList(chunk));
while (_preBuffer.length > _preBufferMaxChunks) {
_preBuffer.removeAt(0);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 检测到说话 → 连接服务器 → 发送预缓存 → 实时转发
// ═══════════════════════════════════════════════════════════════════════════
Future<void> _onSpeechDetected() async {
if (_wsConnecting) return;
_ttsInterrupted = false;
_closeWs();
_sessionEnded = false;
_wsConnecting = true;
status.value = MobileElfStatus.connecting;
_dialogRequestId = _uuid.v4();
_currentAiMsgIndex = -1;
realtimeText.value = '';
_sendBuffer.clear();
_sendBufferOffset = 0;
errorMsg.value = '';
Logger.info('yiding----------- [新轮] 说话触发连接 dialogId=$_dialogRequestId preBuffer=${_preBuffer.length}chunks');
try {
await _connect();
Logger.info('yiding----------- [新轮] WS ready,开始发送预缓存');
} catch (e) {
Logger.error('yiding----------- [新轮] 连接失败: $e');
_wsConnecting = false;
_speechDetected = false;
_setError('连接失败: $e');
return;
}
// 发送预缓存音频
_forwardingAudio = true;
_wsConnecting = false;
status.value = MobileElfStatus.recording;
recordingSeconds.value = 0;
_stopRecordingTimer();
_recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) => recordingSeconds.value++);
final cached = List<Uint8List>.from(_preBuffer);
_preBuffer.clear();
int cachedBytes = 0;
for (final c in cached) {
_feedSendBuffer(c);
cachedBytes += c.length;
}
Logger.info('yiding----------- [新轮] 预缓存已发送 ${cached.length}chunks ${cachedBytes}bytes,实时转发中');
}
/// 将 chunk 加入发送缓冲,凑满 _frameSize 就发
void _feedSendBuffer(Uint8List chunk) {
_sendBuffer.addAll(chunk);
while (_sendBuffer.length - _sendBufferOffset >= _frameSize) {
final frame = Uint8List.fromList(
_sendBuffer.sublist(_sendBufferOffset, _sendBufferOffset + _frameSize));
_sendBufferOffset += _frameSize;
_sendAudioChunk(frame);
}
// 紧缩
if (_sendBufferOffset >= _frameSize * 8) {
_sendBuffer.removeRange(0, _sendBufferOffset);
_sendBufferOffset = 0;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// WebSocket 连接
// ═══════════════════════════════════════════════════════════════════════════
Future<void> _connect() async {
_readyCompleter = Completer<void>();
try {
final ts = DateTime.now().millisecondsSinceEpoch.toString();
final sn = _uuid.v4();
final sign = _calcSign(_deviceSecret, ts);
final url = 'wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr'
'?deviceNo=$_deviceNo&deviceId=$_deviceId&productId=$_productId'
'&productKey=$_productKey&sign=$sign&sn=$sn&ts=$ts';
if (kIsWeb) {
_channel = WebSocketChannel.connect(Uri.parse(url));
} else {
final httpClient = HttpClient()..badCertificateCallback = (_, __, ___) => true;
final ws = await WebSocket.connect(url, customClient: httpClient, headers: {
'Origin': 'https://ivs.chinamobiledevice.com:11443',
'Host': 'ivs.chinamobiledevice.com:11443',
});
_channel = IOWebSocketChannel(ws);
}
_wsSubscription = _channel!.stream.listen(
_handleMessage,
onError: (e) {
Logger.error('yiding----------- [WS] 错误: $e');
if (!(_readyCompleter?.isCompleted ?? true)) _readyCompleter?.completeError(e);
_handleWsDone();
},
onDone: () {
Logger.info('yiding----------- [WS] onDone sessionEnded=$_sessionEnded status=${status.value}');
if (!(_readyCompleter?.isCompleted ?? true)) _readyCompleter?.completeError('WS closed');
_handleWsDone();
},
);
_sendStartFrame();
await _readyCompleter!.future.timeout(
const Duration(seconds: 15),
onTimeout: () => throw TimeoutException('等待 ready 超时'),
);
} catch (e) {
_closeWs();
rethrow;
}
}
void _handleWsDone() {
_channel = null;
_wsSubscription = null;
_forwardingAudio = false;
_wsConnecting = false;
if (_sessionEnded && inCall.value) {
Logger.info('yiding----------- [WS] 正常关闭 (通话模式)');
if (status.value == MobileElfStatus.speaking) {
// TTS 播放中,等 playTts 完成后自动回到 idle 等待说话
} else {
status.value = MobileElfStatus.idle;
_resetVad();
Logger.info('yiding----------- [WS] 回到等待说话');
}
} else if (_sessionEnded && !inCall.value) {
// PTT/文本模式:WS 正常关闭
Logger.info('yiding----------- [WS] 正常关闭 (PTT/文本模式)');
if (status.value != MobileElfStatus.speaking) {
status.value = MobileElfStatus.idle;
}
} else if (inCall.value) {
Logger.info('yiding----------- [WS] 非预期关闭,回到等待说话');
status.value = MobileElfStatus.idle;
_resetVad();
} else {
Logger.info('yiding----------- [WS] 非预期关闭');
status.value = MobileElfStatus.idle;
}
}
void _resetVad() {
_speechDetected = false;
_vadOnsetCount = 0;
_preBuffer.clear();
_openMic();
}
// ═══════════════════════════════════════════════════════════════════════════
// 空闲超时:TTS 播完后等待说话,超时自动挂断
// ═══════════════════════════════════════════════════════════════════════════
void _startIdleTimer() {
_cancelIdleTimer();
_idleTimer = Timer(Duration(seconds: _idleTimeoutSeconds), () {
if (inCall.value && status.value == MobileElfStatus.idle && !_speechDetected) {
Logger.info('yiding----------- [超时] ${_idleTimeoutSeconds}秒无说话,自动挂断');
disconnect();
}
});
Logger.info('yiding----------- [超时] 开始${_idleTimeoutSeconds}秒等待说话计时');
}
void _cancelIdleTimer() {
_idleTimer?.cancel();
_idleTimer = null;
}
void _closeWs() {
final sub = _wsSubscription; _wsSubscription = null;
sub?.cancel();
final ch = _channel; _channel = null;
try { ch?.sink.close(); } catch (_) {}
}
// ═══════════════════════════════════════════════════════════════════════════
// 发送帧
// ═══════════════════════════════════════════════════════════════════════════
void _sendStartFrame() {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'start',
'data': {
'cuid': _deviceId,
'format': 'pcm',
'sample': 16000,
'support_dcs': 2,
'chunk_size': 10240,
'support_tts': true,
'support_text2dcs': true,
'user_agent': 'voitrans/1.0',
'dialog_request_id': _dialogRequestId,
'access_rc': true,
'support_part_tts': true,
'need_stoplisten': true,
'need_dialogue_finish': true,
'result_trans2directive': false,
'rc_version': '5',
'messages': _buildContextMessages(),
'client_context': [],
},
}));
}
void _sendAudioChunk(Uint8List data) {
try { _channel?.sink.add(data); } catch (_) {}
}
void _sendFinish() {
try { _channel?.sink.add(jsonEncode({'type': 'finish'})); } catch (_) {}
}
// ═══════════════════════════════════════════════════════════════════════════
// 接收消息处理
// ═══════════════════════════════════════════════════════════════════════════
void _handleMessage(dynamic raw) {
try {
if (raw is! String) return;
final msg = jsonDecode(raw) as Map<String, dynamic>;
final type = msg['type'] as String? ?? '';
switch (type) {
case 'ready':
if (!(_readyCompleter?.isCompleted ?? true)) _readyCompleter?.complete();
break;
case 'stoplisten':
Logger.info('yiding----------- [服务端] stoplisten');
_stopRecordingTimer();
_forwardingAudio = false;
_speechDetected = false; // 允许 VAD 在 thinking/speaking 期间检测新语音
_vadOnsetCount = 0;
// 发送剩余
final remain = _sendBuffer.length - _sendBufferOffset;
if (remain > 0) _sendAudioChunk(Uint8List.fromList(_sendBuffer.sublist(_sendBufferOffset)));
_sendBuffer.clear(); _sendBufferOffset = 0;
_sendFinish();
status.value = MobileElfStatus.thinking;
break;
case 'mid_result':
realtimeText.value = msg['result'] as String? ?? '';
break;
case 'fin_result':
final result = msg['result'] as String? ?? '';
if (result.isNotEmpty) {
realtimeText.value = '';
_addUserMessage(result);
}
if (msg['end'] == 1) _markSessionEnded();
break;
case 'inside_rc':
_handleInsideRc(msg);
break;
case 'dcs_decide':
if (msg['end'] == 1) {
Logger.info('yiding----------- [服务端] dcs_decide end:1');
_markSessionEnded();
}
break;
}
} catch (e) {
Logger.error('[MobileElf] 处理消息失败: $e');
}
}
void _handleInsideRc(Map<String, dynamic> msg) {
final data = msg['data'] as Map<String, dynamic>?;
if (data == null) return;
if ((data['code'] as int? ?? -1) != 0) return;
final assistantAnswer = data['assistant_answer'] as String? ?? '';
String cleanAnswer = assistantAnswer;
try {
cleanAnswer = (jsonDecode(assistantAnswer) as Map<String, dynamic>)['content'] as String? ?? assistantAnswer;
} catch (_) {}
String? ttsUrl;
for (final item in (data['data'] as List<dynamic>? ?? [])) {
final d = item as Map<String, dynamic>;
final h = d['header'] as Map<String, dynamic>?;
if (h?['namespace'] == 'ai.fxzsos.device_interface.voice_output' && h?['name'] == 'Speak') {
ttsUrl = (d['payload'] as Map<String, dynamic>?)?['url'] as String?;
break;
}
}
if (cleanAnswer.isNotEmpty) {
if (_currentAiMsgIndex >= 0 && _currentAiMsgIndex < messages.length) {
messages[_currentAiMsgIndex] = MobileElfMessage(
isUser: false, text: cleanAnswer,
time: messages[_currentAiMsgIndex].time,
ttsUrl: ttsUrl ?? messages[_currentAiMsgIndex].ttsUrl,
);
} else {
_currentAiMsgIndex = messages.length;
_addAiMessage(cleanAnswer, ttsUrl: ttsUrl);
}
}
if (ttsUrl != null && ttsUrl.isNotEmpty) _enqueueTts(ttsUrl);
if (msg['end'] == 1) _markSessionEnded();
}
void _markSessionEnded() {
if (_sessionEnded) return;
_sessionEnded = true;
Logger.info('yiding----------- [会话] 本轮结束 status=${status.value}');
_stopRecordingTimer();
_forwardingAudio = false;
_speechDetected = false; // 允许 VAD 在 TTS 播放期间检测新语音
_vadOnsetCount = 0;
_sendBuffer.clear(); _sendBufferOffset = 0;
realtimeText.value = '';
}
// ═══════════════════════════════════════════════════════════════════════════
// TTS 播放队列
// ═══════════════════════════════════════════════════════════════════════════
/// 将 TTS URL 加入队列,如果没有正在播放则关闭麦克风并开始播放
void _enqueueTts(String url) {
_ttsQueue.add(url);
Logger.info('yiding----------- [TTS] 入队 queue=${_ttsQueue.length}');
if (!_ttsPlaying) {
// 关闭麦克风,避免音频冲突,TTS 播完后会重开
_micSubscription?.cancel(); _micSubscription = null;
try { _recorder.stop(); } catch (_) {}
micAlive.value = false; micRms.value = 0;
Logger.info('yiding----------- [TTS] 关闭麦克风,开始播放');
_playNextTts();
}
}
/// 顺序播放队列中的 TTS
Future<void> _playNextTts() async {
if (_ttsQueue.isEmpty || _ttsInterrupted) {
_ttsPlaying = false;
// 队列播完 → 重开麦克风,回到等待说话,启动超时计时
if (!_ttsInterrupted && status.value == MobileElfStatus.speaking) {
status.value = MobileElfStatus.idle;
Logger.info('yiding----------- [TTS] 队列全部播完,重开麦克风,等待新一轮说话');
if (inCall.value) {
_resetVad();
_startIdleTimer();
}
}
return;
}
_ttsPlaying = true;
status.value = MobileElfStatus.speaking;
final url = _ttsQueue.removeAt(0);
// 用 Completer 等待播放完成或被打断
_ttsPlayCompleter = Completer<void>();
StreamSubscription? stateSub;
try {
await _player.stop();
// 不再重新配置音频会话!startCall() 已配置好 playAndRecord 模式,
// 重复配置会导致 audio_session 重新请求音频焦点,杀死录音流
await _player.setUrl(url);
// 监听 ProcessingState: completed=正常播完, idle=被打断或意外停止
stateSub = _player.processingStateStream.listen((s) {
if ((s == ProcessingState.completed || s == ProcessingState.idle) &&
!(_ttsPlayCompleter?.isCompleted ?? true)) {
Logger.info('yiding----------- [TTS] 当前段结束 state=$s');
_ttsPlayCompleter?.complete();
}
});
await _player.play();
Logger.info('yiding----------- [TTS] 开始播放 剩余队列=${_ttsQueue.length} micAlive=${micAlive.value} micChunk=$_micChunkCount');
// 等待:正常播完 complete() 或被打断 _interruptTts() 也会 complete()
await _ttsPlayCompleter!.future.timeout(const Duration(seconds: 60));
} catch (e) {
Logger.error('yiding----------- [TTS] 播放异常: $e');
} finally {
stateSub?.cancel();
}
// 当前段结束,继续下一段(如果没被打断)
if (!_ttsInterrupted) {
_playNextTts();
}
}
/// 立即打断 TTS:停播 + 清队列 + 解除等待
void _interruptTts() {
Logger.info('yiding----------- [TTS] 打断! 清空队列=${_ttsQueue.length}');
_ttsInterrupted = true;
_ttsQueue.clear();
_ttsPlaying = false;
// 先解除 _playNextTts 的等待
if (!(_ttsPlayCompleter?.isCompleted ?? true)) {
_ttsPlayCompleter?.complete();
}
_player.stop();
}
/// 手动打断 AI 播报(界面按钮调用)
Future<void> interruptSpeaking() async {
_interruptTts();
_ttsInterrupted = false; // 手动打断后重置,不影响后续
status.value = MobileElfStatus.idle;
if (inCall.value) _resetVad();
}
// ═══════════════════════════════════════════════════════════════════════════
// 手动停止录音
// ═══════════════════════════════════════════════════════════════════════════
Future<void> stopRecording() async {
if (!_forwardingAudio) return;
_stopRecordingTimer();
_forwardingAudio = false;
final remain = _sendBuffer.length - _sendBufferOffset;
if (remain > 0) _sendAudioChunk(Uint8List.fromList(_sendBuffer.sublist(_sendBufferOffset)));
_sendBuffer.clear(); _sendBufferOffset = 0;
_sendFinish();
status.value = MobileElfStatus.thinking;
}
void _stopRecordingTimer() {
_recordingTimer?.cancel(); _recordingTimer = null;
}
// ═══════════════════════════════════════════════════════════════════════════
// 按住说话 (PTT)
// ═══════════════════════════════════════════════════════════════════════════
/// 按住说话 → 开始录音并连接 WS
Future<void> startPtt() async {
if (pttRecording.value) return;
// 如果正在播放 AI 回复 → 立即打断
if (_ttsPlaying || status.value == MobileElfStatus.speaking) {
_interruptTts();
_ttsInterrupted = false;
}
final hasPermission = await _recorder.hasPermission();
if (!hasPermission) { _setError('无麦克风权限'); return; }
// 先设置 connecting 状态,再设置 pttRecording,避免中间按钮橙色闪烁
status.value = MobileElfStatus.connecting;
pttRecording.value = true;
pttSeconds.value = 0;
_pttTimer = Timer.periodic(const Duration(seconds: 1), (_) => pttSeconds.value++);
await _configureAudioSession();
if (!kIsWeb) {
await _player.setAndroidAudioAttributes(const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.voiceCommunication,
));
}
await _openMic();
_startMicWatchdog();
_ttsInterrupted = false;
_closeWs();
_sessionEnded = false;
_wsConnecting = true;
_dialogRequestId = _uuid.v4();
_currentAiMsgIndex = -1;
realtimeText.value = '';
_sendBuffer.clear(); _sendBufferOffset = 0;
errorMsg.value = '';
try {
await _connect();
_forwardingAudio = true;
_wsConnecting = false;
status.value = MobileElfStatus.recording;
Logger.info('yiding----------- [PTT] 录音开始');
} catch (e) {
_wsConnecting = false;
pttRecording.value = false;
_pttTimer?.cancel(); _pttTimer = null;
_stopMicWatchdog();
_setError('连接失败: $e');
}
}
/// 松开 → 停止录音,发送 finish
Future<void> stopPtt({bool cancel = false}) async {
pttRecording.value = false;
_pttTimer?.cancel(); _pttTimer = null;
_stopMicWatchdog();
if (cancel) {
_forwardingAudio = false;
_closeWs();
_micSubscription?.cancel(); _micSubscription = null;
try { _recorder.stop(); } catch (_) {}
micAlive.value = false; micRms.value = 0;
realtimeText.value = '';
status.value = MobileElfStatus.idle;
Logger.info('yiding----------- [PTT] 已取消');
return;
}
// 正常结束:发送剩余音频 + finish
if (_forwardingAudio) {
_forwardingAudio = false;
final remain = _sendBuffer.length - _sendBufferOffset;
if (remain > 0) _sendAudioChunk(Uint8List.fromList(_sendBuffer.sublist(_sendBufferOffset)));
_sendBuffer.clear(); _sendBufferOffset = 0;
_sendFinish();
status.value = MobileElfStatus.thinking;
}
_micSubscription?.cancel(); _micSubscription = null;
try { _recorder.stop(); } catch (_) {}
micAlive.value = false; micRms.value = 0;
Logger.info('yiding----------- [PTT] 录音结束,等待AI回复');
}
// ═══════════════════════════════════════════════════════════════════════════
// 按钮操作
// ═══════════════════════════════════════════════════════════════════════════
Future<void> onMicTap() async {
Logger.info('yiding----------- [按钮] status=${status.value} inCall=${inCall.value}');
switch (status.value) {
case MobileElfStatus.idle:
case MobileElfStatus.error:
if (!inCall.value) {
await startCall();
}
break;
case MobileElfStatus.recording:
await stopRecording();
break;
case MobileElfStatus.speaking:
await interruptSpeaking();
break;
default:
break;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 工具方法
// ═══════════════════════════════════════════════════════════════════════════
String _calcSign(String secret, String ts) => md5.convert(utf8.encode('$secret$ts')).toString();
List<Map<String, String>> _buildContextMessages() =>
messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.text}).toList();
void _addUserMessage(String text) {
if (messages.isNotEmpty && messages.last.isUser && messages.last.text == text) return;
messages.add(MobileElfMessage(isUser: true, text: text, time: DateTime.now()));
scrollToBottom();
}
void _addAiMessage(String text, {String? ttsUrl}) {
messages.add(MobileElfMessage(isUser: false, text: text, time: DateTime.now(), ttsUrl: ttsUrl));
scrollToBottom();
}
void _setError(String msg) {
errorMsg.value = msg;
status.value = MobileElfStatus.error;
}
void scrollToBottom() {
if (scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.animateTo(scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
});
}
}
}