17 changed files with 1094 additions and 298 deletions
@ -0,0 +1,10 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/call_translation_debug_controller.dart'; |
||||
|
|
||||
|
class CallTranslationDebugBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<CallTranslationDebugController>( |
||||
|
() => CallTranslationDebugController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,148 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
|
||||
|
import '../../../data/services/ble_manager.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 通话翻译调试控制器 |
||||
|
/// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发, |
||||
|
/// 以及通话翻译编解码的启动/停止。 |
||||
|
class CallTranslationDebugController extends GetxController { |
||||
|
final BleManager _bleManager = Get.find<BleManager>(); |
||||
|
final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
// 持久化键 |
||||
|
static const String _kSendInterval = 'call_trans_debug_send_interval'; |
||||
|
static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames'; |
||||
|
|
||||
|
// 默认值(与原生默认保持一致) |
||||
|
static const int defaultSendIntervalMs = 100; |
||||
|
static const int defaultBundleFrameCount = 4; |
||||
|
|
||||
|
// 取值范围 |
||||
|
static const int minSendIntervalMs = 20; |
||||
|
static const int maxSendIntervalMs = 1000; |
||||
|
static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms |
||||
|
static const int minBundleFrameCount = 1; |
||||
|
static const int maxBundleFrameCount = 20; |
||||
|
|
||||
|
/// 音频下行发送间隔(ms) |
||||
|
final RxInt sendIntervalMs = defaultSendIntervalMs.obs; |
||||
|
|
||||
|
/// 下行合包帧数 |
||||
|
final RxInt bundleFrameCount = defaultBundleFrameCount.obs; |
||||
|
|
||||
|
/// 通话翻译编解码是否已开启 |
||||
|
RxBool get isCodecActive => _bleManager.isCodecActiveRx; |
||||
|
|
||||
|
/// 蓝牙是否已连接 |
||||
|
RxBool get isConnected => _bleManager.isConnectedRx; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_loadFromStorage(); |
||||
|
// 进入界面时尝试用原生当前值同步(若原生已被修改) |
||||
|
_syncFromNative(); |
||||
|
} |
||||
|
|
||||
|
/// 从本地存储加载参数 |
||||
|
void _loadFromStorage() { |
||||
|
final savedInterval = _storage.read<int>(_kSendInterval); |
||||
|
final savedFrames = _storage.read<int>(_kBundleFrameCount); |
||||
|
if (savedInterval != null) { |
||||
|
sendIntervalMs.value = |
||||
|
savedInterval.clamp(minSendIntervalMs, maxSendIntervalMs); |
||||
|
} |
||||
|
if (savedFrames != null) { |
||||
|
bundleFrameCount.value = |
||||
|
savedFrames.clamp(minBundleFrameCount, maxBundleFrameCount); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 用原生当前实际值同步显示(不覆盖用户本地保存的偏好,仅在无本地保存时参考) |
||||
|
Future<void> _syncFromNative() async { |
||||
|
try { |
||||
|
final params = await _bleManager.getCallTranslationDebugParams(); |
||||
|
if (params.isEmpty) return; |
||||
|
// 仅当本地没有保存过时,采用原生返回值 |
||||
|
if (_storage.read<int>(_kSendInterval) == null) { |
||||
|
final ni = params['sendIntervalMs']; |
||||
|
if (ni != null && ni > 0) { |
||||
|
sendIntervalMs.value = ni.clamp(minSendIntervalMs, maxSendIntervalMs); |
||||
|
} |
||||
|
} |
||||
|
if (_storage.read<int>(_kBundleFrameCount) == null) { |
||||
|
final nf = params['bundleFrameCount']; |
||||
|
if (nf != null && nf > 0) { |
||||
|
bundleFrameCount.value = |
||||
|
nf.clamp(minBundleFrameCount, maxBundleFrameCount); |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void setSendInterval(int value) { |
||||
|
// 按 20ms 步长对齐 |
||||
|
final snapped = |
||||
|
(value / sendIntervalStepMs).round() * sendIntervalStepMs; |
||||
|
sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs); |
||||
|
} |
||||
|
|
||||
|
void setBundleFrameCount(int value) { |
||||
|
bundleFrameCount.value = |
||||
|
value.clamp(minBundleFrameCount, maxBundleFrameCount); |
||||
|
} |
||||
|
|
||||
|
/// 恢复默认值 |
||||
|
void resetDefaults() { |
||||
|
sendIntervalMs.value = defaultSendIntervalMs; |
||||
|
bundleFrameCount.value = defaultBundleFrameCount; |
||||
|
} |
||||
|
|
||||
|
/// 保存并下发参数到原生(运行时立即生效) |
||||
|
Future<void> applyParams() async { |
||||
|
_storage.write(_kSendInterval, sendIntervalMs.value); |
||||
|
_storage.write(_kBundleFrameCount, bundleFrameCount.value); |
||||
|
final ok = await _bleManager.setCallTranslationDebugParams( |
||||
|
sendIntervalMs: sendIntervalMs.value, |
||||
|
bundleFrameCount: bundleFrameCount.value, |
||||
|
); |
||||
|
Logger.i('CALL_TRANS_DEBUG', |
||||
|
'下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} result=$ok'); |
||||
|
Get.snackbar( |
||||
|
'通话翻译调试', |
||||
|
ok ? '参数已应用' : '参数下发失败(请确认设备已连接)', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: const Duration(seconds: 2), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 应用参数并启动通话翻译编解码 |
||||
|
Future<void> applyAndStart() async { |
||||
|
if (!isConnected.value) { |
||||
|
Get.snackbar('通话翻译调试', '设备未连接,无法启动', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: const Duration(seconds: 2)); |
||||
|
return; |
||||
|
} |
||||
|
// 先下发参数再启动,确保编解码以新参数运行 |
||||
|
await applyParams(); |
||||
|
final ok = await _bleManager.openA2DPDecoder(); |
||||
|
Logger.i('CALL_TRANS_DEBUG', '启动通话翻译编解码 result=$ok'); |
||||
|
Get.snackbar('通话翻译调试', ok ? '已启动通话翻译' : '启动失败', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: const Duration(seconds: 2)); |
||||
|
} |
||||
|
|
||||
|
/// 停止通话翻译编解码 |
||||
|
Future<void> stop() async { |
||||
|
final ok = await _bleManager.closeCodec(); |
||||
|
Logger.i('CALL_TRANS_DEBUG', '停止通话翻译编解码 result=$ok'); |
||||
|
Get.snackbar('通话翻译调试', ok ? '已停止通话翻译' : '停止失败', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: const Duration(seconds: 2)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,300 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
|
||||
|
import '../controllers/call_translation_debug_controller.dart'; |
||||
|
|
||||
|
/// 通话翻译调试界面 |
||||
|
/// 可调整音频下行发送间隔与下行合包帧数,应用后启动/停止通话翻译。 |
||||
|
class CallTranslationDebugView |
||||
|
extends GetView<CallTranslationDebugController> { |
||||
|
const CallTranslationDebugView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
return Scaffold( |
||||
|
backgroundColor: isDark ? Colors.black : Colors.grey[50], |
||||
|
appBar: AppBar( |
||||
|
backgroundColor: isDark ? Colors.black : Colors.white, |
||||
|
elevation: 0.5, |
||||
|
centerTitle: true, |
||||
|
title: Text( |
||||
|
'通话翻译调试', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDark ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
body: ListView( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), |
||||
|
children: [ |
||||
|
_buildStatusCard(isDark), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildSendIntervalCard(isDark), |
||||
|
SizedBox(height: 16.h), |
||||
|
_buildBundleFrameCard(isDark), |
||||
|
SizedBox(height: 24.h), |
||||
|
_buildActionButtons(isDark), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _card(bool isDark, {required Widget child}) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? const Color(0xFF1E1E1E) : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
border: isDark |
||||
|
? Border.all(color: const Color(0xFF2C2C2C), width: 1) |
||||
|
: null, |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: isDark |
||||
|
? Colors.black.withValues(alpha: 0.4) |
||||
|
: Colors.black.withValues(alpha: 0.06), |
||||
|
blurRadius: 12, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: child, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 连接 / 编解码状态 |
||||
|
Widget _buildStatusCard(bool isDark) { |
||||
|
return _card( |
||||
|
isDark, |
||||
|
child: Obx(() { |
||||
|
final connected = controller.isConnected.value; |
||||
|
final active = controller.isCodecActive.value; |
||||
|
return Row( |
||||
|
children: [ |
||||
|
_statusDot(connected ? Colors.green : Colors.red), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text( |
||||
|
connected ? '设备已连接' : '设备未连接', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDark ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
const Spacer(), |
||||
|
_statusDot(active ? Colors.green : Colors.grey), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text( |
||||
|
active ? '翻译运行中' : '翻译未启动', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDark ? Colors.white70 : Colors.black54, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _statusDot(Color color) { |
||||
|
return Container( |
||||
|
width: 10.w, |
||||
|
height: 10.w, |
||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 音频下行发送间隔 |
||||
|
Widget _buildSendIntervalCard(bool isDark) { |
||||
|
return _card( |
||||
|
isDark, |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
_title(isDark, '音频下行发送间隔', |
||||
|
'每包音频之间的发送间隔,值越小发送越快、占用带宽越高(${CallTranslationDebugController.minSendIntervalMs}~${CallTranslationDebugController.maxSendIntervalMs} ms,步长 ${CallTranslationDebugController.sendIntervalStepMs} ms)'), |
||||
|
SizedBox(height: 8.h), |
||||
|
Obx( |
||||
|
() => Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Slider( |
||||
|
value: controller.sendIntervalMs.value |
||||
|
.clamp( |
||||
|
CallTranslationDebugController.minSendIntervalMs, |
||||
|
CallTranslationDebugController.maxSendIntervalMs, |
||||
|
) |
||||
|
.toDouble(), |
||||
|
min: CallTranslationDebugController.minSendIntervalMs |
||||
|
.toDouble(), |
||||
|
max: CallTranslationDebugController.maxSendIntervalMs |
||||
|
.toDouble(), |
||||
|
divisions: (CallTranslationDebugController |
||||
|
.maxSendIntervalMs - |
||||
|
CallTranslationDebugController.minSendIntervalMs) ~/ |
||||
|
CallTranslationDebugController.sendIntervalStepMs, |
||||
|
label: '${controller.sendIntervalMs.value} ms', |
||||
|
onChanged: (v) => |
||||
|
controller.setSendInterval(v.round()), |
||||
|
), |
||||
|
), |
||||
|
_valueChip(isDark, '${controller.sendIntervalMs.value} ms'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 下行合包帧数 |
||||
|
Widget _buildBundleFrameCard(bool isDark) { |
||||
|
return _card( |
||||
|
isDark, |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
_title(isDark, '下行合包帧数', |
||||
|
'左右声道各自累积多少帧 opus 合并为一包后下发,值越大单包越大、发送频率越低(${CallTranslationDebugController.minBundleFrameCount}~${CallTranslationDebugController.maxBundleFrameCount} 帧)'), |
||||
|
SizedBox(height: 8.h), |
||||
|
Obx( |
||||
|
() => Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Slider( |
||||
|
value: controller.bundleFrameCount.value |
||||
|
.clamp( |
||||
|
CallTranslationDebugController.minBundleFrameCount, |
||||
|
CallTranslationDebugController.maxBundleFrameCount, |
||||
|
) |
||||
|
.toDouble(), |
||||
|
min: CallTranslationDebugController.minBundleFrameCount |
||||
|
.toDouble(), |
||||
|
max: CallTranslationDebugController.maxBundleFrameCount |
||||
|
.toDouble(), |
||||
|
divisions: |
||||
|
CallTranslationDebugController.maxBundleFrameCount - |
||||
|
CallTranslationDebugController.minBundleFrameCount, |
||||
|
label: '${controller.bundleFrameCount.value} 帧', |
||||
|
onChanged: (v) => |
||||
|
controller.setBundleFrameCount(v.round()), |
||||
|
), |
||||
|
), |
||||
|
_valueChip(isDark, '${controller.bundleFrameCount.value} 帧'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _title(bool isDark, String title, String subtitle) { |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDark ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
subtitle, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12.sp, |
||||
|
color: isDark ? Colors.white60 : Colors.black54, |
||||
|
height: 1.3, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _valueChip(bool isDark, String text) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark |
||||
|
? Colors.blue[900]!.withValues(alpha: 0.3) |
||||
|
: Colors.blue[50], |
||||
|
borderRadius: BorderRadius.circular(10.r), |
||||
|
), |
||||
|
child: Text( |
||||
|
text, |
||||
|
style: TextStyle( |
||||
|
fontSize: 13.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDark ? Colors.blue[200] : Colors.blue[700], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildActionButtons(bool isDark) { |
||||
|
return Column( |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: OutlinedButton( |
||||
|
onPressed: controller.resetDefaults, |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
padding: EdgeInsets.symmetric(vertical: 12.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
child: const Text('恢复默认'), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: ElevatedButton( |
||||
|
onPressed: controller.applyParams, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue[600], |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: EdgeInsets.symmetric(vertical: 12.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
child: const Text('保存并应用'), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Obx( |
||||
|
() { |
||||
|
final active = controller.isCodecActive.value; |
||||
|
return SizedBox( |
||||
|
width: double.infinity, |
||||
|
child: ElevatedButton.icon( |
||||
|
onPressed: active ? controller.stop : controller.applyAndStart, |
||||
|
icon: Icon(active ? Icons.stop : Icons.play_arrow), |
||||
|
label: Text(active ? '停止通话翻译' : '应用并启动通话翻译'), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: active ? Colors.red[600] : Colors.green[600], |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: EdgeInsets.symmetric(vertical: 14.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
Loading…
Reference in new issue