Browse Source

Merge branch 'liwei' of https://github.com/deepcloud2048/deep_voice into new_dev

newdev_shunjiawei
liwei1dao 6 months ago
parent
commit
a749a46e39
  1. 52
      android/app/src/main/kotlin/com/saitong/voitrans/MyApplication.kt
  2. 2
      lib/data/models/appconfig_model.dart
  3. 2
      lib/data/models/appconfig_model.g.dart
  4. 6
      lib/data/services/ast_service.dart
  5. 63
      lib/data/services/ble_manager.dart
  6. 14
      lib/data/services/speech_impl/azure_ast_service.dart
  7. 3
      lib/modules/picture_translation/views/picture_translation_view.dart
  8. 3
      lib/modules/text_translation/controllers/text_translation_controller.dart
  9. 85
      lib/modules/translation/controllers/translation_controller.dart
  10. 7
      lib/modules/translation/views/dialogs/language_selection_dialog.dart
  11. 12
      lib/modules/translation/views/translation_view.dart
  12. 5
      lib/modules/translation/views/widgets/face_to_face_layout.dart
  13. 1
      linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux
  14. 66
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  15. 96
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/CallBPcmPlayer.kt
  16. 4
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt
  17. 6
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift
  18. 196
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  19. 21
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift
  20. 26
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt
  21. 4
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt
  22. 6
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  23. 4
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift
  24. 40
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  25. 1
      windows/flutter/ephemeral/.plugin_symlinks/app_links
  26. 1
      windows/flutter/ephemeral/.plugin_symlinks/connectivity_plus
  27. 1
      windows/flutter/ephemeral/.plugin_symlinks/device_info_plus
  28. 1
      windows/flutter/ephemeral/.plugin_symlinks/file_picker
  29. 1
      windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows
  30. 1
      windows/flutter/ephemeral/.plugin_symlinks/firebase_auth
  31. 1
      windows/flutter/ephemeral/.plugin_symlinks/firebase_core
  32. 1
      windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows
  33. 1
      windows/flutter/ephemeral/.plugin_symlinks/flutter_tts
  34. 1
      windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows
  35. 1
      windows/flutter/ephemeral/.plugin_symlinks/package_info_plus
  36. 1
      windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows
  37. 1
      windows/flutter/ephemeral/.plugin_symlinks/permission_handler_windows
  38. 1
      windows/flutter/ephemeral/.plugin_symlinks/record_windows
  39. 1
      windows/flutter/ephemeral/.plugin_symlinks/share_plus
  40. 1
      windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows
  41. 1
      windows/flutter/ephemeral/.plugin_symlinks/window_size

52
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.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugins.GeneratedPluginRegistrant import io.flutter.plugins.GeneratedPluginRegistrant
import java.io.OutputStream
import java.io.PrintStream
// Application.kt // Application.kt
class MyApp : FlutterApplication() { class MyApp : FlutterApplication() {
@ -29,6 +31,9 @@ class MyApp : FlutterApplication() {
super.onCreate() super.onCreate()
appStartTime = System.currentTimeMillis() appStartTime = System.currentTimeMillis()
Log.d(TAG, "MyApp onCreate - 进程启动") Log.d(TAG, "MyApp onCreate - 进程启动")
// 屏蔽高德定位 SDK(CgiManager) 的 NumberFormatException 噪音日志
installAmapStderrFilter()
val cache = FlutterEngineCache.getInstance() val cache = FlutterEngineCache.getInstance()
val existingEngine = cache.get(ENGINE_ID) val existingEngine = cache.get(ENGINE_ID)
@ -105,6 +110,53 @@ class MyApp : FlutterApplication() {
return true 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 { private fun isAppInForeground(): Boolean {
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val processes = am.runningAppProcesses ?: return false val processes = am.runningAppProcesses ?: return false

2
lib/data/models/appconfig_model.dart

@ -84,6 +84,7 @@ class DBProduct {
bool? isforceupdate; bool? isforceupdate;
final bool? iscontinuouschat; final bool? iscontinuouschat;
final bool? wakeupenable; //该产品是否拥有唤醒开关控制 final bool? wakeupenable; //该产品是否拥有唤醒开关控制
final bool? broadcastpeertranslate; //通话翻译是否本地播报对方翻译内容
final String? updatedescription; final String? updatedescription;
final String? updatepackageaddress; final String? updatepackageaddress;
@ -98,6 +99,7 @@ class DBProduct {
required this.isforceupdate, //该产品固件版本是否强制更新 required this.isforceupdate, //该产品固件版本是否强制更新
required this.iscontinuouschat, required this.iscontinuouschat,
required this.wakeupenable, required this.wakeupenable,
required this.broadcastpeertranslate,
required this.updatedescription, required this.updatedescription,
required this.updatepackageaddress, required this.updatepackageaddress,
}); });

2
lib/data/models/appconfig_model.g.dart

@ -84,6 +84,7 @@ DBProduct _$DBProductFromJson(Map<String, dynamic> json) => DBProduct(
isforceupdate: json['isforceupdate'] as bool?, isforceupdate: json['isforceupdate'] as bool?,
iscontinuouschat: json['iscontinuouschat'] as bool?, iscontinuouschat: json['iscontinuouschat'] as bool?,
wakeupenable: json['wakeupenable'] as bool?, wakeupenable: json['wakeupenable'] as bool?,
broadcastpeertranslate: json['broadcastpeertranslate'] as bool?,
updatedescription: json['updatedescription'] as String?, updatedescription: json['updatedescription'] as String?,
updatepackageaddress: json['updatepackageaddress'] as String?, updatepackageaddress: json['updatepackageaddress'] as String?,
); );
@ -99,6 +100,7 @@ Map<String, dynamic> _$DBProductToJson(DBProduct instance) => <String, dynamic>{
'isforceupdate': instance.isforceupdate, 'isforceupdate': instance.isforceupdate,
'iscontinuouschat': instance.iscontinuouschat, 'iscontinuouschat': instance.iscontinuouschat,
'wakeupenable': instance.wakeupenable, 'wakeupenable': instance.wakeupenable,
'broadcastpeertranslate': instance.broadcastpeertranslate,
'updatedescription': instance.updatedescription, 'updatedescription': instance.updatedescription,
'updatepackageaddress': instance.updatepackageaddress, 'updatepackageaddress': instance.updatepackageaddress,
}; };

6
lib/data/services/ast_service.dart

@ -30,6 +30,12 @@ abstract class AstService {
/// 开始录音 /// 开始录音
Future<bool> path(String filePath); Future<bool> path(String filePath);
/// 通话翻译:是否本地播报对方(B)翻译后的音频。
/// true → 原生层把 B 路 E2E PCM 直接播出,同时启用播放期 gate 防回环
/// false → 原生层丢弃 B 路播放,gate 永不触发
/// 未实现的原生端应 no-op 并返回 true
Future<void> setBroadcastPeerTranslate(bool enabled);
/// 释放资源 /// 释放资源
Future<void> dispose(); Future<void> dispose();
} }

63
lib/data/services/ble_manager.dart

@ -15,6 +15,7 @@ import '../../core/utils/logger.dart';
import 'bluetooth_manager.dart'; import 'bluetooth_manager.dart';
import 'language_manager.dart'; import 'language_manager.dart';
import 'network/dio_manager.dart'; import 'network/dio_manager.dart';
import '../../routes/app_routes.dart';
// 重新导出BLE连接状态常量,让外部可以通过BleManager访问 // 重新导出BLE连接状态常量,让外部可以通过BleManager访问
export 'package:ble_service/ble_service.dart' show BleConnectionState; 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_WAKEUP_VERSION = 0x08;
static const int GET_VOICE_WAKEUP_STATUS = 0x14; static const int GET_VOICE_WAKEUP_STATUS = 0x14;
static const int SET_VOICE_WAKEUP_STATUS = 0x15; 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: 升级失败 0.obs; // 唤醒语音升级状态 0: 未升级 1: 升级中 2: 升级成功 3: 升级失败
RxInt get wakeupVoiceUpgradeStatus => _wakeupVoiceUpgradeStatus; RxInt get wakeupVoiceUpgradeStatus => _wakeupVoiceUpgradeStatus;
/// 设备上报通话翻译开关信号流
/// true = 设备上报开启通话翻译 (0x16),false = 设备上报关闭通话翻译 (0x17)
/// 外部(如 TranslationController)可监听该流,收到信号后启动/停止通话翻译功能。
final StreamController<bool> _callTranslationRequestController =
StreamController<bool>.broadcast();
Stream<bool> get onCallTranslationRequest =>
_callTranslationRequestController.stream;
/// 初始化 /// 初始化
@override @override
void onInit() { void onInit() {
@ -254,6 +267,7 @@ class BleManager extends GetxService {
void onClose() { void onClose() {
_statusSubscription?.cancel(); _statusSubscription?.cancel();
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_callTranslationRequestController.close();
super.onClose(); super.onClose();
} }
@ -392,11 +406,51 @@ class BleManager extends GetxService {
case BleCommands.SET_VOICE_WAKEUP_STATUS: case BleCommands.SET_VOICE_WAKEUP_STATUS:
_updateVoiceWakeupSetResultFromData(data); _updateVoiceWakeupSetResultFromData(data);
break; break;
case BleCommands.CALL_TRANSLATION_ON:
_handleCallTranslationRequest(true, data);
break;
case BleCommands.CALL_TRANSLATION_OFF:
_handleCallTranslationRequest(false, data);
break;
default: default:
Logger.w('BleManager', '未处理的命令ID: $commandId'); 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<dynamic, dynamic> 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( void _processDeviceInfoByCommandName(
String commandName, Map<dynamic, dynamic> data) { String commandName, Map<dynamic, dynamic> data) {
@ -1125,9 +1179,14 @@ class BleManager extends GetxService {
_awaitingCodecOpen = true; _awaitingCodecOpen = true;
_awaitingCodecClose = false; _awaitingCodecClose = false;
_isCodecActive.value = 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) { } catch (e) {
Logger.error('打开解码器失败: ${e.toString()}'); Logger.e('CALL_TRANS_CMD', '通话翻译指令异常: ${e.toString()}');
_awaitingCodecOpen = false; _awaitingCodecOpen = false;
return false; return false;
} }

14
lib/data/services/speech_impl/azure_ast_service.dart

@ -410,6 +410,20 @@ class AzureAstService extends GetxService implements AstService {
} }
} }
@override
Future<void> 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 @override
void setLowVolumeThreshold(int threshold) { void setLowVolumeThreshold(int threshold) {
try { try {

3
lib/modules/picture_translation/views/picture_translation_view.dart

@ -242,6 +242,9 @@ class PictureTranslationView extends GetView<PictureTranslationController> {
isDarkMode, isDarkMode,
languages: langs, languages: langs,
currentLanguage: currentLanguage, currentLanguage: currentLanguage,
excludeLanguage: isSource
? controller.targetLanguageChineseName.value
: controller.sourceLanguageChineseName.value,
includeAutoDetect: isSource, includeAutoDetect: isSource,
translateLanguageNames: true, // 开启多语言显示,语言中文名作为翻译键 translateLanguageNames: true, // 开启多语言显示,语言中文名作为翻译键
onSelected: (name) { onSelected: (name) {

3
lib/modules/text_translation/controllers/text_translation_controller.dart

@ -387,6 +387,9 @@ class TextTranslationController extends GetxController {
currentLanguage: isSourceLanguage currentLanguage: isSourceLanguage
? sourceLanguageChineseName.value ? sourceLanguageChineseName.value
: targetLanguageChineseName.value, : targetLanguageChineseName.value,
excludeLanguage: isSourceLanguage
? targetLanguageChineseName.value
: sourceLanguageChineseName.value,
includeAutoDetect: isSourceLanguage, includeAutoDetect: isSourceLanguage,
translateLanguageNames: true, // 开启多语言显示,语言中文名作为翻译键 translateLanguageNames: true, // 开启多语言显示,语言中文名作为翻译键
onSelected: (name) { onSelected: (name) {

85
lib/modules/translation/controllers/translation_controller.dart

@ -30,6 +30,8 @@ import 'record_timer_controller.dart';
import 'translation_history_controller.dart'; import 'translation_history_controller.dart';
import '../models/translation_models.dart'; import '../models/translation_models.dart';
import '../views/dialogs/translation_mode_selector.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 '../../../data/models/user_Info.dart';
import '../../common/widgets/recharge_dialog.dart'; import '../../common/widgets/recharge_dialog.dart';
@ -131,6 +133,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
final appDocDir = getApplicationDocumentsDirectory(); final appDocDir = getApplicationDocumentsDirectory();
var appDir; var appDir;
var dir; var dir;
late Future<void> _initServicesFuture;
// ==================== 管理器 ==================== // ==================== 管理器 ====================
late final RecordTimerController _timerManager; late final RecordTimerController _timerManager;
@ -151,6 +154,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
StreamSubscription? _recognitionSubscription; StreamSubscription? _recognitionSubscription;
StreamSubscription? _astEventSubscription; StreamSubscription? _astEventSubscription;
StreamSubscription? _voiceInteractionSubscription; StreamSubscription? _voiceInteractionSubscription;
StreamSubscription<bool>? _deviceCallTranslationSubscription;
Timer? _translationDebounceTimer; Timer? _translationDebounceTimer;
// ==================== 重新初始化守卫 ==================== // ==================== 重新初始化守卫 ====================
@ -564,7 +568,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
loadTranslationHistory(); loadTranslationHistory();
// 初始化服务 // 初始化服务
_initServices(); _initServicesFuture = _initServices();
// 初始化历史管理器 // 初始化历史管理器
_historyManager.initialize(); _historyManager.initialize();
@ -572,10 +576,57 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 应用模式逻辑(含ASR/TTS配置等) // 应用模式逻辑(含ASR/TTS配置等)
changeTranslationMode(initMode); changeTranslationMode(initMode);
// 监听设备上报的通话翻译开关 (0x16 / 0x17)
_setupDeviceCallTranslationListener(initMode);
// 监听应用生命周期:当应用切到后台/失焦时兜底重置 activeSpeaker // 监听应用生命周期:当应用切到后台/失焦时兜底重置 activeSpeaker
WidgetsBinding.instance.addObserver(this); 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 (!isRecognizing.value) {
await startRecognition();
}
} 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 {
// 等待服务初始化完成(含 AST 端到端服务),再启动完整识别流程
await _initServicesFuture;
if (!isRecognizing.value) {
await startRecognition();
}
} catch (e, s) {
Logger.e('TRANSL', '[CALL_TRANS_CMD] autoStart 启动失败: $e', e, s);
}
});
}
}
// @override // @override
// void onReady() { // void onReady() {
// super.onReady(); // super.onReady();
@ -910,6 +961,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_astEventSubscription?.cancel(); _astEventSubscription?.cancel();
_astEventSubscription = null; _astEventSubscription = null;
_voiceInteractionSubscription?.cancel(); _voiceInteractionSubscription?.cancel();
_deviceCallTranslationSubscription?.cancel();
_deviceCallTranslationSubscription = null;
_translationDebounceTimer?.cancel(); _translationDebounceTimer?.cancel();
restoreOtherServices(); restoreOtherServices();
// 移除对服务的 dispose,避免频繁销毁/重建造成卡顿 // 移除对服务的 dispose,避免频繁销毁/重建造成卡顿
@ -967,6 +1020,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 开始录音 /// 开始录音
Future<void> startRecording() async { Future<void> startRecording() async {
// 等待服务初始化完成,确保 dir 已赋值
await _initServicesFuture;
_timerManager.startTimer(); _timerManager.startTimer();
final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
AudioSourceType audioSourceType; AudioSourceType audioSourceType;
@ -1155,6 +1210,22 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
} }
_audioSourceType = true; _audioSourceType = true;
isTtsEnabled.value = false; 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(音乐或者通话远端)声音'); Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音');
} }
@ -1266,8 +1337,14 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 处理 AST(语音识别+翻译一体)事件 /// 处理 AST(语音识别+翻译一体)事件
/// 按 serviceId 区分双路(A=己方, B=对方),用 utteranceId 匹配同一句话的事件 /// 按 serviceId 区分双路(A=己方, B=对方),用 utteranceId 匹配同一句话的事件
void _handleAstEvent(ASTEvent event) { void _handleAstEvent(ASTEvent event) {
Logger.d('Translation', // 仅对终态事件打 info 日志,避免中间事件高频写 I/O 阻塞 event loop(会导致 AudioSendSlow 1011)
'[STS] _handleAstEvent: type=${event.type}, serviceId=${event.serviceId}'); 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 确定语言方向 // 根据 serviceId 确定语言方向
final String srcCode = event.serviceId == "A" final String srcCode = event.serviceId == "A"
? sourceLanguageCode.value ? sourceLanguageCode.value
@ -1279,7 +1356,6 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
switch (event.type) { switch (event.type) {
case ASTEventType.intermediateResult: case ASTEventType.intermediateResult:
if (event.text.isEmpty) break; if (event.text.isEmpty) break;
Logger.d('Translation', 'AST 识别中 [${event.serviceId}]: ${event.text}');
currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString();
// 按 utteranceId 查找已有项 // 按 utteranceId 查找已有项
@ -1372,7 +1448,6 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
case ASTEventType.translationInterim: case ASTEventType.translationInterim:
if (event.text.isEmpty) break; if (event.text.isEmpty) break;
Logger.d('Translation', 'AST 翻译中 [${event.serviceId}]: ${event.text}');
currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString();
TranslationItem? target; TranslationItem? target;

7
lib/modules/translation/views/dialogs/language_selection_dialog.dart

@ -12,12 +12,14 @@ class LanguageSelectionDialog {
/// [currentLanguage] 当前选中的语言中文名称 /// [currentLanguage] 当前选中的语言中文名称
/// [includeAutoDetect] 是否在源语言首项加入“自动检测”选项(仅文本翻译需要) /// [includeAutoDetect] 是否在源语言首项加入“自动检测”选项(仅文本翻译需要)
/// [translateLanguageNames] 是否将语言名称作为翻译键进行翻译(语音翻译保持原逻辑) /// [translateLanguageNames] 是否将语言名称作为翻译键进行翻译(语音翻译保持原逻辑)
/// [excludeLanguage] 需排除的语言中文名称(防止原语言与目标语言相同)
/// [onSelected] 选中项回调,参数为中文名称 /// [onSelected] 选中项回调,参数为中文名称
static void show( static void show(
bool isSource, bool isSource,
bool isDarkMode, { bool isDarkMode, {
List<String>? languages, List<String>? languages,
String? currentLanguage, String? currentLanguage,
String? excludeLanguage,
bool includeAutoDetect = false, bool includeAutoDetect = false,
bool translateLanguageNames = true, bool translateLanguageNames = true,
ValueChanged<String>? onSelected, ValueChanged<String>? onSelected,
@ -31,6 +33,11 @@ class LanguageSelectionDialog {
final displayLanguages = List<String>.from(languages ?? fallbackLanguages); final displayLanguages = List<String>.from(languages ?? fallbackLanguages);
// 过滤掉对侧已选语言,防止原语言与目标语言相同
if (excludeLanguage != null && excludeLanguage.isNotEmpty) {
displayLanguages.remove(excludeLanguage);
}
// 源语言是否展示“自动检测”选项(仅列表显示,值为中文“自动检测”) // 源语言是否展示“自动检测”选项(仅列表显示,值为中文“自动检测”)
final hasAutoDetect = includeAutoDetect && isSource; final hasAutoDetect = includeAutoDetect && isSource;

12
lib/modules/translation/views/translation_view.dart

@ -316,9 +316,11 @@ class TranslationView extends GetView<TranslationController> {
sourceText: () => controller.sourceLanguage.value.split(' ')[0], sourceText: () => controller.sourceLanguage.value.split(' ')[0],
targetText: () => controller.targetLanguage.value.split(' ')[0], targetText: () => controller.targetLanguage.value.split(' ')[0],
onTapSource: () => onTapSource: () =>
LanguageSelectionDialog.show(true, isDarkMode), LanguageSelectionDialog.show(true, isDarkMode,
excludeLanguage: controller.targetLanguage.value),
onTapTarget: () => onTapTarget: () =>
LanguageSelectionDialog.show(false, isDarkMode), LanguageSelectionDialog.show(false, isDarkMode,
excludeLanguage: controller.sourceLanguage.value),
onSwap: controller.swapLanguages, onSwap: controller.swapLanguages,
translateLanguageNames: true, // 是否翻译语言名称 translateLanguageNames: true, // 是否翻译语言名称
fontSizeSp: 15, fontSizeSp: 15,
@ -1022,8 +1024,10 @@ class TranslationView extends GetView<TranslationController> {
child: LanguageSelectorBar( child: LanguageSelectorBar(
sourceText: () => controller.sourceLanguage.value.split(' ')[0], sourceText: () => controller.sourceLanguage.value.split(' ')[0],
targetText: () => controller.targetLanguage.value.split(' ')[0], targetText: () => controller.targetLanguage.value.split(' ')[0],
onTapSource: () => LanguageSelectionDialog.show(true, isDarkMode), onTapSource: () => LanguageSelectionDialog.show(true, isDarkMode,
onTapTarget: () => LanguageSelectionDialog.show(false, isDarkMode), excludeLanguage: controller.targetLanguage.value),
onTapTarget: () => LanguageSelectionDialog.show(false, isDarkMode,
excludeLanguage: controller.sourceLanguage.value),
onSwap: controller.swapLanguages, onSwap: controller.swapLanguages,
translateLanguageNames: true, // 是否翻译语言名称 translateLanguageNames: true, // 是否翻译语言名称
fontSizeSp: 15, fontSizeSp: 15,

5
lib/modules/translation/views/widgets/face_to_face_layout.dart

@ -258,6 +258,9 @@ class _FaceToFaceLayoutState extends State<FaceToFaceLayout> {
/// 显示语言选择对话框 /// 显示语言选择对话框
void _showLanguageSelectionDialog(bool isSource, bool isDarkMode) { void _showLanguageSelectionDialog(bool isSource, bool isDarkMode) {
LanguageSelectionDialog.show(isSource, isDarkMode); LanguageSelectionDialog.show(isSource, isDarkMode,
excludeLanguage: isSource
? widget.controller.targetLanguage.value
: widget.controller.sourceLanguage.value);
} }
} }

1
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/

66
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 lateinit var astEventChannel: EventChannel
private var astEventSink: EventChannel.EventSink? = null 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)相关 // 翻译(Volcano)相关
private lateinit var translationChannel: MethodChannel private lateinit var translationChannel: MethodChannel
private lateinit var translationEventChannel: EventChannel private lateinit var translationEventChannel: EventChannel
@ -141,19 +149,27 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
private val gatePeakThreshold = 500 private val gatePeakThreshold = 500
private val silenceFramesToMute = 50 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) { private fun pushAstAudioToA(data: ByteArray) {
astAudioPushCountA++ astAudioPushCountA++
if (astAudioPushCountA % 100 == 1L) { if (astAudioPushCountA % 100 == 1L) {
FileLogger.d(tag, "pushAstAudioToA: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountA") FileLogger.d(tag, "pushAstAudioToA: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountA")
} }
val payload = gateAgainstBPlayback(data)
if (currentAstProvider == "iflytek") { if (currentAstProvider == "iflytek") {
iflytekAstHelperA?.pushAudioData(data) iflytekAstHelperA?.pushAudioData(payload)
} else if (currentAstProvider == "azure") { } else if (currentAstProvider == "azure") {
azureAstHelperA?.pushAudioData(data) azureAstHelperA?.pushAudioData(payload)
} else if (currentAstProvider == "volcano") { } else if (currentAstProvider == "volcano") {
doubaoAstHelperA?.pushAudioData(data) doubaoAstHelperA?.pushAudioData(payload)
} else if (currentAstProvider == "alibaba") { } else if (currentAstProvider == "alibaba") {
bailianAstHelperA?.pushAudioData(data) bailianAstHelperA?.pushAudioData(payload)
} }
} }
@ -168,14 +184,15 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
if (astAudioPushCountB % 100 == 1L) { if (astAudioPushCountB % 100 == 1L) {
FileLogger.d(tag, "pushAstAudioToB: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountB") FileLogger.d(tag, "pushAstAudioToB: provider=$currentAstProvider, size=${data.size}, count=$astAudioPushCountB")
} }
val payload = gateAgainstBPlayback(data)
if (currentAstProvider == "iflytek" ) { if (currentAstProvider == "iflytek" ) {
iflytekAstHelperB?.pushAudioData(data) iflytekAstHelperB?.pushAudioData(payload)
} else if (currentAstProvider == "azure") { } else if (currentAstProvider == "azure") {
azureAstHelperB?.pushAudioData(data) azureAstHelperB?.pushAudioData(payload)
} else if (currentAstProvider == "volcano") { } else if (currentAstProvider == "volcano") {
doubaoAstHelperB?.pushAudioData(data) doubaoAstHelperB?.pushAudioData(payload)
} else if (currentAstProvider == "alibaba") { } else if (currentAstProvider == "alibaba") {
bailianAstHelperB?.pushAudioData(data) bailianAstHelperB?.pushAudioData(payload)
} }
} }
@ -1120,6 +1137,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
"dispose" -> { "dispose" -> {
try { try {
FileLogger.d(tag, "释放双向 AST 资源(异步)") FileLogger.d(tag, "释放双向 AST 资源(异步)")
callBPcmPlayer.stop()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
try { try {
if (currentAstProvider == "iflytek" ) { if (currentAstProvider == "iflytek" ) {
@ -1178,6 +1196,16 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
result.success(true) result.success(true)
} }
"setBroadcastPeerTranslate" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
broadcastPeerTranslate = enabled
if (!enabled) {
callBPcmPlayer.stop()
}
FileLogger.d(tag, "[CALL] setBroadcastPeerTranslate=$enabled")
result.success(true)
}
"initialize" -> { "initialize" -> {
val provider = call.argument<String>("provider") ?: "" val provider = call.argument<String>("provider") ?: ""
val supportedLanguages = val supportedLanguages =
@ -1269,7 +1297,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackB = IflytekAstCallback( val callbackB = IflytekAstCallback(
"B", "$lang1->$lang0", "B", "$lang1->$lang0",
{ sendAstEvent(it) }, { sendAstEvent(it) },
{ data -> } bPcmWriter
) )
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {
@ -1332,7 +1360,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackB = AzureAstCallback( val callbackB = AzureAstCallback(
"B", "$lang1->$lang0", "B", "$lang1->$lang0",
{ sendAstEvent(it) }, { sendAstEvent(it) },
{ data -> } bPcmWriter
) )
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {
@ -1377,7 +1405,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackB = DoubaoAstCallback( val callbackB = DoubaoAstCallback(
"B", "$translationLang1->$translationLang0", translationLang0, "B", "$translationLang1->$translationLang0", translationLang0,
{ sendAstEvent(it) }, { sendAstEvent(it) },
{ data -> } bPcmWriter
) )
Log.d(tag, "initializeIntegrated:lang0= $translationLang0, lang1=$translationLang1") Log.d(tag, "initializeIntegrated:lang0= $translationLang0, lang1=$translationLang1")
// 设置会话语言(与 UI 选择一致) // 设置会话语言(与 UI 选择一致)
@ -1430,7 +1458,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackB = AliyunAstCallback( val callbackB = AliyunAstCallback(
"B", "$translationLang1->$translationLang0", translationLang0, "B", "$translationLang1->$translationLang0", translationLang0,
{ sendAstEvent(it) }, { sendAstEvent(it) },
{ data -> } bPcmWriter
) )
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {
@ -1879,13 +1907,13 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val filteredLeftBuffer = filterLowVolumeAudio(leftBuffer, lowVolumeThreshold) val filteredLeftBuffer = filterLowVolumeAudio(leftBuffer, lowVolumeThreshold)
val filteredRightBuffer = filterLowVolumeAudio(rightBuffer, lowVolumeThreshold) val filteredRightBuffer = filterLowVolumeAudio(rightBuffer, lowVolumeThreshold)
// 左声道是自己的(麦克风),右声道是对方的 // 左声道是自己的(麦克风),右声道是对方的
// 静音门控:持续静音时不推流,避免服务端(尤其豆包)空流超时断连 // 持续静音时推零 PCM 保活(不断流 → 不触发豆包 audio not enough 1011)
if (leftSilentStreak < silenceFramesToMute) { pushAstAudioToA(
pushAstAudioToA(filteredLeftBuffer) if (leftSilentStreak >= silenceFramesToMute) ByteArray(filteredLeftBuffer.size) else filteredLeftBuffer
} )
if (rightSilentStreak < silenceFramesToMute) { pushAstAudioToB(
pushAstAudioToB(filteredRightBuffer) if (rightSilentStreak >= silenceFramesToMute) ByteArray(filteredRightBuffer.size) else filteredRightBuffer
} )
} }

96
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}")
}
}
}
}

4
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt

@ -244,7 +244,7 @@ class DoubaoE2ETranslateHelper(
Type.SourceSubtitleResponse -> { Type.SourceSubtitleResponse -> {
Log.d(TAG, "onMessage: SourceSubtitleResponse text='${text}'") Log.d(TAG, "onMessage: SourceSubtitleResponse text='${text}'")
recvSourceText.add(text) recvSourceText.add(text)
callback?.onPartialSourceText(sessionId, text) callback?.onPartialSourceText(sessionId, recvSourceText.joinToString(" "))
} }
Type.SourceSubtitleEnd -> { Type.SourceSubtitleEnd -> {
Log.d(TAG, "onMessage: SourceSubtitleEnd") Log.d(TAG, "onMessage: SourceSubtitleEnd")
@ -258,7 +258,7 @@ class DoubaoE2ETranslateHelper(
Type.TranslationSubtitleResponse -> { Type.TranslationSubtitleResponse -> {
Log.d(TAG, "onMessage: TranslationSubtitleResponse text='${text}'") Log.d(TAG, "onMessage: TranslationSubtitleResponse text='${text}'")
recvText.add(text) recvText.add(text)
callback?.onPartialText(sessionId, text) callback?.onPartialText(sessionId, recvText.joinToString(" "))
} }
Type.TranslationSubtitleEnd -> { Type.TranslationSubtitleEnd -> {
Log.d(TAG, "onMessage: TranslationSubtitleEnd") Log.d(TAG, "onMessage: TranslationSubtitleEnd")

6
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift

@ -276,7 +276,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate {
os_log("Session updated", log: log, type: .info) os_log("Session updated", log: log, type: .info)
case "conversation.item.input_audio_transcription.text": case "conversation.item.input_audio_transcription.text":
if let txt = obj["text"] as? String, !txt.isEmpty { 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) callback?.onPartialSourceText(sessionId: sessionId, text: txt)
} }
case "conversation.item.input_audio_transcription.completed": case "conversation.item.input_audio_transcription.completed":
@ -300,7 +300,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate {
case "response.audio_transcript.delta": case "response.audio_transcript.delta":
if let delta = obj["delta"] as? String, !delta.isEmpty { if let delta = obj["delta"] as? String, !delta.isEmpty {
recvTextBuffer.append(delta) recvTextBuffer.append(delta)
os_log("onPartialText: %{public}@", log: log, type: .info, delta) // 高频事件,取消 info 日志
callback?.onPartialText(sessionId: sessionId, text: delta) callback?.onPartialText(sessionId: sessionId, text: delta)
} }
case "response.audio_transcript.done": case "response.audio_transcript.done":
@ -320,7 +320,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate {
let resampled = resample24kTo16k(audioBytes) let resampled = resample24kTo16k(audioBytes)
if !resampled.isEmpty { if !resampled.isEmpty {
fullAudioBuffer.append(resampled) 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) processAudioChunk(incoming: resampled, multiple: 1280)
} }
} }

196
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 bailianCallbackA: AliyunCallbackProxy?
private var bailianCallbackB: 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? private var audioDataEventChannel: FlutterEventChannel?
internal var audioDataEventSink: FlutterEventSink? 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": case "dispose":
os_log("[AST-DISPOSE] Releasing all AST resources, provider=%{public}@", log: ctLog, type: .info, currentAstProvider) os_log("[AST-DISPOSE] Releasing all AST resources, provider=%{public}@", log: ctLog, type: .info, currentAstProvider)
azureAstHelperA.dispose() azureAstHelperA.dispose()
@ -838,6 +870,7 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
doubaoAstHelperB.dispose() doubaoAstHelperB.dispose()
bailianAstHelperA.dispose() bailianAstHelperA.dispose()
bailianAstHelperB.dispose() bailianAstHelperB.dispose()
resetCallBPlayer()
os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info) os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info)
result(true) result(true)
@ -1285,26 +1318,29 @@ extension AzureSpeechPlugin: BleService.Callback {
// 根据当前 AST 提供商路由音频数据 // 根据当前 AST 提供商路由音频数据
// 左声道 -> A(己方语音),右声道 -> B(对方语音) // 左声道 -> A(己方语音),右声道 -> B(对方语音)
// B 路本地播放期间,A/B 两路都替换为零字节 PCM 防止 TTS 回放被麦克风回采形成闭环
let leftPayload = gateAgainstBPlayback(leftBuffer)
let rightPayload = gateAgainstBPlayback(rightBuffer)
if currentAstProvider == "volcano" { if currentAstProvider == "volcano" {
doubaoAstHelperA.pushAudioData(leftBuffer) doubaoAstHelperA.pushAudioData(leftPayload)
doubaoAstHelperB.pushAudioData(rightBuffer) doubaoAstHelperB.pushAudioData(rightPayload)
if bleAudioRecvCount % 200 == 1 { if bleAudioRecvCount % 200 == 1 {
os_log("[STS] pushAudio: volcano A(left)=%d bytes, B(right)=%d bytes, count=%lld", 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" { } else if currentAstProvider == "alibaba" {
bailianAstHelperA.pushAudioData(leftBuffer) bailianAstHelperA.pushAudioData(leftPayload)
bailianAstHelperB.pushAudioData(rightBuffer) bailianAstHelperB.pushAudioData(rightPayload)
if bleAudioRecvCount % 200 == 1 { if bleAudioRecvCount % 200 == 1 {
os_log("[STS] pushAudio: alibaba A(left)=%d bytes, B(right)=%d bytes, count=%lld", 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 { } else {
azureAstHelperA.pushAudioData(audioData: leftBuffer) azureAstHelperA.pushAudioData(audioData: leftPayload)
azureAstHelperB.pushAudioData(audioData: rightBuffer) azureAstHelperB.pushAudioData(audioData: rightPayload)
if bleAudioRecvCount % 200 == 1 { if bleAudioRecvCount % 200 == 1 {
os_log("[STS] pushAudio: azure A(left)=%d bytes, B(right)=%d bytes, count=%lld", 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)
} }
} }
} }
@ -1429,8 +1465,7 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback {
} }
func onPartialSourceText(sessionId: String, text: String) { func onPartialSourceText(sessionId: String, text: String) {
os_log("[DoubaoCallback-%{public}@] onPartialSourceText text=%{public}@", // 高频事件,日志改 debug;避免 event loop 拥塞引发豆包 AudioSendSlow(1011)
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "recognizing", "type": "recognizing",
"serviceId": serviceId, "serviceId": serviceId,
@ -1456,8 +1491,7 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback {
} }
func onPartialText(sessionId: String, text: String) { func onPartialText(sessionId: String, text: String) {
os_log("[DoubaoCallback-%{public}@] onPartialText text=%{public}@", // 高频事件,取消 info 日志
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "translatedInterim", "type": "translatedInterim",
"serviceId": serviceId, "serviceId": serviceId,
@ -1470,11 +1504,13 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback {
} }
func onPartialAudio(sessionId: String, data: Data) { func onPartialAudio(sessionId: String, data: Data) {
let isServiceA = (serviceId == "A") if data.isEmpty { return }
if data.count > 0 && isServiceA { if serviceId == "A" {
os_log("[DoubaoCallback-%{public}@] onPartialAudio writing %d bytes to BLE", // A 路:翻译后的音频通过 BLE 写到耳机外放给对方
log: ctLog, type: .debug, serviceId, data.count)
BleService.shared.writeExternalAudioData(data: data) BleService.shared.writeExternalAudioData(data: data)
} else {
// B 路:对方的翻译音频,本地 PCM 播放器直接播
plugin?.pushCallBPcm(data)
} }
} }
@ -1562,8 +1598,7 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback {
} }
func onPartialSourceText(sessionId: String, text: String) { func onPartialSourceText(sessionId: String, text: String) {
os_log("[AliyunCallback-%{public}@] onPartialSourceText text=%{public}@", // 高频事件,取消 info 日志
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "recognizing", "type": "recognizing",
"serviceId": serviceId, "serviceId": serviceId,
@ -1589,8 +1624,7 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback {
} }
func onPartialText(sessionId: String, text: String) { func onPartialText(sessionId: String, text: String) {
os_log("[AliyunCallback-%{public}@] onPartialText text=%{public}@", // 高频事件,取消 info 日志
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "translatedInterim", "type": "translatedInterim",
"serviceId": serviceId, "serviceId": serviceId,
@ -1603,11 +1637,11 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback {
} }
func onPartialAudio(sessionId: String, data: Data) { func onPartialAudio(sessionId: String, data: Data) {
let isServiceA = (serviceId == "A") if data.isEmpty { return }
if data.count > 0 && isServiceA { if serviceId == "A" {
os_log("[AliyunCallback-%{public}@] onPartialAudio writing %d bytes to BLE",
log: ctLog, type: .debug, serviceId, data.count)
BleService.shared.writeExternalAudioData(data: data) BleService.shared.writeExternalAudioData(data: data)
} else {
plugin?.pushCallBPcm(data)
} }
} }
@ -1825,3 +1859,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() }
}
}
}

21
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift

@ -430,40 +430,35 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate {
if !audioData.isEmpty { if !audioData.isEmpty {
recvAudio.append(audioData) recvAudio.append(audioData)
os_log("Append audio chunk: chunkSize=%{public}d totalSize=%{public}d", // 极高频(音频帧),取消 info 日志
log: log, type: .info, audioData.count, recvAudio.count)
processAudioChunk(incoming: audioData, multiple: 1280) processAudioChunk(incoming: audioData, multiple: 1280)
} }
if !text.isEmpty { if !text.isEmpty {
switch event { switch event {
case .sourceSubtitleStart: case .sourceSubtitleStart:
os_log("SourceSubtitleStart", log: log, type: .info)
recvSourceText.removeAll() recvSourceText.removeAll()
case .sourceSubtitleResponse: case .sourceSubtitleResponse:
os_log("SourceSubtitleResponse text=%{public}@", log: log, type: .info, text) // 高频事件,取消 info 日志
recvSourceText.append(text) recvSourceText.append(text)
callback?.onPartialSourceText(sessionId: sessionId, text: text) callback?.onPartialSourceText(sessionId: sessionId, text: recvSourceText.joined(separator: " "))
case .sourceSubtitleEnd: case .sourceSubtitleEnd:
os_log("SourceSubtitleEnd, finalSrcLen=%{public}d", os_log("SourceSubtitleEnd, finalSrcLen=%{public}d",
log: log, type: .info, recvSourceText.joined(separator: " ").count) log: log, type: .info, recvSourceText.joined(separator: " ").count)
callback?.onFinalSourceText(sessionId: sessionId, finalText: recvSourceText.joined(separator: " ")) callback?.onFinalSourceText(sessionId: sessionId, finalText: recvSourceText.joined(separator: " "))
case .translationSubtitleStart: case .translationSubtitleStart:
os_log("TranslationSubtitleStart", log: log, type: .info)
recvText.removeAll() recvText.removeAll()
case .translationSubtitleResponse: case .translationSubtitleResponse:
os_log("TranslationSubtitleResponse text=%{public}@", log: log, type: .info, text) // 高频事件,取消 info 日志
recvText.append(text) recvText.append(text)
callback?.onPartialText(sessionId: sessionId, text: text) callback?.onPartialText(sessionId: sessionId, text: recvText.joined(separator: " "))
case .translationSubtitleEnd: case .translationSubtitleEnd:
os_log("TranslationSubtitleEnd, finalTgtLen=%{public}d", os_log("TranslationSubtitleEnd, finalTgtLen=%{public}d",
log: log, type: .info, recvText.joined(separator: " ").count) log: log, type: .info, recvText.joined(separator: " ").count)
callback?.onFinalTranslatedText(sessionId: sessionId, finalText: recvText.joined(separator: " ")) callback?.onFinalTranslatedText(sessionId: sessionId, finalText: recvText.joined(separator: " "))
startNewSubSession() startNewSubSession()
default: default:
os_log("Other text event=%{public}d text=%{public}@", log: log, type: .info, event.rawValue, text) break
// recvText.append(text)
// callback?.onPartialText(sessionId: sessionId, text: text)
} }
} }
} }
@ -514,14 +509,14 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate {
recvSourceText.removeAll() recvSourceText.removeAll()
case "SourceSubtitleResponse": case "SourceSubtitleResponse":
recvSourceText.append(t) recvSourceText.append(t)
callback?.onPartialSourceText(sessionId: sessionId, text: t) callback?.onPartialSourceText(sessionId: sessionId, text: recvSourceText.joined(separator: " "))
case "SourceSubtitleEnd": case "SourceSubtitleEnd":
callback?.onFinalSourceText(sessionId: sessionId, finalText: recvSourceText.joined(separator: " ")) callback?.onFinalSourceText(sessionId: sessionId, finalText: recvSourceText.joined(separator: " "))
case "TranslationSubtitleStart": case "TranslationSubtitleStart":
recvText.removeAll() recvText.removeAll()
case "TranslationSubtitleResponse": case "TranslationSubtitleResponse":
recvText.append(t) recvText.append(t)
callback?.onPartialText(sessionId: sessionId, text: t) callback?.onPartialText(sessionId: sessionId, text: recvText.joined(separator: " "))
case "TranslationSubtitleEnd": case "TranslationSubtitleEnd":
callback?.onFinalTranslatedText(sessionId: sessionId, finalText: recvText.joined(separator: " ")) callback?.onFinalTranslatedText(sessionId: sessionId, finalText: recvText.joined(separator: " "))
startNewSubSession() startNewSubSession()

26
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt

@ -592,6 +592,32 @@ class BleCommandSender {
// 接收到结束 AI 单次对话信号,关闭编码器 (设备侧) // 接收到结束 AI 单次对话信号,关闭编码器 (设备侧)
callback?.notifyEndSingleDialogReceived() 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 -> { else -> {
// 对于未知上报类型,返回原始数据 // 对于未知上报类型,返回原始数据

4
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_GET_VOICE_WAKEUP_STATUS = 0x14
/** 设置语音唤醒状态 */ /** 设置语音唤醒状态 */
const val CMD_SET_VOICE_WAKEUP_STATUS = 0x15 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
// ================== 语音唤醒状态值 ================== // ================== 语音唤醒状态值 ==================
/** 语音唤醒关闭 */ /** 语音唤醒关闭 */

6
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -1570,7 +1570,7 @@ object BleService {
* @return 是否成功发送命令 * @return 是否成功发送命令
*/ */
fun openA2DPDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean { fun openA2DPDecoder(channelMode: Int = BleConst.AUDIO_CHANNEL_STEREO): Boolean {
Log.i(TAG, "打开A2DP解码器...") Log.i(TAG, "[CALL_TRANS_CMD] 打开A2DP解码器(通话翻译) channelMode=$channelMode")
startOpusEncodeStream() startOpusEncodeStream()
recordfile1?.creatingFiles(); recordfile1?.creatingFiles();
startOpusStreamDecoding(false, 2, 16000, 80) startOpusStreamDecoding(false, 2, 16000, 80)
@ -1578,7 +1578,9 @@ object BleService {
BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(), BleConst.CODEC_CONTROL_A2DP_PLAY.toByte(),
channelMode.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
} }
/** /**

4
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_GET_VOICE_WAKEUP_STATUS: UInt8 = 0x14
/** 设置语音唤醒状态 */ /** 设置语音唤醒状态 */
static let CMD_SET_VOICE_WAKEUP_STATUS: UInt8 = 0x15 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
// ================== 语音唤醒状态值 ================== // ================== 语音唤醒状态值 ==================
/** 语音唤醒关闭 */ /** 语音唤醒关闭 */

40
local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift

@ -746,14 +746,18 @@ public class BleService: NSObject {
/// 打开A2DP解码器并开始录制 /// 打开A2DP解码器并开始录制
func openA2DPDecoder() -> Bool { func openA2DPDecoder() -> Bool {
// 确保Opus解码器处于活跃状态(如果不是则启动) // 确保Opus解码器处于活跃状态(如果不是则启动)
startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280) startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280)
opusProcessor?.startEncoding(sampleRate: 16000, channels: 1, frameRate: 20) 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]) 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 { func openCallRecordDecoder() -> Bool {
@ -1384,6 +1388,32 @@ public class BleService: NSObject {
case BleConst.CMD_END_SINGLE_DIALOG: case BleConst.CMD_END_SINGLE_DIALOG:
os_log("📢 收到结束 AI 单次对话指令", log: logger, type: .info) os_log("📢 收到结束 AI 单次对话指令", log: logger, type: .info)
notifyEndSingleDialogReceived() 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: default:
// 对于未知上报类型,返回原始数据 // 对于未知上报类型,返回原始数据
let resultMap: [String: Any] = [ let resultMap: [String: Any] = [

1
windows/flutter/ephemeral/.plugin_symlinks/app_links

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/app_links-6.4.1/

1
windows/flutter/ephemeral/.plugin_symlinks/connectivity_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/

1
windows/flutter/ephemeral/.plugin_symlinks/device_info_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/

1
windows/flutter/ephemeral/.plugin_symlinks/file_picker

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/file_picker-10.2.0/

1
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/

1
windows/flutter/ephemeral/.plugin_symlinks/firebase_auth

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/firebase_auth-6.1.2/

1
windows/flutter/ephemeral/.plugin_symlinks/firebase_core

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/firebase_core-4.2.1/

1
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/

1
windows/flutter/ephemeral/.plugin_symlinks/flutter_tts

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/flutter_tts-4.2.3/

1
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/

1
windows/flutter/ephemeral/.plugin_symlinks/package_info_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/package_info_plus-8.3.0/

1
windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/

1
windows/flutter/ephemeral/.plugin_symlinks/permission_handler_windows

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/

1
windows/flutter/ephemeral/.plugin_symlinks/record_windows

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/record_windows-1.0.7/

1
windows/flutter/ephemeral/.plugin_symlinks/share_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/share_plus-12.0.1/

1
windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.4/

1
windows/flutter/ephemeral/.plugin_symlinks/window_size

@ -0,0 +1 @@
/Users/liwei/.pub-cache/git/flutter-desktop-embedding-6c66ad23ee79749f30a8eece542cf54eaf157ed8/plugins/window_size/
Loading…
Cancel
Save