From a31c73613f84ea5b3259fbb0ce0028186de93bc9 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 20 Apr 2026 14:25:10 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ephemeral/.plugin_symlinks/flutter_secure_storage_linux | 1 + windows/flutter/ephemeral/.plugin_symlinks/app_links | 1 + windows/flutter/ephemeral/.plugin_symlinks/connectivity_plus | 1 + windows/flutter/ephemeral/.plugin_symlinks/device_info_plus | 1 + windows/flutter/ephemeral/.plugin_symlinks/file_picker | 1 + windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows | 1 + windows/flutter/ephemeral/.plugin_symlinks/firebase_auth | 1 + windows/flutter/ephemeral/.plugin_symlinks/firebase_core | 1 + .../ephemeral/.plugin_symlinks/flutter_secure_storage_windows | 1 + windows/flutter/ephemeral/.plugin_symlinks/flutter_tts | 1 + windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows | 1 + windows/flutter/ephemeral/.plugin_symlinks/package_info_plus | 1 + windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows | 1 + .../ephemeral/.plugin_symlinks/permission_handler_windows | 1 + windows/flutter/ephemeral/.plugin_symlinks/record_windows | 1 + windows/flutter/ephemeral/.plugin_symlinks/share_plus | 1 + windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows | 1 + windows/flutter/ephemeral/.plugin_symlinks/window_size | 1 + 18 files changed, 18 insertions(+) create mode 120000 linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/app_links create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/connectivity_plus create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/device_info_plus create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/file_picker create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/firebase_auth create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/firebase_core create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/flutter_tts create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/package_info_plus create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/permission_handler_windows create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/record_windows create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/share_plus create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows create mode 120000 windows/flutter/ephemeral/.plugin_symlinks/window_size diff --git a/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux b/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux new file mode 120000 index 000000000..dc0ff6f7b --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/app_links b/windows/flutter/ephemeral/.plugin_symlinks/app_links new file mode 120000 index 000000000..011668bba --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/app_links @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/app_links-6.4.1/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/connectivity_plus b/windows/flutter/ephemeral/.plugin_symlinks/connectivity_plus new file mode 120000 index 000000000..5891bf73d --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/connectivity_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/device_info_plus b/windows/flutter/ephemeral/.plugin_symlinks/device_info_plus new file mode 120000 index 000000000..2faf5084e --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/device_info_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/file_picker b/windows/flutter/ephemeral/.plugin_symlinks/file_picker new file mode 120000 index 000000000..6bbda11a3 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/file_picker @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/file_picker-10.2.0/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows b/windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows new file mode 120000 index 000000000..684b72683 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+4/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/firebase_auth b/windows/flutter/ephemeral/.plugin_symlinks/firebase_auth new file mode 120000 index 000000000..1b91a78d7 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/firebase_auth @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/firebase_auth-6.1.2/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/firebase_core b/windows/flutter/ephemeral/.plugin_symlinks/firebase_core new file mode 120000 index 000000000..090496a03 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/firebase_core @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/firebase_core-4.2.1/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows b/windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows new file mode 120000 index 000000000..385477e5a --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/flutter_tts b/windows/flutter/ephemeral/.plugin_symlinks/flutter_tts new file mode 120000 index 000000000..032662a02 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/flutter_tts @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/flutter_tts-4.2.3/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows b/windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows new file mode 120000 index 000000000..ce6a89d7d --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/image_picker_windows-0.2.1+1/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/package_info_plus b/windows/flutter/ephemeral/.plugin_symlinks/package_info_plus new file mode 120000 index 000000000..9b8fda97e --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/package_info_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/package_info_plus-8.3.0/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows b/windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows new file mode 120000 index 000000000..7c82345cb --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/permission_handler_windows b/windows/flutter/ephemeral/.plugin_symlinks/permission_handler_windows new file mode 120000 index 000000000..0423c6387 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/permission_handler_windows @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/record_windows b/windows/flutter/ephemeral/.plugin_symlinks/record_windows new file mode 120000 index 000000000..24d55d455 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/record_windows @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/record_windows-1.0.7/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/share_plus b/windows/flutter/ephemeral/.plugin_symlinks/share_plus new file mode 120000 index 000000000..03c486e71 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/share_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/share_plus-12.0.1/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows b/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows new file mode 120000 index 000000000..75fda99a5 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.4/ \ No newline at end of file diff --git a/windows/flutter/ephemeral/.plugin_symlinks/window_size b/windows/flutter/ephemeral/.plugin_symlinks/window_size new file mode 120000 index 000000000..3e93d5a47 --- /dev/null +++ b/windows/flutter/ephemeral/.plugin_symlinks/window_size @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/git/flutter-desktop-embedding-6c66ad23ee79749f30a8eece542cf54eaf157ed8/plugins/window_size/ \ No newline at end of file From 2ce10a570d6678cf98d35d89e4355cdee2679532 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Wed, 22 Apr 2026 16:37:42 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E7=9C=BC=E7=9D=9B?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=9B=B4=E6=8E=A5=E8=BF=9B=E5=85=A5=E9=80=9A?= =?UTF-8?q?=E8=AF=9D=E7=BF=BB=E8=AF=91=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/saitong/voitrans/MyApplication.kt | 52 +++++++++++++++ lib/data/services/ble_manager.dart | 63 ++++++++++++++++++- .../controllers/translation_controller.dart | 48 ++++++++++++++ .../ble_service/BleCommandSender.kt | 26 ++++++++ .../yunqiinnovation/ble_service/BleConst.kt | 4 ++ .../yunqiinnovation/ble_service/BleService.kt | 6 +- .../Sources/ble_service/BleConst.swift | 4 ++ .../Sources/ble_service/BleService.swift | 40 ++++++++++-- 8 files changed, 234 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/MyApplication.kt b/android/app/src/main/kotlin/com/saitong/voitrans/MyApplication.kt index 17e5ee3b0..88cf5f539 100644 --- a/android/app/src/main/kotlin/com/saitong/voitrans/MyApplication.kt +++ b/android/app/src/main/kotlin/com/saitong/voitrans/MyApplication.kt @@ -8,6 +8,8 @@ import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.plugins.GeneratedPluginRegistrant +import java.io.OutputStream +import java.io.PrintStream // Application.kt class MyApp : FlutterApplication() { @@ -29,6 +31,9 @@ class MyApp : FlutterApplication() { super.onCreate() appStartTime = System.currentTimeMillis() Log.d(TAG, "MyApp onCreate - 进程启动") + + // 屏蔽高德定位 SDK(CgiManager) 的 NumberFormatException 噪音日志 + installAmapStderrFilter() val cache = FlutterEngineCache.getInstance() val existingEngine = cache.get(ENGINE_ID) @@ -105,6 +110,53 @@ class MyApp : FlutterApplication() { return true } + /** + * 过滤 System.err 中来自高德 SDK 的 NumberFormatException 栈(com.autonavi.aps.amapapi / CgiManager / ApsManager)。 + * 仅丢弃这类已知噪音,其他 stderr 输出原样打印。 + */ + private fun installAmapStderrFilter() { + val origin = System.err ?: return + val noiseKeywords = arrayOf( + "com.autonavi.aps.amapapi", + "CgiManager", + "ApsManager", + "onCellLocationChanged", + "PhoneStateListener", + ) + val bufferRef = StringBuilder() + var suppressing = false + + val filtered = PrintStream(object : OutputStream() { + override fun write(b: Int) { + val ch = b.toChar() + bufferRef.append(ch) + if (ch == '\n') { + val line = bufferRef.toString() + bufferRef.setLength(0) + val trimmed = line.trimStart() + if (trimmed.startsWith("java.lang.NumberFormatException")) { + suppressing = true + return + } + if (suppressing) { + // 只要是栈帧 (at xxx) 或 caused by,就继续丢弃 + if (trimmed.startsWith("at ") || + trimmed.startsWith("Caused by") || + trimmed.startsWith("... ") || + noiseKeywords.any { trimmed.contains(it) } + ) { + return + } + suppressing = false + } + origin.print(line) + } + } + }, true) + System.setErr(filtered) + Log.d(TAG, "已安装高德 SDK stderr 过滤器") + } + private fun isAppInForeground(): Boolean { val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val processes = am.runningAppProcesses ?: return false diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index f73e0cb2e..14d137ccf 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -15,6 +15,7 @@ import '../../core/utils/logger.dart'; import 'bluetooth_manager.dart'; import 'language_manager.dart'; import 'network/dio_manager.dart'; +import '../../routes/app_routes.dart'; // 重新导出BLE连接状态常量,让外部可以通过BleManager访问 export 'package:ble_service/ble_service.dart' show BleConnectionState; @@ -29,6 +30,10 @@ class BleCommands { static const int GET_WAKEUP_VERSION = 0x08; static const int GET_VOICE_WAKEUP_STATUS = 0x14; static const int SET_VOICE_WAKEUP_STATUS = 0x15; + /// 设备上报开启通话翻译 0x16 (帧头0xCC, cmdLen=0x01, status=0xFF) + static const int CALL_TRANSLATION_ON = 0x16; + /// 设备上报关闭通话翻译 0x17 (帧头0xCC, cmdLen=0x01, status=0xFF) + static const int CALL_TRANSLATION_OFF = 0x17; } /// 判断编解码控制指令是否成功 @@ -239,6 +244,14 @@ class BleManager extends GetxService { 0.obs; // 唤醒语音升级状态 0: 未升级 1: 升级中 2: 升级成功 3: 升级失败 RxInt get wakeupVoiceUpgradeStatus => _wakeupVoiceUpgradeStatus; + /// 设备上报通话翻译开关信号流 + /// true = 设备上报开启通话翻译 (0x16),false = 设备上报关闭通话翻译 (0x17) + /// 外部(如 TranslationController)可监听该流,收到信号后启动/停止通话翻译功能。 + final StreamController _callTranslationRequestController = + StreamController.broadcast(); + Stream get onCallTranslationRequest => + _callTranslationRequestController.stream; + /// 初始化 @override void onInit() { @@ -254,6 +267,7 @@ class BleManager extends GetxService { void onClose() { _statusSubscription?.cancel(); _dataSubscription?.cancel(); + _callTranslationRequestController.close(); super.onClose(); } @@ -392,11 +406,51 @@ class BleManager extends GetxService { case BleCommands.SET_VOICE_WAKEUP_STATUS: _updateVoiceWakeupSetResultFromData(data); break; + case BleCommands.CALL_TRANSLATION_ON: + _handleCallTranslationRequest(true, data); + break; + case BleCommands.CALL_TRANSLATION_OFF: + _handleCallTranslationRequest(false, data); + break; default: Logger.w('BleManager', '未处理的命令ID: $commandId'); } } + /// 处理设备上报的通话翻译开关指令 (0x16 / 0x17) + /// - [enable] true=开启通话翻译, false=关闭 + /// 行为: + /// 1. 打印 [CALL_TRANS_CMD] 日志; + /// 2. 向 [onCallTranslationRequest] 流广播信号(TranslationController 监听并启停); + /// 3. 未在翻译页时,自动导航到 Routes.translation 并以 call 模式 + autoStart 进入。 + void _handleCallTranslationRequest( + bool enable, Map data) { + final status = data['status']; + Logger.i('BleManager', + '[CALL_TRANS_CMD] 收到设备上报${enable ? "开启" : "关闭"}通话翻译 cmd=0x${(enable ? BleCommands.CALL_TRANSLATION_ON : BleCommands.CALL_TRANSLATION_OFF).toRadixString(16)} status=$status'); + + // 广播给监听者(如 TranslationController 已在 call 模式可直接启/停) + if (!_callTranslationRequestController.isClosed) { + _callTranslationRequestController.add(enable); + } + + // 开启:若当前不在翻译页,则导航并请求自动开始 + if (enable) { + final currentRoute = Get.currentRoute; + if (currentRoute != Routes.translation) { + Logger.i('BleManager', + '[CALL_TRANS_CMD] 当前路由=$currentRoute,导航到翻译页(call 模式, autoStart=true)'); + Get.toNamed( + Routes.translation, + arguments: {'translationId': 'call', 'autoStart': true}, + ); + } else { + Logger.i('BleManager', + '[CALL_TRANS_CMD] 已在翻译页,由 TranslationController 监听信号自行启动'); + } + } + } + /// 根据命令名称处理设备信息 void _processDeviceInfoByCommandName( String commandName, Map data) { @@ -1125,9 +1179,14 @@ class BleManager extends GetxService { _awaitingCodecOpen = true; _awaitingCodecClose = false; _isCodecActive.value = false; - return await _bleService.openA2DPDecoder(); + Logger.i('CALL_TRANS_CMD', + '准备发送通话翻译指令 cmd=0x05 sub=0xA2(A2DP_PLAY) → openA2DPDecoder'); + final ok = await _bleService.openA2DPDecoder(); + Logger.i('CALL_TRANS_CMD', + '通话翻译指令下发结果: ${ok ? "成功" : "失败"} (等待设备回包确认)'); + return ok; } catch (e) { - Logger.error('打开解码器失败: ${e.toString()}'); + Logger.e('CALL_TRANS_CMD', '通话翻译指令异常: ${e.toString()}'); _awaitingCodecOpen = false; return false; } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 0962a74f8..756b932f9 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -151,6 +151,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { StreamSubscription? _recognitionSubscription; StreamSubscription? _astEventSubscription; StreamSubscription? _voiceInteractionSubscription; + StreamSubscription? _deviceCallTranslationSubscription; Timer? _translationDebounceTimer; // ==================== 重新初始化守卫 ==================== @@ -570,10 +571,55 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 应用模式逻辑(含ASR/TTS配置等) changeTranslationMode(initMode); + // 监听设备上报的通话翻译开关 (0x16 / 0x17) + _setupDeviceCallTranslationListener(initMode); + // 监听应用生命周期:当应用切到后台/失焦时兜底重置 activeSpeaker WidgetsBinding.instance.addObserver(this); } + /// 监听 BleManager 下发的设备通话翻译开关信号 (0x16 / 0x17) + /// - 收到 true: 若当前非 call 模式则切换到 call 模式,并自动启动识别; + /// - 收到 false: 停止当前识别(stopAll)。 + /// 首次进入时若 autoStart=true,也立即触发一次开启。 + void _setupDeviceCallTranslationListener(String initMode) { + _deviceCallTranslationSubscription?.cancel(); + _deviceCallTranslationSubscription = + bleManager.onCallTranslationRequest.listen((enable) async { + Logger.info( + '[CALL_TRANS_CMD] TranslationController 收到信号: ${enable ? "开启" : "关闭"}'); + try { + if (enable) { + if (currentMode.value != 'call') { + await changeTranslationMode('call'); + } + if (!isRecording.value) { + await startRecording(); + } + } else { + await stopAll(); + } + } catch (e, s) { + Logger.e('TRANSL', '[CALL_TRANS_CMD] 处理设备通话翻译信号失败: $e', e, s); + } + }); + + // 支持从 BleManager 导航进入时携带 autoStart 参数自动开启 + final autoStart = Get.arguments?['autoStart'] == true; + if (autoStart && initMode == 'call') { + Logger.info('[CALL_TRANS_CMD] 检测到 autoStart=true, 进入通话翻译并自启动'); + Future.microtask(() async { + try { + if (!isRecording.value) { + await startRecording(); + } + } catch (e, s) { + Logger.e('TRANSL', '[CALL_TRANS_CMD] autoStart 启动失败: $e', e, s); + } + }); + } + } + // @override // void onReady() { // super.onReady(); @@ -903,6 +949,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _astEventSubscription?.cancel(); _astEventSubscription = null; _voiceInteractionSubscription?.cancel(); + _deviceCallTranslationSubscription?.cancel(); + _deviceCallTranslationSubscription = null; _translationDebounceTimer?.cancel(); restoreOtherServices(); // 移除对服务的 dispose,避免频繁销毁/重建造成卡顿 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt index 8ad4d2eb9..8c27ca82f 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt @@ -592,6 +592,32 @@ class BleCommandSender { // 接收到结束 AI 单次对话信号,关闭编码器 (设备侧) callback?.notifyEndSingleDialogReceived() } + BleConst.CMD_CALL_TRANSLATION_ON -> { + // 设备上报: 开启通话翻译 0x16 (cmdLen=0x01, status=0xFF) + val status: Int = if (notifyData.size >= 4) (notifyData[3].toInt() and 0xFF) else -1 + Log.i(TAG, "[CALL_TRANS_CMD] 收到设备上报开启通话翻译 cmd=0x16 status=0x${"%02X".format(status)}") + val resultMap = mapOf( + "success" to true, + "command" to infoType, + "callTranslation" to true, + "status" to status, + "isNotification" to true + ) + callback?.notifyDeviceInfoReceived(infoType, resultMap) + } + BleConst.CMD_CALL_TRANSLATION_OFF -> { + // 设备上报: 关闭通话翻译 0x17 (cmdLen=0x01, status=0xFF) + val status: Int = if (notifyData.size >= 4) (notifyData[3].toInt() and 0xFF) else -1 + Log.i(TAG, "[CALL_TRANS_CMD] 收到设备上报关闭通话翻译 cmd=0x17 status=0x${"%02X".format(status)}") + val resultMap = mapOf( + "success" to true, + "command" to infoType, + "callTranslation" to false, + "status" to status, + "isNotification" to true + ) + callback?.notifyDeviceInfoReceived(infoType, resultMap) + } // 可以在这里添加其他主动上报类型的处理 else -> { // 对于未知上报类型,返回原始数据 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index 6f35798a4..fba780cfe 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -101,6 +101,10 @@ object BleConst { const val CMD_GET_VOICE_WAKEUP_STATUS = 0x14 /** 设置语音唤醒状态 */ const val CMD_SET_VOICE_WAKEUP_STATUS = 0x15 + /** 设备上报开启通话翻译 0x16 (帧头0xCC, cmdLen=0x01, status=0xFF) */ + const val CMD_CALL_TRANSLATION_ON = 0x16 + /** 设备上报关闭通话翻译 0x17 (帧头0xCC, cmdLen=0x01, status=0xFF) */ + const val CMD_CALL_TRANSLATION_OFF = 0x17 // ================== 语音唤醒状态值 ================== /** 语音唤醒关闭 */ diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 777981938..0e19216c8 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -1570,7 +1570,7 @@ object BleService { * @return 是否成功发送命令 */ fun openA2DPDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { - Log.i(TAG, "打开A2DP解码器...") + Log.i(TAG, "[CALL_TRANS_CMD] 打开A2DP解码器(通话翻译) channelMode=$channelMode") startOpusEncodeStream() recordfile1?.creatingFiles(); startOpusStreamDecoding(false, 2, 16000, 80) @@ -1578,7 +1578,9 @@ object BleService { BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(), channelMode.toByte() ) - return commandSender.sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), cmdData) + val ok = commandSender.sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), cmdData) + Log.i(TAG, "[CALL_TRANS_CMD] 通话翻译指令下发 cmd=0x05 sub=0xA2 result=$ok bytes=${cmdData.joinToString(",") { "0x%02X".format(it) }}") + return ok } /** diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift index e4fdc1f2d..4c4344422 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift @@ -96,6 +96,10 @@ class BleConst { static let CMD_GET_VOICE_WAKEUP_STATUS: UInt8 = 0x14 /** 设置语音唤醒状态 */ static let CMD_SET_VOICE_WAKEUP_STATUS: UInt8 = 0x15 + /** 设备上报开启通话翻译 0x16 (帧头0xCC, cmdLen=0x01, status=0xFF) */ + static let CMD_CALL_TRANSLATION_ON: UInt8 = 0x16 + /** 设备上报关闭通话翻译 0x17 (帧头0xCC, cmdLen=0x01, status=0xFF) */ + static let CMD_CALL_TRANSLATION_OFF: UInt8 = 0x17 // ================== 语音唤醒状态值 ================== /** 语音唤醒关闭 */ diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift index a519a03c5..b3a66b38c 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift @@ -746,14 +746,18 @@ public class BleService: NSObject { /// 打开A2DP解码器并开始录制 func openA2DPDecoder() -> Bool { // 确保Opus解码器处于活跃状态(如果不是则启动) - + startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280) opusProcessor?.startEncoding(sampleRate: 16000, channels: 1, frameRate: 20) - - - os_log("打开A2DP解码并开始录制...", log: logger, type: .info) + + + os_log("[CALL_TRANS_CMD] 打开A2DP解码并开始录制(通话翻译)...", log: logger, type: .info) let paramData = Data([BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_STEREO]) - return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) + let ok = sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) + os_log("[CALL_TRANS_CMD] 通话翻译指令下发 cmd=0x05 sub=0xA2 result=%{public}@ bytes=[0x%02X,0x%02X]", + log: logger, type: .info, + ok ? "成功" : "失败", BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_STEREO) + return ok } /// 打开通话记录解码器并开始录制 func openCallRecordDecoder() -> Bool { @@ -1384,6 +1388,32 @@ public class BleService: NSObject { case BleConst.CMD_END_SINGLE_DIALOG: os_log("📢 收到结束 AI 单次对话指令", log: logger, type: .info) notifyEndSingleDialogReceived() + case BleConst.CMD_CALL_TRANSLATION_ON: + // 设备上报: 开启通话翻译 0x16 (cmdLen=0x01, status=0xFF) + let status: Int = notifyData.count >= 4 ? Int(notifyData[3]) : -1 + os_log("[CALL_TRANS_CMD] 收到设备上报开启通话翻译 cmd=0x16 status=0x%02X", + log: logger, type: .info, status) + let resultMap: [String: Any] = [ + "success": true, + "command": Int(infoType), + "callTranslation": true, + "status": status, + "isNotification": true + ] + notifyDeviceInfoReceived(infoType: Int(infoType), infoData: resultMap) + case BleConst.CMD_CALL_TRANSLATION_OFF: + // 设备上报: 关闭通话翻译 0x17 (cmdLen=0x01, status=0xFF) + let status: Int = notifyData.count >= 4 ? Int(notifyData[3]) : -1 + os_log("[CALL_TRANS_CMD] 收到设备上报关闭通话翻译 cmd=0x17 status=0x%02X", + log: logger, type: .info, status) + let resultMap: [String: Any] = [ + "success": true, + "command": Int(infoType), + "callTranslation": false, + "status": status, + "isNotification": true + ] + notifyDeviceInfoReceived(infoType: Int(infoType), infoData: resultMap) default: // 对于未知上报类型,返回原始数据 let resultMap: [String: Any] = [ From a11df3bcfb7c3189e7e3e9fd81cfbcf38ef3233d Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Thu, 23 Apr 2026 09:27:32 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E6=92=AD=E6=8A=A5=E5=AF=B9=E6=96=B9=E7=BF=BB=E8=AF=91=E5=8E=9F?= =?UTF-8?q?=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/models/appconfig_model.dart | 2 + lib/data/models/appconfig_model.g.dart | 2 + lib/data/services/ast_service.dart | 6 + .../speech_impl/azure_ast_service.dart | 14 ++ .../controllers/translation_controller.dart | 30 ++- .../azure_speech/AzureSpeechPlugin.kt | 66 ++++-- .../azure_speech/CallBPcmPlayer.kt | 96 +++++++++ .../azure_speech/AliyunBailianE2EHelper.swift | 6 +- .../azure_speech/AzureSpeechPlugin.swift | 196 +++++++++++++++--- .../DoubaoE2ETranslateHelper.swift | 13 +- 10 files changed, 371 insertions(+), 60 deletions(-) create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallBPcmPlayer.kt diff --git a/lib/data/models/appconfig_model.dart b/lib/data/models/appconfig_model.dart index 9487db683..88c0a9661 100644 --- a/lib/data/models/appconfig_model.dart +++ b/lib/data/models/appconfig_model.dart @@ -84,6 +84,7 @@ class DBProduct { bool? isforceupdate; final bool? iscontinuouschat; final bool? wakeupenable; //该产品是否拥有唤醒开关控制 + final bool? broadcastpeertranslate; //通话翻译是否本地播报对方翻译内容 final String? updatedescription; final String? updatepackageaddress; @@ -98,6 +99,7 @@ class DBProduct { required this.isforceupdate, //该产品固件版本是否强制更新 required this.iscontinuouschat, required this.wakeupenable, + required this.broadcastpeertranslate, required this.updatedescription, required this.updatepackageaddress, }); diff --git a/lib/data/models/appconfig_model.g.dart b/lib/data/models/appconfig_model.g.dart index d369dc7a5..4227611e4 100644 --- a/lib/data/models/appconfig_model.g.dart +++ b/lib/data/models/appconfig_model.g.dart @@ -84,6 +84,7 @@ DBProduct _$DBProductFromJson(Map json) => DBProduct( isforceupdate: json['isforceupdate'] as bool?, iscontinuouschat: json['iscontinuouschat'] as bool?, wakeupenable: json['wakeupenable'] as bool?, + broadcastpeertranslate: json['broadcastpeertranslate'] as bool?, updatedescription: json['updatedescription'] as String?, updatepackageaddress: json['updatepackageaddress'] as String?, ); @@ -99,6 +100,7 @@ Map _$DBProductToJson(DBProduct instance) => { 'isforceupdate': instance.isforceupdate, 'iscontinuouschat': instance.iscontinuouschat, 'wakeupenable': instance.wakeupenable, + 'broadcastpeertranslate': instance.broadcastpeertranslate, 'updatedescription': instance.updatedescription, 'updatepackageaddress': instance.updatepackageaddress, }; diff --git a/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart index 8692e2b45..992ab3545 100644 --- a/lib/data/services/ast_service.dart +++ b/lib/data/services/ast_service.dart @@ -30,6 +30,12 @@ abstract class AstService { /// 开始录音 Future path(String filePath); + /// 通话翻译:是否本地播报对方(B)翻译后的音频。 + /// true → 原生层把 B 路 E2E PCM 直接播出,同时启用播放期 gate 防回环 + /// false → 原生层丢弃 B 路播放,gate 永不触发 + /// 未实现的原生端应 no-op 并返回 true + Future setBroadcastPeerTranslate(bool enabled); + /// 释放资源 Future dispose(); } diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index cbb3364b7..e75215a59 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -410,6 +410,20 @@ class AzureAstService extends GetxService implements AstService { } } + @override + Future setBroadcastPeerTranslate(bool enabled) async { + try { + await _channel.invokeMethod('setBroadcastPeerTranslate', { + 'enabled': enabled, + }); + Logger.info('[STS] setBroadcastPeerTranslate=$enabled'); + } on MissingPluginException { + // 原生未实现:忽略(默认原生层会按自己的默认行为处理) + } catch (e) { + Logger.error('setBroadcastPeerTranslate 失败: $e'); + } + } + @override void setLowVolumeThreshold(int threshold) { try { diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 756b932f9..3ddaf957c 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -30,6 +30,8 @@ import 'record_timer_controller.dart'; import 'translation_history_controller.dart'; import '../models/translation_models.dart'; import '../views/dialogs/translation_mode_selector.dart'; +import '../../../data/models/appconfig.dart'; +import '../../../data/models/appconfig_model.dart'; import '../../../data/models/user_Info.dart'; import '../../common/widgets/recharge_dialog.dart'; @@ -1183,6 +1185,22 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } _audioSourceType = true; isTtsEnabled.value = false; + + // 根据设备产品配置决定是否由原生层本地播报对方(B)翻译音频 + // 只有 product.broadcastpeertranslate == true 才播报;null / false / 读取异常 → 不播报 + bool broadcastPeer = false; + try { + final deviceName = bleManager.getDeviceName(); + if (deviceName.isNotEmpty) { + final DBProduct? product = AppConfig.getproduct(deviceName); + broadcastPeer = product?.broadcastpeertranslate == true; + } + } catch (e) { + Logger.error('读取 broadcastpeertranslate 失败,回退默认 false: $e'); + } + await _astService.setBroadcastPeerTranslate(broadcastPeer); + Logger.info('[CALL] broadcastPeerTranslate=$broadcastPeer'); + Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); } @@ -1294,8 +1312,14 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 处理 AST(语音识别+翻译一体)事件 /// 按 serviceId 区分双路(A=己方, B=对方),用 utteranceId 匹配同一句话的事件 void _handleAstEvent(ASTEvent event) { - Logger.d('Translation', - '[STS] _handleAstEvent: type=${event.type}, serviceId=${event.serviceId}'); + // 仅对终态事件打 info 日志,避免中间事件高频写 I/O 阻塞 event loop(会导致 AudioSendSlow 1011) + if (event.type == ASTEventType.finalResult || + event.type == ASTEventType.translationResult || + event.type == ASTEventType.error || + event.type == ASTEventType.canceled) { + Logger.info( + '[STS] _handleAstEvent 收到: type=${event.type}, serviceId=${event.serviceId}, text=${event.text}'); + } // 根据 serviceId 确定语言方向 final String srcCode = event.serviceId == "A" ? sourceLanguageCode.value @@ -1307,7 +1331,6 @@ class TranslationController extends GetxController with WidgetsBindingObserver { switch (event.type) { case ASTEventType.intermediateResult: if (event.text.isEmpty) break; - Logger.d('Translation', 'AST 识别中 [${event.serviceId}]: ${event.text}'); currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); // 按 utteranceId 查找已有项 @@ -1400,7 +1423,6 @@ class TranslationController extends GetxController with WidgetsBindingObserver { case ASTEventType.translationInterim: if (event.text.isEmpty) break; - Logger.d('Translation', 'AST 翻译中 [${event.serviceId}]: ${event.text}'); currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); TranslationItem? target; diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 90f2aaa97..a03772888 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -70,6 +70,14 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, private lateinit var astEventChannel: EventChannel private var astEventSink: EventChannel.EventSink? = null + // 通话翻译:对方(B)路 E2E 翻译音频(16kHz PCM16 mono)本地播放器 + private val callBPcmPlayer = CallBPcmPlayer() + // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 + @Volatile private var broadcastPeerTranslate: Boolean = false + private val bPcmWriter: AudioWriter = AudioWriter { data -> + if (broadcastPeerTranslate) callBPcmPlayer.feed(data) + } + // 翻译(Volcano)相关 private lateinit var translationChannel: MethodChannel private lateinit var translationEventChannel: EventChannel @@ -141,19 +149,27 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, private val gatePeakThreshold = 500 private val silenceFramesToMute = 50 + // B 路本地播放时,A/B 两路都改推静音帧,避免 TTS 回放被麦克风回采形成闭环 + // 若开关关闭,gate 永不触发(也不会有 isPlaying,因为 feed 已被 bPcmWriter 拦截) + private fun gateAgainstBPlayback(data: ByteArray): ByteArray { + if (!broadcastPeerTranslate) return data + return if (callBPcmPlayer.isPlaying()) ByteArray(data.size) else data + } + private fun pushAstAudioToA(data: ByteArray) { astAudioPushCountA++ if (astAudioPushCountA % 100 == 1L) { FileLogger.d(tag, "pushAstAudioToA: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountA") } + val payload = gateAgainstBPlayback(data) if (currentAstProvider == "iflytek") { - iflytekAstHelperA?.pushAudioData(data) + iflytekAstHelperA?.pushAudioData(payload) } else if (currentAstProvider == "azure") { - azureAstHelperA?.pushAudioData(data) + azureAstHelperA?.pushAudioData(payload) } else if (currentAstProvider == "volcano") { - doubaoAstHelperA?.pushAudioData(data) + doubaoAstHelperA?.pushAudioData(payload) } else if (currentAstProvider == "alibaba") { - bailianAstHelperA?.pushAudioData(data) + bailianAstHelperA?.pushAudioData(payload) } } @@ -168,14 +184,15 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, if (astAudioPushCountB % 100 == 1L) { FileLogger.d(tag, "pushAstAudioToB: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountB") } + val payload = gateAgainstBPlayback(data) if (currentAstProvider == "iflytek" ) { - iflytekAstHelperB?.pushAudioData(data) + iflytekAstHelperB?.pushAudioData(payload) } else if (currentAstProvider == "azure") { - azureAstHelperB?.pushAudioData(data) + azureAstHelperB?.pushAudioData(payload) } else if (currentAstProvider == "volcano") { - doubaoAstHelperB?.pushAudioData(data) + doubaoAstHelperB?.pushAudioData(payload) } else if (currentAstProvider == "alibaba") { - bailianAstHelperB?.pushAudioData(data) + bailianAstHelperB?.pushAudioData(payload) } } @@ -1120,6 +1137,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, "dispose" -> { try { FileLogger.d(tag, "释放双向 AST 资源(异步)") + callBPcmPlayer.stop() GlobalScope.launch(Dispatchers.IO) { try { if (currentAstProvider == "iflytek" ) { @@ -1178,6 +1196,16 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, result.success(true) } + "setBroadcastPeerTranslate" -> { + val enabled = call.argument("enabled") ?: false + broadcastPeerTranslate = enabled + if (!enabled) { + callBPcmPlayer.stop() + } + FileLogger.d(tag, "[CALL] setBroadcastPeerTranslate=$enabled") + result.success(true) + } + "initialize" -> { val provider = call.argument("provider") ?: "" val supportedLanguages = @@ -1269,7 +1297,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackB = IflytekAstCallback( "B", "$lang1->$lang0", { sendAstEvent(it) }, - { data -> } + bPcmWriter ) GlobalScope.launch(Dispatchers.Main) { @@ -1332,7 +1360,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackB = AzureAstCallback( "B", "$lang1->$lang0", { sendAstEvent(it) }, - { data -> } + bPcmWriter ) GlobalScope.launch(Dispatchers.Main) { @@ -1377,7 +1405,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackB = DoubaoAstCallback( "B", "$translationLang1->$translationLang0", translationLang0, { sendAstEvent(it) }, - { data -> } + bPcmWriter ) Log.d(tag, "initializeIntegrated:lang0= $translationLang0, lang1=$translationLang1") // 设置会话语言(与 UI 选择一致) @@ -1430,7 +1458,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackB = AliyunAstCallback( "B", "$translationLang1->$translationLang0", translationLang0, { sendAstEvent(it) }, - { data -> } + bPcmWriter ) GlobalScope.launch(Dispatchers.Main) { @@ -1879,13 +1907,13 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val filteredLeftBuffer = filterLowVolumeAudio(leftBuffer, lowVolumeThreshold) val filteredRightBuffer = filterLowVolumeAudio(rightBuffer, lowVolumeThreshold) // 左声道是自己的(麦克风),右声道是对方的 - // 静音门控:持续静音时不推流,避免服务端(尤其豆包)空流超时断连 - if (leftSilentStreak < silenceFramesToMute) { - pushAstAudioToA(filteredLeftBuffer) - } - if (rightSilentStreak < silenceFramesToMute) { - pushAstAudioToB(filteredRightBuffer) - } + // 持续静音时推零 PCM 保活(不断流 → 不触发豆包 audio not enough 1011) + pushAstAudioToA( + if (leftSilentStreak >= silenceFramesToMute) ByteArray(filteredLeftBuffer.size) else filteredLeftBuffer + ) + pushAstAudioToB( + if (rightSilentStreak >= silenceFramesToMute) ByteArray(filteredRightBuffer.size) else filteredRightBuffer + ) } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallBPcmPlayer.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallBPcmPlayer.kt new file mode 100644 index 000000000..ac4478fd9 --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallBPcmPlayer.kt @@ -0,0 +1,96 @@ +package com.yunqiinnovation.azure_speech + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.util.Log +import java.util.concurrent.Executors + +/** + * 通话翻译 B 路 PCM 本地播放器 + * 豆包/阿里 E2E onPartialAudio 回调拿到的 16kHz PCM16 mono 音频,直接流式写到 AudioTrack + * 单线程顺序 write,避免被 WebSocket 线程阻塞 + */ +class CallBPcmPlayer { + private val tag = "CallBPcmPlayer" + private val sampleRate = 16000 + private val channelCfg = AudioFormat.CHANNEL_OUT_MONO + private val encoding = AudioFormat.ENCODING_PCM_16BIT + private val executor = Executors.newSingleThreadExecutor { r -> + Thread(r, "CallBPcmPlayer").apply { isDaemon = true } + } + + @Volatile private var track: AudioTrack? = null + + // 排空时间点(elapsedRealtime, ms)—— 小于此时间认为 B 路 PCM 还在播,此时上游应对 A/B 路推静音防回环 + @Volatile var playbackUntilMs: Long = 0L + private set + private val tailPaddingMs = 200L + + fun isPlaying(): Boolean = android.os.SystemClock.elapsedRealtime() < playbackUntilMs + + private fun ensureTrack(): AudioTrack { + track?.let { return it } + val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelCfg, encoding) + val bufferSize = (minBuf.coerceAtLeast(1) * 4) + val attrs = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + val format = AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(channelCfg) + .setEncoding(encoding) + .build() + val t = AudioTrack( + attrs, + format, + bufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ) + t.play() + track = t + return t + } + + fun feed(data: ByteArray) { + if (data.isEmpty()) return + // 16bit mono 16kHz → 每字节 = 1/32ms;+ 尾部静默余量避免回环残尾 + val incomingMs = data.size.toLong() * 1000L / (sampleRate.toLong() * 2L) + val now = android.os.SystemClock.elapsedRealtime() + val base = if (playbackUntilMs > now) playbackUntilMs else now + playbackUntilMs = base + incomingMs + tailPaddingMs + + executor.execute { + try { + val t = ensureTrack() + var off = 0 + while (off < data.size) { + val written = t.write(data, off, data.size - off) + if (written <= 0) break + off += written + } + } catch (e: Exception) { + Log.e(tag, "feed failed: ${e.message}") + } + } + } + + fun stop() { + playbackUntilMs = 0L + executor.execute { + try { + track?.apply { + if (playState == AudioTrack.PLAYSTATE_PLAYING) stop() + flush() + release() + } + track = null + } catch (e: Exception) { + Log.e(tag, "stop failed: ${e.message}") + } + } + } +} diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift index 41ecacc17..4c9a16c6b 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift @@ -242,7 +242,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { os_log("Session updated", log: log, type: .info) case "conversation.item.input_audio_transcription.text": if let txt = obj["text"] as? String, !txt.isEmpty { - os_log("onPartialSourceText: %{public}@", log: log, type: .info, txt) + // 高频事件,取消 info 日志 callback?.onPartialSourceText(sessionId: sessionId, text: txt) } case "conversation.item.input_audio_transcription.completed": @@ -266,7 +266,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { case "response.audio_transcript.delta": if let delta = obj["delta"] as? String, !delta.isEmpty { recvTextBuffer.append(delta) - os_log("onPartialText: %{public}@", log: log, type: .info, delta) + // 高频事件,取消 info 日志 callback?.onPartialText(sessionId: sessionId, text: delta) } case "response.audio_transcript.done": @@ -286,7 +286,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate { let resampled = resample24kTo16k(audioBytes) if !resampled.isEmpty { fullAudioBuffer.append(resampled) - os_log("onPartialAudio: rawSize=%{public}d resampledSize=%{public}d", log: log, type: .info, audioBytes.count, resampled.count) + // 极高频(音频帧),取消 info 日志 processAudioChunk(incoming: resampled, multiple: 1280) } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 08fba0f71..b304f6d35 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -110,6 +110,28 @@ import ble_service private var bailianCallbackA: AliyunCallbackProxy? private var bailianCallbackB: AliyunCallbackProxy? + // 通话翻译:对方(B)路 E2E 返回的翻译音频(16kHz PCM16 mono)直接用 AVAudioEngine 播到系统默认路由 + private let callBPcmPlayer = CallBPcmPlayer() + // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 + private var broadcastPeerTranslate: Bool = false + + fileprivate func pushCallBPcm(_ data: Data) { + guard broadcastPeerTranslate else { return } + callBPcmPlayer.feed(data) + } + + fileprivate func resetCallBPlayer() { + callBPcmPlayer.stop() + } + + /// 若 B 路本地播放未启用或当前不在播放窗口内,返回原始 data; + /// 否则返回同长度的零字节 PCM,既保持 ws 连续推流又阻断声学回环。 + fileprivate func gateAgainstBPlayback(_ data: Data) -> Data { + if !broadcastPeerTranslate { return data } + if !callBPcmPlayer.isPlaying { return data } + return Data(count: data.count) + } + // 音频数据相关 private var audioDataEventChannel: FlutterEventChannel? internal var audioDataEventSink: FlutterEventSink? @@ -830,6 +852,16 @@ private func sendAudioDataEvent(_ event: [String: Any]) { } } + case "setBroadcastPeerTranslate": + let args = call.arguments as? [String: Any] + let enabled = (args?["enabled"] as? Bool) ?? false + broadcastPeerTranslate = enabled + if !enabled { + callBPcmPlayer.stop() + } + os_log("[CALL] setBroadcastPeerTranslate=%{public}@", log: ctLog, type: .info, enabled ? "true" : "false") + result(true) + case "dispose": os_log("[AST-DISPOSE] Releasing all AST resources, provider=%{public}@", log: ctLog, type: .info, currentAstProvider) azureAstHelperA.dispose() @@ -838,6 +870,7 @@ private func sendAudioDataEvent(_ event: [String: Any]) { doubaoAstHelperB.dispose() bailianAstHelperA.dispose() bailianAstHelperB.dispose() + resetCallBPlayer() os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info) result(true) @@ -1281,26 +1314,29 @@ extension AzureSpeechPlugin: BleService.Callback { // 根据当前 AST 提供商路由音频数据 // 左声道 -> A(己方语音),右声道 -> B(对方语音) + // B 路本地播放期间,A/B 两路都替换为零字节 PCM 防止 TTS 回放被麦克风回采形成闭环 + let leftPayload = gateAgainstBPlayback(leftBuffer) + let rightPayload = gateAgainstBPlayback(rightBuffer) if currentAstProvider == "volcano" { - doubaoAstHelperA.pushAudioData(leftBuffer) - doubaoAstHelperB.pushAudioData(rightBuffer) + doubaoAstHelperA.pushAudioData(leftPayload) + doubaoAstHelperB.pushAudioData(rightPayload) if bleAudioRecvCount % 200 == 1 { os_log("[STS] pushAudio: volcano A(left)=%d bytes, B(right)=%d bytes, count=%lld", - log: ctLog, type: .info, leftBuffer.count, rightBuffer.count, bleAudioRecvCount) + log: ctLog, type: .info, leftPayload.count, rightPayload.count, bleAudioRecvCount) } } else if currentAstProvider == "alibaba" { - bailianAstHelperA.pushAudioData(leftBuffer) - bailianAstHelperB.pushAudioData(rightBuffer) + bailianAstHelperA.pushAudioData(leftPayload) + bailianAstHelperB.pushAudioData(rightPayload) if bleAudioRecvCount % 200 == 1 { os_log("[STS] pushAudio: alibaba A(left)=%d bytes, B(right)=%d bytes, count=%lld", - log: ctLog, type: .info, leftBuffer.count, rightBuffer.count, bleAudioRecvCount) + log: ctLog, type: .info, leftPayload.count, rightPayload.count, bleAudioRecvCount) } } else { - azureAstHelperA.pushAudioData(audioData: leftBuffer) - azureAstHelperB.pushAudioData(audioData: rightBuffer) + azureAstHelperA.pushAudioData(audioData: leftPayload) + azureAstHelperB.pushAudioData(audioData: rightPayload) if bleAudioRecvCount % 200 == 1 { os_log("[STS] pushAudio: azure A(left)=%d bytes, B(right)=%d bytes, count=%lld", - log: ctLog, type: .info, leftBuffer.count, rightBuffer.count, bleAudioRecvCount) + log: ctLog, type: .info, leftPayload.count, rightPayload.count, bleAudioRecvCount) } } } @@ -1425,8 +1461,7 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { } func onPartialSourceText(sessionId: String, text: String) { - os_log("[DoubaoCallback-%{public}@] onPartialSourceText text=%{public}@", - log: ctLog, type: .info, serviceId, text) + // 高频事件,日志改 debug;避免 event loop 拥塞引发豆包 AudioSendSlow(1011) plugin?.sendAstEvent([ "type": "recognizing", "serviceId": serviceId, @@ -1452,8 +1487,7 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { } func onPartialText(sessionId: String, text: String) { - os_log("[DoubaoCallback-%{public}@] onPartialText text=%{public}@", - log: ctLog, type: .info, serviceId, text) + // 高频事件,取消 info 日志 plugin?.sendAstEvent([ "type": "translatedInterim", "serviceId": serviceId, @@ -1466,11 +1500,13 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { } func onPartialAudio(sessionId: String, data: Data) { - let isServiceA = (serviceId == "A") - if data.count > 0 && isServiceA { - os_log("[DoubaoCallback-%{public}@] onPartialAudio writing %d bytes to BLE", - log: ctLog, type: .debug, serviceId, data.count) + if data.isEmpty { return } + if serviceId == "A" { + // A 路:翻译后的音频通过 BLE 写到耳机外放给对方 BleService.shared.writeExternalAudioData(data: data) + } else { + // B 路:对方的翻译音频,本地 PCM 播放器直接播 + plugin?.pushCallBPcm(data) } } @@ -1558,8 +1594,7 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback { } func onPartialSourceText(sessionId: String, text: String) { - os_log("[AliyunCallback-%{public}@] onPartialSourceText text=%{public}@", - log: ctLog, type: .info, serviceId, text) + // 高频事件,取消 info 日志 plugin?.sendAstEvent([ "type": "recognizing", "serviceId": serviceId, @@ -1585,8 +1620,7 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback { } func onPartialText(sessionId: String, text: String) { - os_log("[AliyunCallback-%{public}@] onPartialText text=%{public}@", - log: ctLog, type: .info, serviceId, text) + // 高频事件,取消 info 日志 plugin?.sendAstEvent([ "type": "translatedInterim", "serviceId": serviceId, @@ -1599,11 +1633,11 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback { } func onPartialAudio(sessionId: String, data: Data) { - let isServiceA = (serviceId == "A") - if data.count > 0 && isServiceA { - os_log("[AliyunCallback-%{public}@] onPartialAudio writing %d bytes to BLE", - log: ctLog, type: .debug, serviceId, data.count) + if data.isEmpty { return } + if serviceId == "A" { BleService.shared.writeExternalAudioData(data: data) + } else { + plugin?.pushCallBPcm(data) } } @@ -1821,3 +1855,115 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { ]) } } + +// MARK: - 通话翻译 B 路 PCM 本地播放器 +/// 16kHz / Int16 / mono PCM 流式播放;由豆包/阿里 E2E onPartialAudio 逐帧灌入 +final class CallBPcmPlayer { + private let log = OSLog(subsystem: "com.azure.speech", category: "CallBPcmPlayer") + private let queue = DispatchQueue(label: "com.azure.speech.call.b.pcm") + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let sourceFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: true)! + private lazy var outputFormat: AVAudioFormat = engine.mainMixerNode.outputFormat(forBus: 0) + private var converter: AVAudioConverter? + private var attached = false + + // 排空时间点(CACurrentMediaTime 秒)—— 小于此时间视为 B 路仍在播,上游应对 A/B 路推静音防回环 + private let untilLock = NSLock() + private var playbackUntil: CFTimeInterval = 0 + private let tailPadding: CFTimeInterval = 0.2 + + /// 当前是否还在播放窗口内(供 pushAudioData 入口查询决定是否替换为静音帧) + var isPlaying: Bool { + untilLock.lock(); defer { untilLock.unlock() } + return CACurrentMediaTime() < playbackUntil + } + + private func ensureAttached() { + guard !attached else { return } + engine.attach(player) + engine.connect(player, to: engine.mainMixerNode, format: outputFormat) + converter = AVAudioConverter(from: sourceFormat, to: outputFormat) + attached = true + } + + private func ensureRunning() { + if engine.isRunning { return } + do { + try engine.start() + if !player.isPlaying { player.play() } + } catch { + os_log("engine start failed: %{public}@", log: log, type: .error, error.localizedDescription) + } + } + + func feed(_ data: Data) { + if data.isEmpty { return } + // 16bit mono 16kHz → 每字节 = 1/32ms;+ 尾部静默余量避免回环残尾 + let durSec = Double(data.count) / (16000.0 * 2.0) + untilLock.lock() + let now = CACurrentMediaTime() + let base = max(now, playbackUntil) + playbackUntil = base + durSec + tailPadding + untilLock.unlock() + + queue.async { [weak self] in + guard let self = self else { return } + self.ensureAttached() + self.ensureRunning() + guard let converter = self.converter else { return } + + let frameCount = AVAudioFrameCount(data.count / 2) + guard frameCount > 0, + let inBuf = AVAudioPCMBuffer(pcmFormat: self.sourceFormat, frameCapacity: frameCount) else { + return + } + inBuf.frameLength = frameCount + if let dst = inBuf.int16ChannelData?[0] { + data.withUnsafeBytes { raw in + if let src = raw.baseAddress { + memcpy(dst, src, Int(frameCount) * 2) + } + } + } + + let ratio = self.outputFormat.sampleRate / self.sourceFormat.sampleRate + let outCap = AVAudioFrameCount(Double(frameCount) * ratio + 1024) + guard let outBuf = AVAudioPCMBuffer(pcmFormat: self.outputFormat, frameCapacity: outCap) else { + return + } + var supplied = false + var err: NSError? + let status = converter.convert(to: outBuf, error: &err) { _, flag in + if supplied { + flag.pointee = .noDataNow + return nil + } + supplied = true + flag.pointee = .haveData + return inBuf + } + if status == .error || err != nil { + os_log("convert failed: %{public}@", log: self.log, type: .error, err?.localizedDescription ?? "?") + return + } + if outBuf.frameLength > 0 { + self.player.scheduleBuffer(outBuf, completionHandler: nil) + } + } + } + + func stop() { + untilLock.lock() + playbackUntil = 0 + untilLock.unlock() + queue.async { [weak self] in + guard let self = self else { return } + if self.player.isPlaying { self.player.stop() } + if self.engine.isRunning { self.engine.stop() } + } + } +} diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift index fcc55baa3..27591a0e9 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift @@ -438,18 +438,16 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate { if !audioData.isEmpty { recvAudio.append(audioData) - os_log("Append audio chunk: chunkSize=%{public}d totalSize=%{public}d", - log: log, type: .info, audioData.count, recvAudio.count) + // 极高频(音频帧),取消 info 日志 processAudioChunk(incoming: audioData, multiple: 1280) } if !text.isEmpty { switch event { case .sourceSubtitleStart: - os_log("SourceSubtitleStart", log: log, type: .info) recvSourceText.removeAll() case .sourceSubtitleResponse: - os_log("SourceSubtitleResponse text=%{public}@", log: log, type: .info, text) + // 高频事件,取消 info 日志 recvSourceText.append(text) callback?.onPartialSourceText(sessionId: sessionId, text: text) case .sourceSubtitleEnd: @@ -457,10 +455,9 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate { log: log, type: .info, recvSourceText.joined(separator: " ").count) callback?.onFinalSourceText(sessionId: sessionId, finalText: recvSourceText.joined(separator: " ")) case .translationSubtitleStart: - os_log("TranslationSubtitleStart", log: log, type: .info) recvText.removeAll() case .translationSubtitleResponse: - os_log("TranslationSubtitleResponse text=%{public}@", log: log, type: .info, text) + // 高频事件,取消 info 日志 recvText.append(text) callback?.onPartialText(sessionId: sessionId, text: text) case .translationSubtitleEnd: @@ -469,9 +466,7 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate { callback?.onFinalTranslatedText(sessionId: sessionId, finalText: recvText.joined(separator: " ")) startNewSubSession() default: - os_log("Other text event=%{public}d text=%{public}@", log: log, type: .info, event.rawValue, text) - // recvText.append(text) - // callback?.onPartialText(sessionId: sessionId, text: text) + break } } } From 2b70b7dd72580806c337d48f03820c676ac9938c Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Thu, 23 Apr 2026 16:36:01 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E7=BF=BB=E8=AF=91=E5=BC=80=E5=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/translation_controller.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 3ddaf957c..d6c2a6305 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -133,6 +133,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { final appDocDir = getApplicationDocumentsDirectory(); var appDir; var dir; + late Future _initServicesFuture; // ==================== 管理器 ==================== late final RecordTimerController _timerManager; @@ -565,7 +566,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { loadTranslationHistory(); // 初始化服务 - _initServices(); + _initServicesFuture = _initServices(); // 初始化历史管理器 _historyManager.initialize(); @@ -595,8 +596,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (currentMode.value != 'call') { await changeTranslationMode('call'); } - if (!isRecording.value) { - await startRecording(); + if (!isRecognizing.value) { + await startRecognition(); } } else { await stopAll(); @@ -612,8 +613,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Logger.info('[CALL_TRANS_CMD] 检测到 autoStart=true, 进入通话翻译并自启动'); Future.microtask(() async { try { - if (!isRecording.value) { - await startRecording(); + // 等待服务初始化完成(含 AST 端到端服务),再启动完整识别流程 + await _initServicesFuture; + if (!isRecognizing.value) { + await startRecognition(); } } catch (e, s) { Logger.e('TRANSL', '[CALL_TRANS_CMD] autoStart 启动失败: $e', e, s); @@ -1010,6 +1013,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 开始录音 Future startRecording() async { + // 等待服务初始化完成,确保 dir 已赋值 + await _initServicesFuture; _timerManager.startTimer(); final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); AudioSourceType audioSourceType; From 2a1d0745a9bd4581c809b02a7eec69781011e4b4 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 24 Apr 2026 10:29:44 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=9A=84=E9=80=BB=E8=BE=91=E5=B0=8F=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/picture_translation_view.dart | 3 +++ .../controllers/text_translation_controller.dart | 3 +++ .../views/dialogs/language_selection_dialog.dart | 7 +++++++ lib/modules/translation/views/translation_view.dart | 12 ++++++++---- .../views/widgets/face_to_face_layout.dart | 5 ++++- .../azure_speech/DoubaoE2ETranslateHelper.kt | 4 ++-- .../azure_speech/DoubaoE2ETranslateHelper.swift | 8 ++++---- 7 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/modules/picture_translation/views/picture_translation_view.dart b/lib/modules/picture_translation/views/picture_translation_view.dart index 453d8f9fa..56d93c4c0 100644 --- a/lib/modules/picture_translation/views/picture_translation_view.dart +++ b/lib/modules/picture_translation/views/picture_translation_view.dart @@ -242,6 +242,9 @@ class PictureTranslationView extends GetView { isDarkMode, languages: langs, currentLanguage: currentLanguage, + excludeLanguage: isSource + ? controller.targetLanguageChineseName.value + : controller.sourceLanguageChineseName.value, includeAutoDetect: isSource, translateLanguageNames: true, // 开启多语言显示,语言中文名作为翻译键 onSelected: (name) { diff --git a/lib/modules/text_translation/controllers/text_translation_controller.dart b/lib/modules/text_translation/controllers/text_translation_controller.dart index 76c0a0345..d1852614f 100644 --- a/lib/modules/text_translation/controllers/text_translation_controller.dart +++ b/lib/modules/text_translation/controllers/text_translation_controller.dart @@ -387,6 +387,9 @@ class TextTranslationController extends GetxController { currentLanguage: isSourceLanguage ? sourceLanguageChineseName.value : targetLanguageChineseName.value, + excludeLanguage: isSourceLanguage + ? targetLanguageChineseName.value + : sourceLanguageChineseName.value, includeAutoDetect: isSourceLanguage, translateLanguageNames: true, // 开启多语言显示,语言中文名作为翻译键 onSelected: (name) { diff --git a/lib/modules/translation/views/dialogs/language_selection_dialog.dart b/lib/modules/translation/views/dialogs/language_selection_dialog.dart index ccef8f120..b2a368672 100644 --- a/lib/modules/translation/views/dialogs/language_selection_dialog.dart +++ b/lib/modules/translation/views/dialogs/language_selection_dialog.dart @@ -12,12 +12,14 @@ class LanguageSelectionDialog { /// [currentLanguage] 当前选中的语言中文名称 /// [includeAutoDetect] 是否在源语言首项加入“自动检测”选项(仅文本翻译需要) /// [translateLanguageNames] 是否将语言名称作为翻译键进行翻译(语音翻译保持原逻辑) + /// [excludeLanguage] 需排除的语言中文名称(防止原语言与目标语言相同) /// [onSelected] 选中项回调,参数为中文名称 static void show( bool isSource, bool isDarkMode, { List? languages, String? currentLanguage, + String? excludeLanguage, bool includeAutoDetect = false, bool translateLanguageNames = true, ValueChanged? onSelected, @@ -31,6 +33,11 @@ class LanguageSelectionDialog { final displayLanguages = List.from(languages ?? fallbackLanguages); + // 过滤掉对侧已选语言,防止原语言与目标语言相同 + if (excludeLanguage != null && excludeLanguage.isNotEmpty) { + displayLanguages.remove(excludeLanguage); + } + // 源语言是否展示“自动检测”选项(仅列表显示,值为中文“自动检测”) final hasAutoDetect = includeAutoDetect && isSource; diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index e9ac3caf9..1c20717ed 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -316,9 +316,11 @@ class TranslationView extends GetView { sourceText: () => controller.sourceLanguage.value.split(' ')[0], targetText: () => controller.targetLanguage.value.split(' ')[0], onTapSource: () => - LanguageSelectionDialog.show(true, isDarkMode), + LanguageSelectionDialog.show(true, isDarkMode, + excludeLanguage: controller.targetLanguage.value), onTapTarget: () => - LanguageSelectionDialog.show(false, isDarkMode), + LanguageSelectionDialog.show(false, isDarkMode, + excludeLanguage: controller.sourceLanguage.value), onSwap: controller.swapLanguages, translateLanguageNames: true, // 是否翻译语言名称 fontSizeSp: 15, @@ -1022,8 +1024,10 @@ class TranslationView extends GetView { child: LanguageSelectorBar( sourceText: () => controller.sourceLanguage.value.split(' ')[0], targetText: () => controller.targetLanguage.value.split(' ')[0], - onTapSource: () => LanguageSelectionDialog.show(true, isDarkMode), - onTapTarget: () => LanguageSelectionDialog.show(false, isDarkMode), + onTapSource: () => LanguageSelectionDialog.show(true, isDarkMode, + excludeLanguage: controller.targetLanguage.value), + onTapTarget: () => LanguageSelectionDialog.show(false, isDarkMode, + excludeLanguage: controller.sourceLanguage.value), onSwap: controller.swapLanguages, translateLanguageNames: true, // 是否翻译语言名称 fontSizeSp: 15, diff --git a/lib/modules/translation/views/widgets/face_to_face_layout.dart b/lib/modules/translation/views/widgets/face_to_face_layout.dart index efc6140a3..8161c6ff2 100644 --- a/lib/modules/translation/views/widgets/face_to_face_layout.dart +++ b/lib/modules/translation/views/widgets/face_to_face_layout.dart @@ -258,6 +258,9 @@ class _FaceToFaceLayoutState extends State { /// 显示语言选择对话框 void _showLanguageSelectionDialog(bool isSource, bool isDarkMode) { - LanguageSelectionDialog.show(isSource, isDarkMode); + LanguageSelectionDialog.show(isSource, isDarkMode, + excludeLanguage: isSource + ? widget.controller.targetLanguage.value + : widget.controller.sourceLanguage.value); } } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt index 690e45dab..ea443b48e 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt @@ -231,7 +231,7 @@ class DoubaoE2ETranslateHelper( Type.SourceSubtitleResponse -> { Log.d(TAG, "onMessage: SourceSubtitleResponse text='${text}'") recvSourceText.add(text) - callback?.onPartialSourceText(sessionId, text) + callback?.onPartialSourceText(sessionId, recvSourceText.joinToString(" ")) } Type.SourceSubtitleEnd -> { Log.d(TAG, "onMessage: SourceSubtitleEnd") @@ -245,7 +245,7 @@ class DoubaoE2ETranslateHelper( Type.TranslationSubtitleResponse -> { Log.d(TAG, "onMessage: TranslationSubtitleResponse text='${text}'") recvText.add(text) - callback?.onPartialText(sessionId, text) + callback?.onPartialText(sessionId, recvText.joinToString(" ")) } Type.TranslationSubtitleEnd -> { Log.d(TAG, "onMessage: TranslationSubtitleEnd") diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift index f0abee1a6..a3915d906 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift @@ -457,7 +457,7 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate { case .sourceSubtitleResponse: // 高频事件,取消 info 日志 recvSourceText.append(text) - callback?.onPartialSourceText(sessionId: sessionId, text: text) + callback?.onPartialSourceText(sessionId: sessionId, text: recvSourceText.joined(separator: " ")) case .sourceSubtitleEnd: os_log("SourceSubtitleEnd, finalSrcLen=%{public}d", log: log, type: .info, recvSourceText.joined(separator: " ").count) @@ -467,7 +467,7 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate { case .translationSubtitleResponse: // 高频事件,取消 info 日志 recvText.append(text) - callback?.onPartialText(sessionId: sessionId, text: text) + callback?.onPartialText(sessionId: sessionId, text: recvText.joined(separator: " ")) case .translationSubtitleEnd: os_log("TranslationSubtitleEnd, finalTgtLen=%{public}d", log: log, type: .info, recvText.joined(separator: " ").count) @@ -525,14 +525,14 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate { recvSourceText.removeAll() case "SourceSubtitleResponse": recvSourceText.append(t) - callback?.onPartialSourceText(sessionId: sessionId, text: t) + callback?.onPartialSourceText(sessionId: sessionId, text: recvSourceText.joined(separator: " ")) case "SourceSubtitleEnd": callback?.onFinalSourceText(sessionId: sessionId, finalText: recvSourceText.joined(separator: " ")) case "TranslationSubtitleStart": recvText.removeAll() case "TranslationSubtitleResponse": recvText.append(t) - callback?.onPartialText(sessionId: sessionId, text: t) + callback?.onPartialText(sessionId: sessionId, text: recvText.joined(separator: " ")) case "TranslationSubtitleEnd": callback?.onFinalTranslatedText(sessionId: sessionId, finalText: recvText.joined(separator: " ")) startNewSubSession()