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
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;
|
|
}
|
|
}
|
|
}
|
|
|