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.

572 lines
18 KiB

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
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';
import '../../../data/models/user_Info.dart';
import '../../../data/services/bluetooth_manager.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;
// WebSocket
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
// 录音
final AudioRecorder _recorder = AudioRecorder();
StreamSubscription<Uint8List>? _audioSubscription;
final List<int> _audioBuffer = [];
static const int _frameSize = 5120; // 160ms @ 16kHz 16bit mono
// 播放
final AudioPlayer _player = AudioPlayer();
// 配置
late String _deviceNo;
late String _deviceId;
late String _productId;
late String _productKey;
late String _deviceSecret;
// A80 凭证状态
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';
// 当前对话 ID
String _dialogRequestId = '';
final _uuid = const Uuid();
@override
void onInit() {
super.onInit();
_loadConfig();
_fetchA80CredentialsIfNeeded();
}
@override
void onClose() {
disconnect();
_recorder.dispose();
_player.dispose();
super.onClose();
}
void _loadConfig() {
_productId = dotenv.env['MOBILE_ELF_PRODUCT_ID'] ?? '1899007766144614402';
_productKey = dotenv.env['MOBILE_ELF_PRODUCT_KEY'] ?? 'd0aI2yidbUUXS';
// 优先使用缓存的 A80 凭证,否则降级到 .env 默认值
_deviceNo = _storage.read(_kDeviceNo) ??
dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? '869700070000570';
_deviceId = _storage.read(_kDeviceId) ??
dotenv.env['MOBILE_ELF_DEVICE_ID'] ?? '1899013964170694658';
_deviceSecret = _storage.read(_kDeviceSecret) ??
dotenv.env['MOBILE_ELF_DEVICE_SECRET'] ?? '';
credentialReady.value = _deviceSecret.isNotEmpty;
}
/// 检测连接的耳机是否为 A80,如是则用其 license 换取平台凭证
Future<void> _fetchA80CredentialsIfNeeded() async {
try {
final btManager = Get.find<BluetoothManager>();
final deviceName = btManager.currentDevice?.name ?? '';
final deviceMac = btManager.currentDevice?.address ?? '';
final license = _findLicense(deviceMac);
Logger.i('[MobileElf]', '=== 设备信息 ===');
Logger.i('[MobileElf]', '设备名称: $deviceName');
Logger.i('[MobileElf]', '设备MAC: $deviceMac');
Logger.i('[MobileElf]', '匹配license: $license');
Logger.i('[MobileElf]', '===============');
if (!deviceName.toUpperCase().contains('A80')) {
credentialReady.value = _deviceSecret.isNotEmpty;
return;
}
// 已有缓存凭证且设备号未变则直接使用
final cachedNo = _storage.read<String>(_kDeviceNo) ?? '';
final cachedSecret = _storage.read<String>(_kDeviceSecret) ?? '';
if (license == null || license.isEmpty) {
Logger.w('[MobileElf]', 'A80 设备未找到 license,使用默认凭证');
credentialReady.value = _deviceSecret.isNotEmpty;
return;
}
if (cachedNo == license && cachedSecret.isNotEmpty) {
Logger.i('[MobileElf]', 'A80 凭证命中缓存,跳过请求');
credentialReady.value = true;
return;
}
credentialError.value = '';
Logger.i('[MobileElf]', 'A80 设备 license=$license,开始请求平台凭证');
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': license,
'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? ?? license;
_deviceId = data['deviceId'] as String? ?? _deviceId;
_deviceSecret = data['deviceSecret'] as String? ?? '';
await _storage.write(_kDeviceNo, _deviceNo);
await _storage.write(_kDeviceId, _deviceId);
await _storage.write(_kDeviceSecret, _deviceSecret);
credentialReady.value = _deviceSecret.isNotEmpty;
Logger.i('[MobileElf]', 'A80 凭证获取成功 deviceId=$_deviceId');
} else {
credentialError.value = body['message']?.toString() ?? '凭证请求失败';
Logger.e('[MobileElf]', '平台返回错误: ${body['message']}');
}
} catch (e) {
credentialError.value = '凭证请求异常: $e';
Logger.e('[MobileElf]', '获取 A80 凭证失败: $e');
}
}
/// 从用户设备列表中按蓝牙 MAC 匹配 license
String? _findLicense(String? bluetoothMac) {
if (!User.isLoggedIn()) return null;
final devices = User.instance.devices;
if (bluetoothMac != null && bluetoothMac.isNotEmpty) {
final matched = devices.where((d) =>
d.devicemac.toUpperCase() == bluetoothMac.toUpperCase()).toList();
if (matched.isNotEmpty) return matched.first.license;
}
// 按设备名兜底匹配
final byName = devices.where((d) =>
d.devicename.toUpperCase().contains('A80')).toList();
return byName.isNotEmpty ? byName.first.license : null;
}
// ── 连接 ──────────────────────────────────────────────────────────────────
Future<void> _connect() async {
if (_channel != null) return;
status.value = MobileElfStatus.connecting;
errorMsg.value = '';
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 {
_channel = IOWebSocketChannel.connect(Uri.parse(url));
}
_wsSubscription = _channel!.stream.listen(
_handleMessage,
onError: _handleWsError,
onDone: _handleWsDone,
);
Logger.info('[MobileElf] WebSocket 连接中: $url');
} catch (e) {
Logger.error('[MobileElf] 连接失败: $e');
_setError('连接失败: $e');
}
}
// ── 开始对话(录音) ─────────────────────────────────────────────────────
Future<void> startRecording() async {
if (status.value == MobileElfStatus.recording) return;
if (status.value == MobileElfStatus.thinking) return;
await _connect();
final hasPermission = await _recorder.hasPermission();
if (!hasPermission) {
_setError('无麦克风权限');
return;
}
_dialogRequestId = _uuid.v4();
realtimeText.value = '';
try {
final stream = await _recorder.startStream(
const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 16000,
numChannels: 1,
),
);
_audioBuffer.clear();
_audioSubscription = stream.listen((chunk) {
_audioBuffer.addAll(chunk);
while (_audioBuffer.length >= _frameSize) {
_sendAudioChunk(
Uint8List.fromList(_audioBuffer.sublist(0, _frameSize)));
_audioBuffer.removeRange(0, _frameSize);
}
});
status.value = MobileElfStatus.recording;
Logger.info('[MobileElf] 开始录音');
} catch (e) {
Logger.error('[MobileElf] 录音失败: $e');
_setError('录音失败: $e');
}
}
Future<void> stopRecording() async {
if (status.value != MobileElfStatus.recording) return;
await _audioSubscription?.cancel();
_audioSubscription = null;
// 发送剩余缓冲数据
if (_audioBuffer.isNotEmpty) {
_sendAudioChunk(Uint8List.fromList(_audioBuffer));
_audioBuffer.clear();
}
await _recorder.stop();
_sendFinish();
status.value = MobileElfStatus.thinking;
Logger.info('[MobileElf] 停止录音,等待 AI 回答');
}
// ── 发送帧 ────────────────────────────────────────────────────────────────
void _sendStartFrame() {
if (_channel == null) return;
final startFrame = {
'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': [],
},
};
_channel!.sink.add(jsonEncode(startFrame));
Logger.info('[MobileElf] 已发送 start 帧');
}
void _sendAudioChunk(Uint8List data) {
try {
_channel?.sink.add(data);
} catch (e) {
Logger.error('[MobileElf] 发送音频帧失败: $e');
}
}
void _sendFinish() {
try {
_channel?.sink.add(jsonEncode({'type': 'finish'}));
Logger.info('[MobileElf] 已发送 finish 帧');
} catch (e) {
Logger.error('[MobileElf] 发送 finish 帧失败: $e');
}
}
// ── 接收消息处理 ──────────────────────────────────────────────────────────
void _handleMessage(dynamic raw) {
try {
if (raw is! String) return;
final Map<String, dynamic> msg = jsonDecode(raw);
final type = msg['type'] as String? ?? '';
switch (type) {
case 'ready':
Logger.info('[MobileElf] 收到 ready,发送 start 帧');
_sendStartFrame();
break;
case 'mid_result':
final result = msg['result'] as String? ?? '';
realtimeText.value = result;
break;
case 'fin_result':
final result = msg['result'] as String? ?? '';
if (result.isNotEmpty) {
realtimeText.value = result;
// 用户消息在 fin_result 时确认写入
_addUserMessage(result);
}
final end = msg['end'] as int? ?? 0;
if (end == 1) _handleSessionEnd();
break;
case 'inside_rc':
_handleInsideRc(msg);
break;
case 'dcs_decide':
final end = msg['end'] as int? ?? 0;
if (end == 1) _handleSessionEnd();
break;
default:
Logger.info('[MobileElf] 未知消息类型: $type');
}
} catch (e) {
Logger.error('[MobileElf] 处理消息失败: $e');
}
}
void _handleInsideRc(Map<String, dynamic> msg) {
final data = msg['data'] as Map<String, dynamic>?;
if (data == null) return;
final code = data['code'] as int? ?? -1;
if (code != 0) {
Logger.error('[MobileElf] inside_rc 错误: ${data['msg']}');
return;
}
// 提取 AI 文本回答
final assistantAnswer = data['assistant_answer'] as String? ?? '';
String cleanAnswer = assistantAnswer;
// assistant_answer 可能是 JSON 字符串
try {
final decoded = jsonDecode(assistantAnswer) as Map<String, dynamic>;
cleanAnswer = decoded['content'] as String? ?? assistantAnswer;
} catch (_) {}
// 提取 TTS URL(Speak 指令)
String? ttsUrl;
final directives = data['data'] as List<dynamic>? ?? [];
for (final item in directives) {
final directive = item as Map<String, dynamic>;
final header = directive['header'] as Map<String, dynamic>?;
final ns = header?['namespace'] as String? ?? '';
final name = header?['name'] as String? ?? '';
if (ns == 'ai.fxzsos.device_interface.voice_output' && name == 'Speak') {
final payload = directive['payload'] as Map<String, dynamic>?;
ttsUrl = payload?['url'] as String?;
break;
}
}
if (cleanAnswer.isNotEmpty) {
_addAiMessage(cleanAnswer, ttsUrl: ttsUrl);
}
// 播放 TTS
if (ttsUrl != null && ttsUrl.isNotEmpty) {
playTts(ttsUrl);
}
final end = msg['end'] as int? ?? 0;
if (end == 1) _handleSessionEnd();
}
void _handleSessionEnd() {
if (status.value != MobileElfStatus.speaking) {
status.value = MobileElfStatus.idle;
}
realtimeText.value = '';
// 断开当前 WebSocket,下次对话重新连接(协议要求每次新对话重连)
_closeWs();
}
void _handleWsError(dynamic error) {
Logger.error('[MobileElf] WebSocket 错误: $error');
_setError('连接错误: $error');
_closeWs();
}
void _handleWsDone() {
Logger.info('[MobileElf] WebSocket 关闭');
if (status.value == MobileElfStatus.connecting ||
status.value == MobileElfStatus.recording) {
_setError('连接已断开');
}
_channel = null;
_wsSubscription = null;
}
// ── TTS 播放 ──────────────────────────────────────────────────────────────
Future<void> playTts(String url) async {
try {
status.value = MobileElfStatus.speaking;
await _player.setUrl(url);
await _player.play();
await _player.processingStateStream
.firstWhere((s) => s == ProcessingState.completed);
status.value = MobileElfStatus.idle;
Logger.info('[MobileElf] TTS 播放完成');
} catch (e) {
Logger.error('[MobileElf] TTS 播放失败: $e');
status.value = MobileElfStatus.idle;
}
}
Future<void> stopSpeaking() async {
await _player.stop();
status.value = MobileElfStatus.idle;
}
// ── 断开连接 ──────────────────────────────────────────────────────────────
void disconnect() {
_audioSubscription?.cancel();
_audioSubscription = null;
_recorder.cancel();
_closeWs();
status.value = MobileElfStatus.idle;
}
void _closeWs() {
_wsSubscription?.cancel();
_wsSubscription = null;
_channel?.sink.close();
_channel = null;
}
// ── 辅助方法 ──────────────────────────────────────────────────────────────
String _calcSign(String deviceSecret, String ts) {
final bytes = utf8.encode('$deviceSecret$ts');
return md5.convert(bytes).toString();
}
List<Map<String, String>> _buildContextMessages() {
return 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(),
));
}
void _addAiMessage(String text, {String? ttsUrl}) {
messages.add(MobileElfMessage(
isUser: false,
text: text,
time: DateTime.now(),
ttsUrl: ttsUrl,
));
}
void _setError(String msg) {
errorMsg.value = msg;
status.value = MobileElfStatus.error;
}
/// 重试获取 A80 凭证
Future<void> retryCredentials() async {
credentialError.value = '';
await _fetchA80CredentialsIfNeeded();
}
/// 按钮主操作:空闲/错误 → 开始录音;录音中 → 停止;播放中 → 停止
Future<void> onMicTap() async {
switch (status.value) {
case MobileElfStatus.idle:
case MobileElfStatus.error:
await startRecording();
break;
case MobileElfStatus.recording:
await stopRecording();
break;
case MobileElfStatus.speaking:
await stopSpeaking();
break;
default:
break;
}
}
}