From 03d73815ff4ab4c7b7b939c840f472261e791d23 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Sat, 11 Jul 2026 09:55:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=99=88=E5=B7=A5=E7=9A=84?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner.xcodeproj/project.pbxproj | 14 +- lib/data/services/ble_manager.dart | 21 - .../call_translation_debug_controller.dart | 95 +--- .../views/call_translation_debug_view.dart | 100 ---- .../azure_speech/AzureSpeechPlugin.swift | 41 +- .../ble_service/BleCommandSender.kt | 41 +- .../yunqiinnovation/ble_service/BleConst.kt | 11 - .../yunqiinnovation/ble_service/BleService.kt | 134 +---- .../ble_service/BleServicePlugin.kt | 15 +- .../Sources/ble_service/BleConst.swift | 4 +- .../Sources/ble_service/BleService.swift | 458 +++++++++++------- .../ble_service/SwiftOpusAudioProcessor.swift | 324 +++++++------ .../ble_service/lib/ble_service.dart | 24 +- pubspec.yaml | 2 +- 14 files changed, 517 insertions(+), 767 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6f823e086..ed13eec0f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -251,7 +251,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -346,14 +346,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -367,14 +363,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -789,7 +781,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 8e6663766..790bd82a6 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -384,14 +384,6 @@ class BleManager extends GetxService { _updateBatteryInfoFromData(data); break; case BleCommands.CONTROL_CODEC: - // F4/F5 解码空余上报也复用 command=0x05,但它是下行流控上报(native 已内部处理调速), - // 不是"开/关编解码"的应答。必须先排除:否则每条 F4/F5 都会被当成应答,其第二字节(空余 - // 字节数 int32 大端最高位)恒为 0 → 判"失败",既刷屏"编解码控制失败",又可能污染 - // _isCodecActive / 吞掉真正的应答(在等待开关应答的窗口内先收到 F4/F5 时)。 - final int? codecStatus = (data['codecStatus'] as num?)?.toInt(); - if (codecStatus == 0xF4 || codecStatus == 0xF5) { - break; - } final bool codecSuccess = _isCodecControlSuccess(data); if (_awaitingCodecOpen) { _isCodecActive.value = codecSuccess; @@ -1257,27 +1249,14 @@ class BleManager extends GetxService { /// 设置通话翻译调试参数(运行时生效) /// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数 - /// tierN* 为三档控流:每档 空余下限(FreeBytes) + 发送间隔(SendMs) Future setCallTranslationDebugParams({ int? sendIntervalMs, int? bundleFrameCount, - int? tier1FreeBytes, - int? tier1SendMs, - int? tier2FreeBytes, - int? tier2SendMs, - int? tier3FreeBytes, - int? tier3SendMs, }) async { try { return await _bleService.setCallTranslationDebugParams( sendIntervalMs: sendIntervalMs, bundleFrameCount: bundleFrameCount, - tier1FreeBytes: tier1FreeBytes, - tier1SendMs: tier1SendMs, - tier2FreeBytes: tier2FreeBytes, - tier2SendMs: tier2SendMs, - tier3FreeBytes: tier3FreeBytes, - tier3SendMs: tier3SendMs, ); } catch (e) { Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}'); diff --git a/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart b/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart index 682b4a652..f5ad69687 100644 --- a/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart +++ b/lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart @@ -5,7 +5,7 @@ import '../../../data/services/ble_manager.dart'; import '../../../core/utils/logger.dart'; /// 通话翻译调试控制器 -/// 负责调试参数(音频下行发送间隔、下行合包帧数、三档控流)的读取、保存、下发, +/// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发, /// 以及通话翻译编解码的启动/停止。 class CallTranslationDebugController extends GetxController { final BleManager _bleManager = Get.find(); @@ -14,23 +14,10 @@ class CallTranslationDebugController extends GetxController { // 持久化键 static const String _kSendInterval = 'call_trans_debug_send_interval'; static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames'; - static const String _kTier1Free = 'call_trans_debug_tier1_free'; - static const String _kTier1Send = 'call_trans_debug_tier1_send'; - static const String _kTier2Free = 'call_trans_debug_tier2_free'; - static const String _kTier2Send = 'call_trans_debug_tier2_send'; - static const String _kTier3Free = 'call_trans_debug_tier3_free'; - static const String _kTier3Send = 'call_trans_debug_tier3_send'; // 默认值(与原生默认保持一致) static const int defaultSendIntervalMs = 100; static const int defaultBundleFrameCount = 5; - // 三档控流默认:≥2880B→40ms / ≥1120B→160ms / 其余→320ms(对齐原生默认) - static const int defaultTier1Free = 2880; - static const int defaultTier1Send = 40; - static const int defaultTier2Free = 1120; - static const int defaultTier2Send = 160; - static const int defaultTier3Free = 0; - static const int defaultTier3Send = 320; // 取值范围 static const int minSendIntervalMs = 20; @@ -38,14 +25,6 @@ class CallTranslationDebugController extends GetxController { static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms static const int minBundleFrameCount = 1; static const int maxBundleFrameCount = 20; - // 三档:剩余字节数门限范围(设备按约 160B/包 步进上报,故用 160 步长) - static const int minTierFree = 0; - static const int maxTierFree = 4000; - static const int tierFreeStep = 160; - // 三档:发送间隔范围(取拍长整数倍更干净,用 40ms 步长) - static const int minTierSendMs = 40; - static const int maxTierSendMs = 800; - static const int tierSendStepMs = 40; /// 音频下行发送间隔(ms) final RxInt sendIntervalMs = defaultSendIntervalMs.obs; @@ -53,14 +32,6 @@ class CallTranslationDebugController extends GetxController { /// 下行合包帧数 final RxInt bundleFrameCount = defaultBundleFrameCount.obs; - /// 三档控流:每档 剩余字节数门限 + 发送间隔(ms) - final RxInt tier1Free = defaultTier1Free.obs; - final RxInt tier1Send = defaultTier1Send.obs; - final RxInt tier2Free = defaultTier2Free.obs; - final RxInt tier2Send = defaultTier2Send.obs; - final RxInt tier3Free = defaultTier3Free.obs; - final RxInt tier3Send = defaultTier3Send.obs; - /// 通话翻译编解码是否已开启 RxBool get isCodecActive => _bleManager.isCodecActiveRx; @@ -87,17 +58,6 @@ class CallTranslationDebugController extends GetxController { bundleFrameCount.value = savedFrames.clamp(minBundleFrameCount, maxBundleFrameCount); } - _loadTier(_kTier1Free, tier1Free, minTierFree, maxTierFree); - _loadTier(_kTier1Send, tier1Send, minTierSendMs, maxTierSendMs); - _loadTier(_kTier2Free, tier2Free, minTierFree, maxTierFree); - _loadTier(_kTier2Send, tier2Send, minTierSendMs, maxTierSendMs); - _loadTier(_kTier3Free, tier3Free, minTierFree, maxTierFree); - _loadTier(_kTier3Send, tier3Send, minTierSendMs, maxTierSendMs); - } - - void _loadTier(String key, RxInt target, int lo, int hi) { - final v = _storage.read(key); - if (v != null) target.value = v.clamp(lo, hi); } /// 用原生当前实际值同步显示(仅在本地未保存过该项时采用原生返回值) @@ -118,31 +78,11 @@ class CallTranslationDebugController extends GetxController { nf.clamp(minBundleFrameCount, maxBundleFrameCount); } } - _syncTier(params, 'tier1FreeBytes', _kTier1Free, tier1Free, minTierFree, - maxTierFree); - _syncTier(params, 'tier1SendMs', _kTier1Send, tier1Send, minTierSendMs, - maxTierSendMs); - _syncTier(params, 'tier2FreeBytes', _kTier2Free, tier2Free, minTierFree, - maxTierFree); - _syncTier(params, 'tier2SendMs', _kTier2Send, tier2Send, minTierSendMs, - maxTierSendMs); - _syncTier(params, 'tier3FreeBytes', _kTier3Free, tier3Free, minTierFree, - maxTierFree); - _syncTier(params, 'tier3SendMs', _kTier3Send, tier3Send, minTierSendMs, - maxTierSendMs); } catch (e) { Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $e'); } } - void _syncTier(Map params, String nativeKey, String storeKey, - RxInt target, int lo, int hi) { - // 剩余字节数下限可为 0,故不用 >0 过滤;仅在本地未保存过时采用原生值 - if (_storage.read(storeKey) != null) return; - final v = params[nativeKey]; - if (v != null) target.value = v.clamp(lo, hi); - } - void setSendInterval(int value) { final snapped = (value / sendIntervalStepMs).round() * sendIntervalStepMs; sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs); @@ -153,53 +93,22 @@ class CallTranslationDebugController extends GetxController { value.clamp(minBundleFrameCount, maxBundleFrameCount); } - void setTierFree(RxInt target, int value) { - final snapped = (value / tierFreeStep).round() * tierFreeStep; - target.value = snapped.clamp(minTierFree, maxTierFree); - } - - void setTierSend(RxInt target, int value) { - final snapped = (value / tierSendStepMs).round() * tierSendStepMs; - target.value = snapped.clamp(minTierSendMs, maxTierSendMs); - } - /// 恢复默认值 void resetDefaults() { sendIntervalMs.value = defaultSendIntervalMs; bundleFrameCount.value = defaultBundleFrameCount; - tier1Free.value = defaultTier1Free; - tier1Send.value = defaultTier1Send; - tier2Free.value = defaultTier2Free; - tier2Send.value = defaultTier2Send; - tier3Free.value = defaultTier3Free; - tier3Send.value = defaultTier3Send; } /// 保存并下发参数到原生(运行时立即生效) Future applyParams() async { _storage.write(_kSendInterval, sendIntervalMs.value); _storage.write(_kBundleFrameCount, bundleFrameCount.value); - _storage.write(_kTier1Free, tier1Free.value); - _storage.write(_kTier1Send, tier1Send.value); - _storage.write(_kTier2Free, tier2Free.value); - _storage.write(_kTier2Send, tier2Send.value); - _storage.write(_kTier3Free, tier3Free.value); - _storage.write(_kTier3Send, tier3Send.value); final ok = await _bleManager.setCallTranslationDebugParams( sendIntervalMs: sendIntervalMs.value, bundleFrameCount: bundleFrameCount.value, - tier1FreeBytes: tier1Free.value, - tier1SendMs: tier1Send.value, - tier2FreeBytes: tier2Free.value, - tier2SendMs: tier2Send.value, - tier3FreeBytes: tier3Free.value, - tier3SendMs: tier3Send.value, ); Logger.i('CALL_TRANS_DEBUG', - '下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} ' - '档1(≥${tier1Free.value}B→${tier1Send.value}ms) ' - '档2(≥${tier2Free.value}B→${tier2Send.value}ms) ' - '档3(其余→${tier3Send.value}ms) result=$ok'); + '下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} result=$ok'); Get.snackbar( '通话翻译调试', ok ? '参数已应用' : '参数下发失败(请确认设备已连接)', diff --git a/lib/modules/call_translation_debug/views/call_translation_debug_view.dart b/lib/modules/call_translation_debug/views/call_translation_debug_view.dart index ad6c5ff66..7221bf98b 100644 --- a/lib/modules/call_translation_debug/views/call_translation_debug_view.dart +++ b/lib/modules/call_translation_debug/views/call_translation_debug_view.dart @@ -36,8 +36,6 @@ class CallTranslationDebugView _buildSendIntervalCard(isDark), SizedBox(height: 16.h), _buildBundleFrameCard(isDark), - SizedBox(height: 16.h), - _buildFlowControlCard(isDark), SizedBox(height: 24.h), _buildActionButtons(isDark), ], @@ -195,104 +193,6 @@ class CallTranslationDebugView ); } - // 下行控流三档:每档 剩余字节数门限 + 发送间隔 - Widget _buildFlowControlCard(bool isDark) { - return _card( - isDark, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _title(isDark, '下行控流三档', - '按设备上报的解码空余字节数分三档控制发送频率:空余越少发得越慢,防设备解码溢出丢包。' - '规则:从第一档往下取第一个满足「空余 ≥ 门限」的档,用该档发送间隔。'), - SizedBox(height: 8.h), - _tierBlock(isDark, '第一档(最快)', controller.tier1Free, controller.tier1Send), - Divider(height: 24.h, color: isDark ? Colors.white12 : Colors.black12), - _tierBlock(isDark, '第二档(中速)', controller.tier2Free, controller.tier2Send), - Divider(height: 24.h, color: isDark ? Colors.white12 : Colors.black12), - _tierBlock(isDark, '第三档(最慢·其余)', controller.tier3Free, controller.tier3Send), - ], - ), - ); - } - - // 单个档位:两条滑杆(剩余字节数门限 + 发送间隔ms) - Widget _tierBlock(bool isDark, String label, RxInt freeRx, RxInt sendRx) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.black87, - ), - ), - SizedBox(height: 4.h), - _tierSliderRow( - isDark, - '剩余字节数门限', - freeRx, - min: CallTranslationDebugController.minTierFree, - max: CallTranslationDebugController.maxTierFree, - step: CallTranslationDebugController.tierFreeStep, - unit: 'B', - onChanged: (v) => controller.setTierFree(freeRx, v), - ), - _tierSliderRow( - isDark, - '发送间隔', - sendRx, - min: CallTranslationDebugController.minTierSendMs, - max: CallTranslationDebugController.maxTierSendMs, - step: CallTranslationDebugController.tierSendStepMs, - unit: 'ms', - onChanged: (v) => controller.setTierSend(sendRx, v), - ), - ], - ); - } - - Widget _tierSliderRow( - bool isDark, - String name, - RxInt valueRx, { - required int min, - required int max, - required int step, - required String unit, - required ValueChanged onChanged, - }) { - return Row( - children: [ - SizedBox( - width: 96.w, - child: Text( - name, - style: TextStyle( - fontSize: 12.sp, - color: isDark ? Colors.white60 : Colors.black54, - ), - ), - ), - Expanded( - child: Obx( - () => Slider( - value: valueRx.value.clamp(min, max).toDouble(), - min: min.toDouble(), - max: max.toDouble(), - divisions: (max - min) ~/ step, - label: '${valueRx.value} $unit', - onChanged: (v) => onChanged(v.round()), - ), - ), - ), - Obx(() => _valueChip(isDark, '${valueRx.value} $unit')), - ], - ); - } - Widget _title(bool isDark, String title, String subtitle) { return Column( crossAxisAlignment: CrossAxisAlignment.start, 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 3de58607b..12a9af453 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 @@ -8,6 +8,12 @@ import os.log // 导入蓝牙服务模块,用于处理蓝牙设备回调 import ble_service +// 通话翻译下行:每声道一条串行队列(对应 Android 的 bleLeftMutex / bleRightMutex)。 +// 保证同一声道内「译音 PCM → 句尾补偿静音」严格按调用顺序进编码器, +// 否则尾音静音可能抢在最后一段真实音频之前,把句尾顶丢。 +private let bleLeftWriteQueue = DispatchQueue(label: "com.azure.speech.ble.write.left") +private let bleRightWriteQueue = DispatchQueue(label: "com.azure.speech.ble.write.right") + /** * Azure Speech Plugin * @@ -1283,11 +1289,15 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC let pcmData = audioData print("[\(serviceId)] 合成音频生成,PCM大小=\(pcmData.count),合成ID=\(utteranceId)") if pcmData.count > 0 { - // B 路:对方翻译后的音频,灌入立体声录音器右声道(仅在录音开启时) if serviceId == "B" { + // B 路:对方翻译后的音频,灌入立体声录音器右声道(仅在录音开启时) plugin?.recordAzureBTtsPcm(pcmData) + // 通话翻译:对端译音下发到耳机右声道 + bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: pcmData) } + } else { + // A 路:本端译音下发到耳机左声道 + bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: pcmData) } } - BleService.shared.writeExternalAudioData(data: pcmData) } } } @@ -1578,15 +1588,24 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { func onPartialAudio(sessionId: String, data: Data) { if data.isEmpty { return } if serviceId == "A" { - // A 路:翻译后的音频通过 BLE 写到耳机外放给对方 - BleService.shared.writeExternalAudioData(data: data) + // A 路:本端译音通过 BLE 写到耳机左声道,外放给对方 + bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: data) } } else { - // B 路:对方的翻译音频,本地 PCM 播放器直接播 + // B 路:对方的翻译音频,本地 PCM 播放器直接播,同时下发到耳机右声道 plugin?.pushCallBPcm(data) + bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: data) } } } func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) { + // 句尾补偿静音:本句音频经 onPartialAudio 全部下发后触发,走与音频同一条声道队列, + // 保证补在最后一段真实音频之后,把编码器内滞留的不足一帧尾音顶出。 + if serviceId == "A" { + bleLeftWriteQueue.async { BleService.shared.feedLeftTailSilence() } + } else { + bleRightWriteQueue.async { BleService.shared.feedRightTailSilence() } + } + let key = "\(serviceId):\(sessionId)" let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? "" os_log("[DoubaoCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d", @@ -1711,13 +1730,23 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback { func onPartialAudio(sessionId: String, data: Data) { if data.isEmpty { return } if serviceId == "A" { - BleService.shared.writeExternalAudioData(data: data) + // A 路:本端译音下发到耳机左声道 + bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: data) } } else { + // B 路:对方译音本地播放,同时下发到耳机右声道 plugin?.pushCallBPcm(data) + bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: data) } } } func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) { + // 句尾补偿静音:走与音频同一条声道队列,补在最后一段真实音频之后 + if serviceId == "A" { + bleLeftWriteQueue.async { BleService.shared.feedLeftTailSilence() } + } else { + bleRightWriteQueue.async { BleService.shared.feedRightTailSilence() } + } + let key = "\(serviceId):\(sessionId)" let original = sourceTextCache.removeValue(forKey: key) ?? "" os_log("[AliyunCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d", 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 5e8bcd753..d14d1e6cf 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 @@ -85,13 +85,6 @@ class BleCommandSender { */ fun notifyWakeupSignalReceived() - /** - * 通知设备上报某声道解码缓存空余字节数(F4=左 / F5=右)。App 据此动态调整该声道下发速率。 - * @param channel BleConst.AUDIO_CHANNEL_LEFT(左) / BleConst.AUDIO_CHANNEL_RIGHT(右) - * @param freeBytes 该声道解码缓存空余字节数 - */ - fun notifyCallDecodeFreeReported(channel: Int, freeBytes: Int) - /** * 通知开始 AI 单次对话 */ @@ -384,8 +377,6 @@ class BleCommandSender { BleConst.CODEC_CONTROL_A2DP_PLAY -> "A2DP播放模式" BleConst.CODEC_CONTROL_CALL_RECORD_PLAY -> "通话记录播放模式" BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码" - BleConst.CODEC_CONTROL_DECODE_FREE_LEFT -> "左声道解码空余上报" - BleConst.CODEC_CONTROL_DECODE_FREE_RIGHT -> "右声道解码空余上报" else -> "未知状态($codecStatus)" } @@ -592,28 +583,10 @@ class BleCommandSender { } BleConst.CMD_CONTROL_CODEC -> { - // 设备主动上报编解码控制状态,关心左/右声道解码缓存空余字节数上报 (0xF4 左 / 0xF5 右),用于下行流控 - // 帧格式: 0xCC 0x05 [len] [codecStatus, ...] [crc];F4/F5 载荷为 [code][int32 大端] + // 设备主动上报编解码控制状态。帧格式: 0xCC 0x05 [len] [codecStatus, ...] [crc] val codecStatus = if (notifyData.isNotEmpty()) notifyData[0].toInt() and 0xFF else -1 - when (codecStatus) { - BleConst.CODEC_CONTROL_DECODE_FREE_LEFT, - BleConst.CODEC_CONTROL_DECODE_FREE_RIGHT -> { - // F4=左 / F5=右,紧跟一个大端 int32 空余字节数;notifyData[0]=code,[1..4]=int32,共需 >=5 字节 - val channel = if (codecStatus == BleConst.CODEC_CONTROL_DECODE_FREE_LEFT) - BleConst.AUDIO_CHANNEL_LEFT else BleConst.AUDIO_CHANNEL_RIGHT - if (notifyData.size >= 5) { - val freeBytes = readInt32BE(notifyData, 1) - CallLog.i(TAG, "收到主动上报: ${if (channel == BleConst.AUDIO_CHANNEL_LEFT) "左" else "右"}声道解码空余=$freeBytes 字节 (code=0x${codecStatus.toString(16)})") - callback?.notifyCallDecodeFreeReported(channel, freeBytes) - } else { - Log.w(TAG, "解码空余上报数据长度不足: ${notifyData.size}(期望>=5),忽略 code=0x${codecStatus.toString(16)}") - } - } - else -> { - Log.d(TAG, "收到主动上报编解码控制: codecStatus=0x${codecStatus.toString(16)}") - } - } + Log.d(TAG, "收到主动上报编解码控制: codecStatus=0x${codecStatus.toString(16)}") val resultMap = mapOf( "success" to true, "command" to infoType, @@ -814,14 +787,4 @@ class BleCommandSender { } return crc.toByte() } - - /** - * 从 data[offset] 起按大端读取一个 int32(设备解码空余字节数 F4/F5 采用大端)。 - * 若后续固件改为小端,只需把此处移位顺序反转即可,调用方无需改动。 - */ - private fun readInt32BE(data: ByteArray, offset: Int): Int = - ((data[offset].toInt() and 0xFF) shl 24) or - ((data[offset + 1].toInt() and 0xFF) shl 16) or - ((data[offset + 2].toInt() and 0xFF) shl 8) or - (data[offset + 3].toInt() and 0xFF) } \ No newline at end of file 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 38b70261d..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 @@ -121,17 +121,6 @@ object BleConst { const val CODEC_CONTROL_A2DP_PLAY = 0xA2 /** mic和dac(音乐或者通话远端)声音 */ const val CODEC_CONTROL_CALL_RECORD_PLAY = 0xA3 - /** - * 设备主动上报"左声道"解码缓存空余字节数(下行流控)。 - * 帧载荷: [0xF4][int32 大端],共 5 字节,单位字节。空余越小说明设备解码越来不及,App 据此把 - * 左声道下发节拍在 40ms / 80ms / 160ms 三档间动态切换,缓解设备端解码缓存溢出丢包。 - */ - const val CODEC_CONTROL_DECODE_FREE_LEFT = 0xF4 - /** - * 设备主动上报"右声道"解码缓存空余字节数(下行流控)。 - * 帧载荷: [0xF5][int32 大端],共 5 字节,单位字节。含义与 F4 一致、声道相反。 - */ - const val CODEC_CONTROL_DECODE_FREE_RIGHT = 0xF5 /** 打开编码指令 */ const val CODEC_CONTROL_ENCODE_ON = 0xB1 /** 左声道 */ 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 bcbbd4e38..9f2bb64cc 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 @@ -133,8 +133,8 @@ object BleService { // 音频数据缓存 //private val audioDataBuffer = mutableListOf() - // 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按声道节拍下发。 - // 缓存兼作重连暂停期间的暂存与 F4/F5 降速积压;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。 + // 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按拍下发。 + // 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。 private val leftSendBuffer = LinkedBlockingDeque() private val rightSendBuffer = LinkedBlockingDeque() private var leftSendThread: Thread? = null @@ -203,13 +203,6 @@ object BleService { // 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改 @Volatile private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL - // 左/右声道下发节拍倍数(以一个 audioSendIntervalNormal 拍为单位): - // 1 拍=1×拍长/包(全速)、4 拍、8 拍(三档)。由设备 F4(左)/F5(右) 上报的解码空余动态切换:空余越少越降速。 - // 各声道发送线程按 tick - lastSendTick(线程内局部) >= paceTicks 控制该声道下发频率。 - @Volatile - private var leftPaceTicks: Int = 1 - @Volatile - private var rightPaceTicks: Int = 1 // 声道缓存安全上限(纯防 OOM 兜底):现在缓存元素是单帧 opus,超限丢最旧帧并告警。 private const val CHANNEL_HOLD_BUFFER_MAX = 10000 // ---- 下行合包(已从 OpusAudioManager 上移到 BleService) ---- @@ -225,16 +218,6 @@ object BleService { // 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null @Volatile private var silenceFrame: ByteArray? = null - // ---- F4/F5 解码空余字节数 → 下发档位:简单三档(可在通话调试界面运行时调整) ---- - // 每档 = (剩余字节数下限 门限, 发送间隔 sendMs)。设备上报某声道解码空余 freeBytes 后, - // 从第一档往下取第一个满足 freeBytes>=门限 的档,用该档发送间隔下发(内部按 audioSendIntervalNormal 换算为拍数)。 - // 无迟滞(直观好调);如遇边界抖动可再引入固定小迟滞。默认对齐历史行为(≥2880→40ms / ≥1120→160ms / 其余→320ms)。 - @Volatile private var tier1FreeBytes = 2880 // 第一档(最快):空余 ≥ 此值 - @Volatile private var tier1SendMs = 40L // 第一档发送间隔(ms/包) - @Volatile private var tier2FreeBytes = 1120 // 第二档(中速):空余 ≥ 此值 - @Volatile private var tier2SendMs = 160L // 第二档发送间隔(ms/包) - @Volatile private var tier3FreeBytes = 0 // 第三档(最慢):其余(空余 < 第二档门限),此门限为兜底下界 - @Volatile private var tier3SendMs = 320L // 第三档发送间隔(ms/包) // 音频数据缓冲区,用于累积数据到80字节再发送 private val audioBuffer = mutableListOf() // 重发机制相关常量 @@ -447,14 +430,6 @@ object BleService { this@BleService.notifyWakeupSignalReceived() } - /** - * 通知设备上报某声道解码缓存空余字节数(F4=左 / F5=右),用于下行流控调速 - */ - override fun notifyCallDecodeFreeReported(channel: Int, freeBytes: Int) { - // 使用外部类的方法处理,避免无限递归 - this@BleService.onDecodeFreeReported(channel, freeBytes) - } - /** * 通知开始 AI 单次对话 */ @@ -1517,7 +1492,7 @@ object BleService { /** * 开始Opus数据流编码(通话翻译模式:左右声道独立编码) - * 输出合包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B + * 输出合包格式:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B * @return 是否成功开始编码 */ private fun startOpusEncodeStream(): Boolean { @@ -1560,7 +1535,7 @@ object BleService { /** * 启动左右两条独立发送线程(各发一路声道),右声道相对左声道错开 RIGHT_SEND_THREAD_STAGGER_MS 启动, - * 避免两路同一时刻抢 BLE 写。每条线程按自身声道的 paceTicks 控制下发频率,互不影响。 + * 避免两路同一时刻抢 BLE 写。每条线程按固定拍长下发,互不影响。 */ private fun startAudioSendThread() { if (leftSendThread?.isAlive == true || rightSendThread?.isAlive == true) { @@ -1575,7 +1550,7 @@ object BleService { /** * 构建单声道发送线程:固定 audioSendIntervalNormal 一拍(睡到绝对时刻,不随耗时抖动), - * 每拍按该声道 paceTicks 判断是否到点,到点则从该声道缓存取队首一包下发。 + * 每拍从该声道缓存取队首一包下发。 * 重连暂停期间只跳过下发、不清缓存(译音继续积压待回连续发)。 * @param isLeft true=左声道 / false=右声道 * @param startOffsetMs 相对启动时刻的错开毫秒(右声道用来与左声道错峰) @@ -1585,8 +1560,6 @@ object BleService { return Thread { CallLog.i(TAG, "音频发送线程[$chName]已启动 offset=${startOffsetMs}ms") var nextTickAt = System.currentTimeMillis() + startOffsetMs - var tick = 0L - var lastSendTick = -1L // 首拍错开:右线程先睡 startOffsetMs 再进入循环,使两路发送时刻相互错峰 val initDelay = nextTickAt - System.currentTimeMillis() if (initDelay > 0) { @@ -1597,14 +1570,10 @@ object BleService { // 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发) if (!isDownlinkPaused) { val buffer = if (isLeft) leftSendBuffer else rightSendBuffer - val paceTicks = if (isLeft) leftPaceTicks else rightPaceTicks - if (buffer.isNotEmpty() && (lastSendTick < 0 || tick - lastSendTick >= paceTicks)) { - if (trySendChannelChunk(isLeft)) lastSendTick = tick - } + if (buffer.isNotEmpty()) trySendChannelChunk(isLeft) } // 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动 nextTickAt += audioSendIntervalNormal - tick++ val sleepMs = nextTickAt - System.currentTimeMillis() if (sleepMs > 0) { Thread.sleep(sleepMs) @@ -1699,7 +1668,7 @@ object BleService { } val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap" CallLog.i(TAG, "音频下行发送 ch=$chTag seq=$seq Δ=${deltaMs}ms 帧=${realFrames.size}+静音$padCount size=${packet.size}B " + - "lastSeq(L=$lastLeftSeq,R=$lastRightSeq)$gapTag pace(L=${leftPaceTicks}x,R=${rightPaceTicks}x) " + + "lastSeq(L=$lastLeftSeq,R=$lastRightSeq)$gapTag " + "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") // [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包) @@ -1744,15 +1713,13 @@ object BleService { } /** - * 清空下行发送队列/左右声道缓存,并复位档位、包序、节拍与流控计数。 + * 清空下行发送队列/左右声道缓存,并复位包序、节拍与写入拥塞计数。 * 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。 */ private fun resetDownlinkBuffers() { leftSendBuffer.clear() // 清空左声道下行缓存队列 rightSendBuffer.clear() // 清空右声道下行缓存队列 audioBuffer.clear() // 清空音频缓冲区 - leftPaceTicks = 1 // 档位复位为全速 - rightPaceTicks = 1 leftPacketSeq = 0 // 复位左右声道下行包序号 rightPacketSeq = 0 lastAudioSendTime = -1L // 重置发送节拍计时,下次首包 Δ 从 0 开始 @@ -1778,18 +1745,14 @@ object BleService { } /** - * 退出下行暂停:保留暂停期间缓存的译音,仅复位下发档位/发送拍号为全速, - * 让积压的译音尽快开始下发(设备刚回连解码缓存是空的),之后仍由 F4/F5 反馈动态调速防溢出。 + * 退出下行暂停:保留暂停期间缓存的译音,仅复位发送节拍计时, + * 让积压的译音按固定拍长继续下发。 */ private fun exitDownlinkPause() { if (!isDownlinkPaused) return - // 复位档位(不动缓存):从全速开始排空积压,F4/F5 会随即接管调速。 - // 两声道线程内的 lastSendTick 会因 tick 已推进而立即到点,无需外部复位。 - leftPaceTicks = 1 - rightPaceTicks = 1 lastAudioSendTime = -1L isDownlinkPaused = false - CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,档位复位全速续发缓存译音 " + + CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,续发缓存译音 " + "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") } @@ -2029,16 +1992,10 @@ object BleService { * 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。 * @param sendIntervalMs 音频下行发送间隔(ms) * @param bundleFrameCount 下行合包帧数 - * @param t1FreeBytes/t1SendMs 第一档(最快):空余下限 / 发送间隔(ms) - * @param t2FreeBytes/t2SendMs 第二档(中速):空余下限 / 发送间隔(ms) - * @param t3FreeBytes/t3SendMs 第三档(最慢):空余下限(兜底) / 发送间隔(ms) */ fun setCallTranslationDebugParams( sendIntervalMs: Long?, - bundleFrameCount: Int?, - t1FreeBytes: Int? = null, t1SendMs: Long? = null, - t2FreeBytes: Int? = null, t2SendMs: Long? = null, - t3FreeBytes: Int? = null, t3SendMs: Long? = null + bundleFrameCount: Int? ) { if (sendIntervalMs != null && sendIntervalMs > 0) { audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L) @@ -2048,36 +2005,16 @@ object BleService { this.bundleFrameCount = bundleFrameCount.coerceIn(1, 20) Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${this.bundleFrameCount}") } - // 三档控流:门限允许 0(第三档兜底),故用 >=0 判定;发送间隔要求 >0 - if (t1FreeBytes != null && t1FreeBytes >= 0) tier1FreeBytes = t1FreeBytes - if (t1SendMs != null && t1SendMs > 0) tier1SendMs = t1SendMs.coerceIn(1L, 2000L) - if (t2FreeBytes != null && t2FreeBytes >= 0) tier2FreeBytes = t2FreeBytes - if (t2SendMs != null && t2SendMs > 0) tier2SendMs = t2SendMs.coerceIn(1L, 2000L) - if (t3FreeBytes != null && t3FreeBytes >= 0) tier3FreeBytes = t3FreeBytes - if (t3SendMs != null && t3SendMs > 0) tier3SendMs = t3SendMs.coerceIn(1L, 2000L) - if (t1FreeBytes != null || t1SendMs != null || t2FreeBytes != null || - t2SendMs != null || t3FreeBytes != null || t3SendMs != null) { - Log.i(TAG, "[CALL_TRANS_DEBUG] 设置三档控流: " + - "档1(≥${tier1FreeBytes}B→${tier1SendMs}ms) " + - "档2(≥${tier2FreeBytes}B→${tier2SendMs}ms) " + - "档3(其余→${tier3SendMs}ms)") - } } /** * 获取当前通话翻译调试参数。 - * @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) / 三档门限与间隔 + * @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) */ fun getCallTranslationDebugParams(): Map { return mapOf( "sendIntervalMs" to audioSendIntervalNormal, - "bundleFrameCount" to bundleFrameCount, - "tier1FreeBytes" to tier1FreeBytes, - "tier1SendMs" to tier1SendMs, - "tier2FreeBytes" to tier2FreeBytes, - "tier2SendMs" to tier2SendMs, - "tier3FreeBytes" to tier3FreeBytes, - "tier3SendMs" to tier3SendMs + "bundleFrameCount" to bundleFrameCount ) } @@ -2511,49 +2448,6 @@ object BleService { } } - /** - * 收到设备主动上报的某声道解码缓存空余字节数 (F4=左 / F5=右) 时调用,用于下行流控。 - * 空余越少说明设备解码越来不及,则把该声道下发节拍降速(40→80→160ms),缓解解码缓存溢出丢包; - * 空余恢复后再升回全速。仅更新对应声道档位,由发送线程在下一拍按新档位执行。 - * @param channel BleConst.AUDIO_CHANNEL_LEFT(左) / BleConst.AUDIO_CHANNEL_RIGHT(右) - * @param freeBytes 该声道解码缓存空余字节数 - */ - private fun onDecodeFreeReported(channel: Int, freeBytes: Int) { - when (channel) { - BleConst.AUDIO_CHANNEL_LEFT -> { - val pace = nextPaceTicks(freeBytes) - if (leftPaceTicks != pace) { - CallLog.i(TAG, "左声道解码空余=$freeBytes 字节,下发档位 ${leftPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)") - } - leftPaceTicks = pace - } - BleConst.AUDIO_CHANNEL_RIGHT -> { - val pace = nextPaceTicks(freeBytes) - if (rightPaceTicks != pace) { - CallLog.i(TAG, "右声道解码空余=$freeBytes 字节,下发档位 ${rightPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)") - } - rightPaceTicks = pace - } - else -> Log.w(TAG, "收到未知声道解码空余上报: channel=$channel") - } - } - - /** - * 简单三档流控(无迟滞):按最新空余 freeBytes 从第一档往下取第一个满足 freeBytes>=门限 的档, - * 得到该档发送间隔 sendMs,再按当前拍长 audioSendIntervalNormal 换算为拍数(paceTicks)。 - * 三档门限/间隔均可在通话调试界面运行时调整;发送线程按 paceTicks 控制该声道下发频率。 - * @return 拍数(≥1),即"每 paceTicks 拍发一包"。 - */ - private fun nextPaceTicks(freeBytes: Int): Int { - val sendMs = when { - freeBytes >= tier1FreeBytes -> tier1SendMs - freeBytes >= tier2FreeBytes -> tier2SendMs - else -> tier3SendMs - } - // ms → 拍数:四舍五入到最近的整数拍,至少 1 拍(不快于全速拍长) - return Math.round(sendMs.toDouble() / audioSendIntervalNormal).toInt().coerceAtLeast(1) - } - /** * 向所有回调监听器分发开始 AI 单次对话 */ diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index 63f981875..4ce100c69 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -249,16 +249,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, "setCallTranslationDebugParams" -> { val sendIntervalMs = (call.argument("sendIntervalMs"))?.toLong() val bundleFrameCount = (call.argument("bundleFrameCount"))?.toInt() - val t1FreeBytes = (call.argument("tier1FreeBytes"))?.toInt() - val t1SendMs = (call.argument("tier1SendMs"))?.toLong() - val t2FreeBytes = (call.argument("tier2FreeBytes"))?.toInt() - val t2SendMs = (call.argument("tier2SendMs"))?.toLong() - val t3FreeBytes = (call.argument("tier3FreeBytes"))?.toInt() - val t3SendMs = (call.argument("tier3SendMs"))?.toLong() - BleService.setCallTranslationDebugParams( - sendIntervalMs, bundleFrameCount, - t1FreeBytes, t1SendMs, t2FreeBytes, t2SendMs, t3FreeBytes, t3SendMs - ) + BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount) result.success(true) } "getCallTranslationDebugParams" -> { @@ -484,6 +475,4 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, // 发送升级失败事件 sendEvent(statusEventSink, resultMap, "发送升级失败异常") } - - // 注:F4/F5 解码空余字节数仅用于 native 内部下行流控(动态调整左右下发速率),不再向 Flutter 推送声道休眠/恢复事件。 -} +} 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 9f7549e89..15aff2628 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 @@ -112,12 +112,10 @@ class BleConst { static let CODEC_CONTROL_CLOSE: UInt8 = 0x00 /** 音乐或者通话远端声音 */ static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1 - /** mic和dac(音乐或者通话远端)声音 */ + /** mic和dac(音乐或者通话远端)声音+翻译后重新编码 */ static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2 /** mic和dac(音乐或者通话远端)声音 */ static let CODEC_CONTROL_CALL_RECORD_PLAY: UInt8 = 0xA3 - /** 通话休眠 */ - static let CODEC_CONTROL_CALL_Sleep: UInt8 = 0xFF /** 打开编码指令 */ static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1 /** 左声道 */ 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 3a91d0e7e..5b01edd44 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 @@ -100,28 +100,58 @@ public class BleService: NSObject { private var replyTimeoutTimer: Timer? private let REPLY_TIMEOUT_MS: TimeInterval = 1.0 + /// 接收帧 CRC 校验开关(调试期屏蔽):false = 校验失败只告警不丢帧,改回 true 恢复强校验。 + /// 仅作用于接收方向;发往设备的命令帧仍照常附带 CRC。 + private let ENABLE_RX_CRC_CHECK = false + // 新增:音频配置参数 private var sampleRate: Int32 = 16000 private var channels: Int32 = 1 private var frameSize: Int32 = 640 - // MARK: - 音频数据发送相关属性 - private var audioBuffer = [UInt8]() // 音频数据缓冲区 - private var audioSendQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.audio_send", qos: .userInitiated) - private var audioDataQueue = [Data]() // 音频数据发送队列 - private var isAudioSending = false // 音频发送状态 - private var audioSendTimer: Timer? // 音频发送定时器 - - // 音频发送配置常量 - private let AUDIO_CHUNK_SIZE = 120 // 每次发送120字节 - private let AUDIO_SEND_INTERVAL: TimeInterval = 0.06 // 60毫秒间隔 - // 收到 CODEC_CONTROL_CALL_Sleep 时插入的单次暂停时长(单位:秒) - private let AUDIO_SEND_SLEEP_PAUSE: TimeInterval = 1.0 - // 在该时间点之前 processAudioSendQueue 不消费队列,实现"插入一次 1000ms 暂停" - private var nextSendEarliestAt: Date? - private let nextSendEarliestLock = NSLock() - - private let audioQueueLock = NSLock() // 队列操作锁 + // MARK: - 音频下行发送(与 Android BleService 对齐) + // 左右声道各一条缓存队列 + 一条独立发送定时器,右声道错开 RIGHT_SEND_STAGGER 启动,避免两路同时抢 BLE 写。 + // 队列元素是单帧 opus(40B);发送线程每拍取最多 BUNDLE_FRAME_COUNT 帧,不足用静音帧补齐后组包下发。 + private var leftSendBuffer = [Data]() + private var rightSendBuffer = [Data]() + private let sendBufferLock = NSLock() + private var leftSendTimer: DispatchSourceTimer? + private var rightSendTimer: DispatchSourceTimer? + private let leftSendQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.audio_send.left", qos: .userInitiated) + private let rightSendQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.audio_send.right", qos: .userInitiated) + /// 两路发送线程写同一个 callWriteCharacteristic + 同一 peripheral,写入必须串行化 + private let bleWriteLock = NSLock() + /// 以下几个状态跨「左发送队列 / 右发送队列 / 编码回调线程 / 调用线程」共享, + /// 对应 Android 的 @Volatile 字段,统一用 sendStateLock 保护。 + private let sendStateLock = NSLock() + private var _isAudioSending = false + /// 补齐用静音 opus 帧模板(编码器启动后异步送来);未就绪时为 nil,此时只发真实帧 + private var _silenceFrame: Data? + /// 下行写入连续失败(底层发送缓冲拥塞)的计数,写成功即清零 + private var _writeFailRetryCount = 0 + /// 上一包音频实际发送的时间戳,仅用于发送节拍日志统计间隔;-1 表示尚未发送过 + private var _lastAudioSendTime: TimeInterval = -1 + + /// 左/右声道各自的下行包序号(每成功发一包自增),供设备侧核对连续性。 + /// 各自只被对应声道的发送队列访问,无需加锁。 + private var leftPacketSeq: UInt32 = 0 + private var rightPacketSeq: UInt32 = 0 + + private var isAudioSending: Bool { + get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _isAudioSending } + set { sendStateLock.lock(); _isAudioSending = newValue; sendStateLock.unlock() } + } + private var silenceFrame: Data? { + get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _silenceFrame } + set { sendStateLock.lock(); _silenceFrame = newValue; sendStateLock.unlock() } + } + + // 音频发送配置常量(与 Android 默认值对齐) + private let AUDIO_SEND_INTERVAL_NORMAL: TimeInterval = 0.08 // 音频下行发送间隔 80ms/包 + private let RIGHT_SEND_STAGGER: TimeInterval = 0.04 // 右声道相对左声道错开 40ms 启动 + private let BUNDLE_FRAME_COUNT = 5 // 每包合并的 opus 帧数 + private let BUNDLE_HEADER_SIZE = 5 // 4B 序号(小端) + 1B 声道 + private let CHANNEL_HOLD_BUFFER_MAX = 10000 // 声道缓存安全上限(防 OOM 兜底) //private var currentCommand: Command? // 命令结构体 @@ -480,16 +510,12 @@ public class BleService: NSObject { if centralManager != nil { isManualDisconnect = true // 标记为主动断开 - - // 停止音频发送定时器 - stopAudioSendTimer() + + // 停止双声道编码与左右发送定时器(内部清空下行缓存并复位包序) + opusProcessor?.stopDualEncodeStream() + stopAudioSendThreads() + silenceFrame = nil stopOpusRecording() - - // 清空音频缓冲区和队列 - audioQueueLock.lock() - audioBuffer.removeAll() - audioDataQueue.removeAll() - audioQueueLock.unlock() // 重置命令队列(与 Android reset 对齐) replyTimeoutTimer?.invalidate() @@ -684,9 +710,30 @@ public class BleService: NSObject { // MARK: - 编解码控制 - public func writeExternalAudioData(data: Data) { - // 将外部音频数据写入编码流 - opusProcessor?.encodePCMData(data) + /// 写入左声道 PCM(本端翻译音频),走左声道独立 Opus 编码器 + /// - Parameter data: PCM(16kHz / 16bit / 单声道) + public func writeExternalLeftAudioData(data: Data) { + guard !data.isEmpty else { return } + opusProcessor?.writeLeftPcm(data) + } + + /// 写入右声道 PCM(对端翻译音频),走右声道独立 Opus 编码器 + /// - Parameter data: PCM(16kHz / 16bit / 单声道) + public func writeExternalRightAudioData(data: Data) { + guard !data.isEmpty else { return } + opusProcessor?.writeRightPcm(data) + } + + /// 句尾补偿静音(本端/左声道):一句翻译音频的 PCM 全部喂完后调用, + /// 把编码器内滞留的不足一帧真实尾音顶出成整帧,避免耳机端句尾被截断。 + /// 必须在本句最后一段真实 PCM 之后调用,保证编码顺序。 + public func feedLeftTailSilence(paddingMs: Int = 400) { + opusProcessor?.feedTailSilence(channel: SwiftOpusAudioProcessor.CHANNEL_LEFT, paddingMs: paddingMs) + } + + /// 句尾补偿静音(对端/右声道),语义同 feedLeftTailSilence + public func feedRightTailSilence(paddingMs: Int = 400) { + opusProcessor?.feedTailSilence(channel: SwiftOpusAudioProcessor.CHANNEL_RIGHT, paddingMs: paddingMs) } /// 打开编码器 @@ -753,8 +800,9 @@ public class BleService: NSObject { // 确保Opus解码器处于活跃状态(如果不是则启动) startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280) - opusProcessor?.startEncoding(sampleRate: 16000, channels: 1, frameRate: 20) - + // 通话翻译:左右声道独立编码,每帧 40B/20ms(与 Android setPacketSize(40) 对齐) + _ = opusProcessor?.startDualEncodeStream(sampleRate: 16000, packetBytes: 40) + startAudioSendThreads() os_log("[CALL_TRANS_CMD] 打开A2DP解码并开始录制(通话翻译)...", log: logger, type: .info) let paramData = Data([BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_STEREO]) @@ -776,18 +824,11 @@ public class BleService: NSObject { /// - Returns: 操作是否成功 public func closeCodec() -> Bool { os_log("关闭编解码并停止录制...", log: logger, type: .info) - - - - // 停止音频发送定时器 - stopAudioSendTimer() - - // 清空音频缓冲区和队列 - audioQueueLock.lock() - audioBuffer.removeAll() - audioDataQueue.removeAll() - audioQueueLock.unlock() - + + // 停止双声道编码与左右发送定时器(内部清空下行缓存并复位包序) + opusProcessor?.stopDualEncodeStream() + stopAudioSendThreads() + silenceFrame = nil requestStopOpusRecording() @@ -1105,7 +1146,7 @@ public class BleService: NSObject { let receivedCrc = data[data.count - 1] if calculatedCrc != receivedCrc { os_log("设备响应CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc) - return + if ENABLE_RX_CRC_CHECK { return } } // 提取响应数据 @@ -1330,7 +1371,7 @@ public class BleService: NSObject { if calculatedCrc != receivedCrc { os_log("设备主动上报CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc) - return + if ENABLE_RX_CRC_CHECK { return } } // 提取上报数据 @@ -1420,15 +1461,10 @@ public class BleService: NSObject { ] notifyDeviceInfoReceived(infoType: Int(infoType), infoData: resultMap) case BleConst.CMD_CONTROL_CODEC: - // 设备主动上报编解码控制状态,目前只关心 CODEC_CONTROL_CALL_Sleep (0xFF) + // 设备主动上报编解码控制状态 // 帧格式: 0xCC 0x05 [len] [codecStatus, channelMode, ...] [crc] let codecStatus: Int = notifyData.count >= 1 ? Int(notifyData[0]) : -1 - if codecStatus == Int(BleConst.CODEC_CONTROL_CALL_Sleep) { - os_log("收到主动上报: 通话休眠 (codecStatus=0xFF)", log: logger, type: .info) - handleCallSleepReceived() - } else { - os_log("收到主动上报编解码控制: codecStatus=0x%02X", log: logger, type: .debug, codecStatus) - } + os_log("收到主动上报编解码控制: codecStatus=0x%02X", log: logger, type: .debug, codecStatus) let resultMap: [String: Any] = [ "success": true, "command": Int(infoType), @@ -1513,162 +1549,211 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback { func onAudioDataReceived(data: Data, channel: Int32) { notifyAudioDataReceived(data: data, channel: channel) } - - - /// 接收到编码后的音频数据 - /// - Parameter data: 编码后的音频数据 - func onEncodedDataReceived(data: Data) { - // 保存编码数据到文件 - recordingFile?.saveAudioData(data) - - // 根据你的业务需求处理编码数据 - // 例如:发送到蓝牙设备、保存到文件等 - os_log("🎵 接收到编码音频数据: %d bytes", log: logger, type: .info, data.count) - - // 修复条件绑定语句 - guard let characteristic = callWriteCharacteristic, - let peripheral = peripheralDevice, - connectionState == BleConst.STATE_CONNECTED else { - os_log("通话音频特征未设置或设备未连接", log: logger, type: .error) - return - } - - // 将编码音频数据加入到队列,按每次发送120字节,每次间隔60毫秒 - addAudioDataToSendQueue(data: data) - - // 启动音频发送定时器(如果尚未启动) - if !isAudioSending { - startAudioSendTimer() + + /// 每编出一帧 opus 即回调:按声道入对应下行队列,合包由发送定时器按拍完成 + func onAudioFrameEncoded(channel: UInt8, frame: Data) { + // 调试落地:保留原有「编码下行流」录制语义(此前只有本端译音走 BLE,即左声道)。 + // 仍回主队列写文件,与改造前一致,避免与 closeFile 抢同一个 RecordingFile。 + if channel == SwiftOpusAudioProcessor.CHANNEL_LEFT { + DispatchQueue.main.async { [weak self] in + self?.recordingFile?.saveAudioData(frame) + } } + enqueueOpusFrame(channel: channel, frame: frame) } - - /** - * 将音频数据添加到发送队列 - * 只有当缓冲区达到120字节时才发送数据 - * @param data 音频数据字节数组 - */ - private func addAudioDataToSendQueue(data: Data) { - audioQueueLock.lock() - defer { audioQueueLock.unlock() } - - do { - // 将新数据添加到缓冲区 - audioBuffer.append(contentsOf: data) - - // 当缓冲区达到120字节时,发送数据 - while audioBuffer.count >= AUDIO_CHUNK_SIZE { - // 取出120字节数据 - let chunk = Data(audioBuffer.prefix(AUDIO_CHUNK_SIZE)) - audioBuffer.removeFirst(AUDIO_CHUNK_SIZE) - - // 将数据加入发送队列 - - audioDataQueue.append(chunk) - + + /// 静音帧模板就绪,缓存起来用于合包时不足帧补齐 + func onSilenceFrameReady(frame: Data) { + silenceFrame = frame + } +} + +// MARK: - 下行音频:入队 / 合包 / 按拍发送(与 Android BleService 对齐) +@available(iOS 13.0, *) +extension BleService { + + /// 将一帧 opus 按声道加入对应下行队列。合包(取 N 帧/补静音/组头)在发送定时器按拍完成。 + /// 仅超 OOM 兜底上限才丢最旧帧。 + private func enqueueOpusFrame(channel: UInt8, frame: Data) { + guard !frame.isEmpty else { return } + sendBufferLock.lock() + defer { sendBufferLock.unlock() } + + if channel == SwiftOpusAudioProcessor.CHANNEL_RIGHT { + while rightSendBuffer.count >= CHANNEL_HOLD_BUFFER_MAX { + rightSendBuffer.removeFirst() + os_log("声道积压缓存超上限(%d),丢弃最旧帧 ch=R", log: logger, type: .error, CHANNEL_HOLD_BUFFER_MAX) } - - // os_log("音频数据已加入缓冲区,当前缓冲区大小: %d 字节", log: logger, type: .debug, audioBuffer.count) - } catch { - os_log("处理音频数据异常: %{public}@", log: logger, type: .error, error.localizedDescription) + rightSendBuffer.append(frame) + } else { + while leftSendBuffer.count >= CHANNEL_HOLD_BUFFER_MAX { + leftSendBuffer.removeFirst() + os_log("声道积压缓存超上限(%d),丢弃最旧帧 ch=L", log: logger, type: .error, CHANNEL_HOLD_BUFFER_MAX) + } + leftSendBuffer.append(frame) } } - - /** - * 启动音频数据发送定时器 - */ - private func startAudioSendTimer() { + + /// 启动左右两条独立发送定时器(各发一路声道),右声道错开 RIGHT_SEND_STAGGER 启动。 + func startAudioSendThreads() { guard !isAudioSending else { os_log("音频发送定时器已在运行", log: logger, type: .debug) return } - isAudioSending = true - - // 在主队列中创建定时器 - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - self.audioSendTimer = Timer.scheduledTimer(withTimeInterval: self.AUDIO_SEND_INTERVAL, repeats: true) { [weak self] _ in - self?.processAudioSendQueue() - } - - os_log("音频发送定时器已启动", log: self.logger, type: .info) - } + leftSendTimer = makeChannelSendTimer(isLeft: true) + rightSendTimer = makeChannelSendTimer(isLeft: false) + os_log("音频发送定时器已启动(左/右双路,右错开 %.0fms)", log: logger, type: .info, RIGHT_SEND_STAGGER * 1000) } - - /** - * 停止音频数据发送定时器 - */ - private func stopAudioSendTimer() { + + /// 停止左右两条发送定时器并清空缓存 + func stopAudioSendThreads() { isAudioSending = false - - DispatchQueue.main.async { [weak self] in - self?.audioSendTimer?.invalidate() - self?.audioSendTimer = nil - - guard let self = self else { return } - os_log("音频发送定时器已停止", log: self.logger, type: .info) - } - } - - /** - * 处理音频发送队列 - */ - private func processAudioSendQueue() { - // 若收到过 CODEC_CONTROL_CALL_Sleep,本轮直接放弃发送,等到 nextSendEarliestAt 之后再消费队列 - nextSendEarliestLock.lock() - let earliest = nextSendEarliestAt - nextSendEarliestLock.unlock() - if let earliest = earliest, Date() < earliest { - return + leftSendTimer?.cancel() + rightSendTimer?.cancel() + leftSendTimer = nil + rightSendTimer = nil + resetDownlinkBuffers() + os_log("音频发送定时器(左/右)已停止,队列和缓冲区已清空", log: logger, type: .info) + } + + /// 清空下行发送队列,并复位包序、节拍与写入拥塞计数 + private func resetDownlinkBuffers() { + sendBufferLock.lock() + leftSendBuffer.removeAll() + rightSendBuffer.removeAll() + sendBufferLock.unlock() + + leftPacketSeq = 0 + rightPacketSeq = 0 + + sendStateLock.lock() + _lastAudioSendTime = -1 + _writeFailRetryCount = 0 + sendStateLock.unlock() + } + + /// 构建单声道发送定时器:固定 AUDIO_SEND_INTERVAL_NORMAL 一拍,每拍取队首若干帧组包下发。 + private func makeChannelSendTimer(isLeft: Bool) -> DispatchSourceTimer { + let queue = isLeft ? leftSendQueue : rightSendQueue + let timer = DispatchSource.makeTimerSource(queue: queue) + let firstFire: DispatchTime = .now() + (isLeft ? 0 : RIGHT_SEND_STAGGER) + timer.schedule(deadline: firstFire, repeating: AUDIO_SEND_INTERVAL_NORMAL, leeway: .milliseconds(2)) + timer.setEventHandler { [weak self] in + guard let self = self, self.isAudioSending else { return } + _ = self.trySendChannelChunk(isLeft: isLeft) + } + timer.resume() + return timer + } + + /// 从指定声道队列取最多 BUNDLE_FRAME_COUNT 帧,不足用静音帧补齐,组包后下发。 + /// - 队列空:不发,返回 false + /// - 失败(拥塞):取出的真实帧按原序放回队头(静音补齐帧丢弃、下拍重组),返回 false,绝不丢帧 + private func trySendChannelChunk(isLeft: Bool) -> Bool { + // 取最多 N 帧真实数据 + sendBufferLock.lock() + var realFrames = [Data]() + while realFrames.count < BUNDLE_FRAME_COUNT { + if isLeft { + guard !leftSendBuffer.isEmpty else { break } + realFrames.append(leftSendBuffer.removeFirst()) + } else { + guard !rightSendBuffer.isEmpty else { break } + realFrames.append(rightSendBuffer.removeFirst()) + } } + sendBufferLock.unlock() + guard !realFrames.isEmpty else { return false } // 队列空则不发 - audioQueueLock.lock() - defer { audioQueueLock.unlock() } + let channel = isLeft ? SwiftOpusAudioProcessor.CHANNEL_LEFT : SwiftOpusAudioProcessor.CHANNEL_RIGHT + let chTag = isLeft ? "L" : "R" + let seq = isLeft ? leftPacketSeq : rightPacketSeq - guard !audioDataQueue.isEmpty else { return } + // 组包帧 = 真实帧 + 静音帧补齐到 N(静音帧未就绪时只发真实帧,罕见的启动窗口) + var frames = realFrames + var padCount = 0 + if let silence = silenceFrame { + while frames.count < BUNDLE_FRAME_COUNT { + frames.append(silence) + padCount += 1 + } + } + let packet = buildBundlePacket(seq: seq, channel: channel, frames: frames) - // 从队列中取出音频数据块 - let audioChunk = audioDataQueue.removeFirst() + // 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 + guard sendAudioPacket(packet) else { + // 写失败:真实帧按原顺序放回队头,下一拍重试;静音补齐帧丢弃,下次按新帧重组 + sendBufferLock.lock() + if isLeft { + leftSendBuffer.insert(contentsOf: realFrames, at: 0) + } else { + rightSendBuffer.insert(contentsOf: realFrames, at: 0) + } + sendBufferLock.unlock() + + sendStateLock.lock() + _writeFailRetryCount += 1 + let fails = _writeFailRetryCount + sendStateLock.unlock() + if fails == 1 || fails % 50 == 0 { + os_log("下行写入拥塞,重试中 ch=%{public}@ seq=%u 连续失败=%d", + log: logger, type: .error, chTag, seq, fails) + } + return false + } - // 在后台队列中发送音频数据块 - audioSendQueue.async { [weak self] in - self?.sendAudioChunk(audioChunk) + sendStateLock.lock() + let prevFails = _writeFailRetryCount + _writeFailRetryCount = 0 + let now = Date().timeIntervalSince1970 + let deltaMs = _lastAudioSendTime < 0 ? 0 : Int((now - _lastAudioSendTime) * 1000) + _lastAudioSendTime = now + sendStateLock.unlock() + if prevFails > 0 { + os_log("下行写入已恢复,之前连续失败=%d", log: logger, type: .info, prevFails) } + + // 成功后推进该声道包序(各自只被本声道发送队列访问) + if isLeft { leftPacketSeq &+= 1 } else { rightPacketSeq &+= 1 } + + sendBufferLock.lock() + let lBuf = leftSendBuffer.count + let rBuf = rightSendBuffer.count + sendBufferLock.unlock() + os_log("音频下行发送 ch=%{public}@ seq=%u Δ=%dms 帧=%d+静音%d size=%dB buf(L=%d,R=%d)", + log: logger, type: .debug, chTag, seq, deltaMs, + realFrames.count, padCount, packet.count, lBuf, rBuf) + return true } - /** - * 收到设备主动上报的 CODEC_CONTROL_CALL_Sleep 时调用, - * 让音频发送定时器在 AUDIO_SEND_SLEEP_PAUSE 内跳过发送,相当于插入一次较长暂停, - * 之后自动回到 60ms 节拍。期间重复触发会"刷新"暂停截止时间。 - */ - private func handleCallSleepReceived() { - nextSendEarliestLock.lock() - nextSendEarliestAt = Date().addingTimeInterval(AUDIO_SEND_SLEEP_PAUSE) - nextSendEarliestLock.unlock() - os_log("收到通话休眠指令,音频发送将暂停 %.0f ms", log: logger, type: .info, AUDIO_SEND_SLEEP_PAUSE * 1000) + /// 组下行包:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [N 帧 opus 顺次拼接] + private func buildBundlePacket(seq: UInt32, channel: UInt8, frames: [Data]) -> Data { + var packet = Data() + packet.reserveCapacity(BUNDLE_HEADER_SIZE + frames.reduce(0) { $0 + $1.count }) + packet.append(UInt8(seq & 0xFF)) + packet.append(UInt8((seq >> 8) & 0xFF)) + packet.append(UInt8((seq >> 16) & 0xFF)) + packet.append(UInt8((seq >> 24) & 0xFF)) + packet.append(channel) + for frame in frames { packet.append(frame) } + return packet } - - /** - * 发送音频数据块到蓝牙设备 - * @param data 要发送的音频数据 - */ - private func sendAudioChunk(_ data: Data) { + + /// 写一包到通话音频特征,返回是否成功塞入底层发送缓冲。 + /// canSendWriteWithoutResponse == false 即背压信号:底层缓冲已满,调用方应放回队头下一拍重试。 + private func sendAudioPacket(_ packet: Data) -> Bool { guard let characteristic = callWriteCharacteristic, let peripheral = peripheralDevice, connectionState == BleConst.STATE_CONNECTED else { - os_log("通话音频特征未设置或设备未连接,无法发送音频数据", log: logger, type: .error) - return - } - - // 发送音频数据块 - peripheral.writeValue(data, for: characteristic, type: .withoutResponse) - //判断是否发送成功 - if peripheral.state == .connected { - os_log("🎵 发送音频数据块: %d bytes", log: logger, type: .debug, data.count) - } else { - os_log("🎵 发送音频数据块失败: %d bytes", log: logger, type: .debug, data.count) + return false } + bleWriteLock.lock() + defer { bleWriteLock.unlock() } + + guard peripheral.canSendWriteWithoutResponse else { return false } + peripheral.writeValue(packet, for: characteristic, type: .withoutResponse) + return true } } @@ -2120,11 +2205,16 @@ extension BleService: CBPeripheralDelegate { } } + /// 底层发送缓冲从「满」恢复为可写。发送定时器下一拍会自然重试队头帧,这里只记录一次恢复。 + public func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + os_log("下行写入缓冲已就绪(canSendWriteWithoutResponse 恢复)", log: logger, type: .debug) + } + public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { return } - + guard let data = characteristic.value, !data.isEmpty else { return } if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID { diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift index 3c84f0a62..1b556154c 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift @@ -9,14 +9,27 @@ class SwiftOpusAudioProcessor: NSObject { /// 音频数据回调协议 protocol AudioDataCallback: AnyObject { func onAudioDataReceived(data: Data, channel: Int32) - func onEncodedDataReceived(data: Data) // 编码数据回调 + /// 每编出一帧 opus 即回调(帧粒度,40B/20ms),由 BleService 按声道入队并合包下发。 + /// - Parameters: + /// - channel: CHANNEL_LEFT(0=本端) / CHANNEL_RIGHT(1=对端) + /// - frame: 一帧 opus 编码数据 + func onAudioFrameEncoded(channel: UInt8, frame: Data) + /// 静音帧模板就绪,供 BleService 合包时不足帧补齐 + func onSilenceFrameReady(frame: Data) } - + + /// 双声道下行包声道标识:0=左(本端翻译) / 1=右(对端翻译),与 Android OpusAudioManager 对齐 + static let CHANNEL_LEFT: UInt8 = 0 + static let CHANNEL_RIGHT: UInt8 = 1 + /// 生成静音帧模板时喂入的零 PCM 长度(100ms@16k@16bit),足够编码器产出至少一帧 + private static let SILENCE_PCM_BYTES = 3200 + /// 16kHz / 16bit / 单声道:32 字节 = 1ms + private static let PCM_BYTES_PER_MS = 32 + // MARK: - 私有属性 // 状态标志 private var isDecodingActive = false - private var isEncodingActive = false private weak var callback: AudioDataCallback? // Opus 解码器相关 - 使用 YbridOpus C API @@ -25,20 +38,24 @@ class SwiftOpusAudioProcessor: NSObject { private var channels: Int32 = 1 private let maxFrameSize: Int32 = 640 // 为16kHz优化:40ms * 16000Hz = 640 samples - // MARK: - JLOpusEncoder 编码器相关属性 - private var jlOpusEncoder: JLOpusEncoder? - private var encoderFormat: JLOpusFormat? - - // MARK: - PCM数据队列相关属性 - /// PCM数据缓存队列 - private var pcmDataQueue = Data() - /// 队列访问锁,确保线程安全 - private let queueLock = NSLock() - /// 每次处理的数据块大小(字节) + // MARK: - 双声道独立编码(与 Android OpusAudioManager.startDualEncodeStream 对齐) + // 左右各一个独立 JLOpusEncoder,互不污染预测状态;另有一个只用于生成静音帧模板的编码器。 + private var leftEncoder: JLOpusEncoder? + private var rightEncoder: JLOpusEncoder? + private var silenceEncoder: JLOpusEncoder? + private var isDualEncoding = false + /// 静音帧模板,捕获首帧后不再更新 + private var capturedSilenceFrame: Data? + + /// 每次送入编码器的 PCM 块大小:320 samples × 2B = 640B = 20ms@16kHz。 + /// 不足一块的尾巴留在 remainder 里等下一段 PCM,绝不补零——补零会在句中插入静音。 private let chunkSize = 640 - /// 异步处理队列 - private var processingQueue: DispatchQueue? - + private var leftPcmRemainder = Data() + private var rightPcmRemainder = Data() + /// 每声道一条串行队列:保证「喂 PCM」与「句尾补偿静音」严格按调用顺序进编码器 + private let leftEncodeQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.opus.encode.left", qos: .userInitiated) + private let rightEncodeQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.opus.encode.right", qos: .userInitiated) + // MARK: - 初始化和清理 override init() { @@ -351,7 +368,12 @@ class SwiftOpusAudioProcessor: NSObject { /// 获取编码器状态 /// - Returns: 是否正在编码 func isEncoding() -> Bool { - return isEncodingActive + return isDualEncoding + } + + /// 是否正在进行双声道独立编码 + func isDualEncodingActive() -> Bool { + return isDualEncoding } /// 获取当前配置信息 @@ -367,149 +389,156 @@ class SwiftOpusAudioProcessor: NSObject { } - /// 启动JLOpusEncoder编码(完善版本) + // ====================================================================================================== + // 双声道独立编码(左右声道各用独立 JLOpusEncoder,互不污染预测状态) + // 每编出一帧即通过 onAudioFrameEncoded(声道, 帧) 回给 BleService;合包(取N帧/补静音/组头)由 BleService 完成。 + // 与 Android OpusAudioManager.startDualEncodeStream 语义一致。 + // ====================================================================================================== + + /// 启动双声道独立编码流 /// - Parameters: - /// - sampleRate: 采样率(默认16000Hz) - /// - channels: 声道数(默认1) - /// - frameRate: 帧长度(默认20ms) - /// - isHeader: 是否包含头部信息 + /// - sampleRate: 采样率(默认 16000Hz) + /// - packetBytes: 每帧 opus 输出字节数(默认 40B,对应 20ms@16kbps,与 Android setPacketSize(40) 一致) /// - Returns: 是否启动成功 - func startEncoding(sampleRate: Int32 = 16000, - channels: Int32 = 1, - frameRate: Int32 = 20, - isHeader: Bool = false) -> Bool { - if isEncodingActive { + func startDualEncodeStream(sampleRate: Int32 = 16000, packetBytes: Int32 = 40) -> Bool { + if isDualEncoding { + print("双声道编码流已在运行") return true } - - print("启动JLOpusEncoder编码 - 采样率: \(sampleRate)Hz, 声道: \(channels), 帧长度: \(frameRate)ms, 头部: \(isHeader)") - - // 创建JLOpusFormat配置 - encoderFormat = JLOpusFormat.defaultFormats() - encoderFormat?.sampleRate = Int32(sampleRate) - encoderFormat?.channels = Int32(channels) - encoderFormat?.frameDuration = Int32(frameRate) - encoderFormat?.hasDataHeader = isHeader - - // 根据配置计算数据大小 - if channels == 1 { - encoderFormat?.dataSize = frameRate == 20 ? 40 : 80 // 20ms=40bytes, 40ms=80bytes - } else { - encoderFormat?.dataSize = frameRate == 20 ? 80 : 160 // 双声道翻倍 - } - - guard let format = encoderFormat else { - print("编码器格式配置失败") - return false + + capturedSilenceFrame = nil + leftPcmRemainder.removeAll() + rightPcmRemainder.removeAll() + + // 每个编码器必须用独立的 format 实例,避免共享可变对象 + func makeFormat() -> JLOpusFormat { + let f = JLOpusFormat.defaultFormats() + f.sampleRate = sampleRate + f.channels = 1 + f.frameDuration = 20 + f.dataSize = packetBytes + f.hasDataHeader = false // 与 Android setHasHead(false) 对齐 + return f } - - // 初始化JLOpusEncoder - jlOpusEncoder = JLOpusEncoder(format: format, delegate: self) - - guard jlOpusEncoder != nil else { - print("JLOpusEncoder初始化失败") + + leftEncoder = JLOpusEncoder(format: makeFormat(), delegate: self) + rightEncoder = JLOpusEncoder(format: makeFormat(), delegate: self) + silenceEncoder = JLOpusEncoder(format: makeFormat(), delegate: self) + + guard leftEncoder != nil, rightEncoder != nil, silenceEncoder != nil else { + print("JLOpusEncoder 初始化失败") + stopDualEncodeStream() return false } - - isEncodingActive = true - print("JLOpusEncoder启动成功") - + + isDualEncoding = true + print("双声道独立编码流已启动 sampleRate=\(sampleRate) packetBytes=\(packetBytes)") + + // 生成静音帧模板:喂一段零 PCM,捕获输出首帧 + startSilenceFrameCapture() return true } - - /// 停止JLOpusEncoder编码(完善版本) - /// - Returns: 是否停止成功 - func stopEncoding() -> Bool { - if !isEncodingActive { - return false + + /// 停止双声道独立编码流。 + /// 左右编码器必须在各自的串行队列上释放:直接在调用线程 opusOnRelease() 会与队列里在飞的 + /// opusEncode() 撞上,造成编码器内部状态已释放却仍被使用(UAF)。排到队尾释放即可保证 + /// 之前入队的编码工作全部先跑完。 + @discardableResult + func stopDualEncodeStream() -> Bool { + isDualEncoding = false + + // 静音编码器只在 startDualEncodeStream 里同步喂过,没有在飞的异步编码,可直接释放 + silenceEncoder?.opusOnRelease() + silenceEncoder = nil + capturedSilenceFrame = nil + + leftEncodeQueue.async { [weak self] in + guard let self = self else { return } + self.leftEncoder?.opusOnRelease() + self.leftEncoder = nil + self.leftPcmRemainder.removeAll() } - - isEncodingActive = false - - // 清空PCM数据队列 - clearPCMQueue() - - // 释放JLOpusEncoder资源 - jlOpusEncoder?.opusOnRelease() - jlOpusEncoder = nil - encoderFormat = nil - processingQueue = nil - - print("JLOpusEncoder已停止") - + rightEncodeQueue.async { [weak self] in + guard let self = self else { return } + self.rightEncoder?.opusOnRelease() + self.rightEncoder = nil + self.rightPcmRemainder.removeAll() + } + + print("双声道独立编码流已停止") return true } - - /// 编码PCM音频数据 - /// - Parameter pcmData: PCM音频数据 - func encodePCMData(_ pcmData: Data) { - guard isEncodingActive, let encoder = jlOpusEncoder else { return } - guard !pcmData.isEmpty else { return } - - // 将新数据加入队列 - queueLock.lock() - pcmDataQueue.append(pcmData) - queueLock.unlock() - - // 异步处理队列中的数据 - processQueuedData() + + /// 写入左声道 mono PCM(本端翻译音频) + func writeLeftPcm(_ pcm: Data) { + feedPcm(pcm, channel: Self.CHANNEL_LEFT) } - - /// 处理队列中的PCM数据 - private func processQueuedData() { - guard isEncodingActive, let encoder = jlOpusEncoder else { - return - } - - // 使用专用队列进行异步处理 - if processingQueue == nil { - processingQueue = DispatchQueue(label: "com.opus.processing", qos: .userInitiated) - } - - processingQueue?.async { [weak self, weak encoder] in - guard let self = self, let encoder = encoder else { return } - - while self.isEncodingActive { - var chunkData: Data? - - self.queueLock.lock() - if self.pcmDataQueue.count >= self.chunkSize { - chunkData = self.pcmDataQueue.subdata(in: 0.. 0 { - let availableBytes = self.pcmDataQueue.count - let silentBytesNeeded = self.chunkSize - availableBytes - let silentData = Data(repeating: 0, count: silentBytesNeeded) - chunkData = Data() - chunkData?.append(self.pcmDataQueue) - chunkData?.append(silentData) - self.pcmDataQueue.removeAll() - } - self.queueLock.unlock() - if let dataToEncode = chunkData { - guard self.isEncodingActive else { break } - encoder.opusEncode(dataToEncode) + /// 写入右声道 mono PCM(对端翻译音频) + func writeRightPcm(_ pcm: Data) { + feedPcm(pcm, channel: Self.CHANNEL_RIGHT) + } + + /// 句尾补偿静音:一句翻译音频的真实 PCM 全部喂完后调用。 + /// 往指定声道编码器再补一段零 PCM,把编码器内滞留的不足一帧真实尾音顶出成整帧, + /// 避免耳机端句尾被截断;多出的静音帧一并编码下发,给解码/DAC pipeline 排空余量。 + /// - Parameters: + /// - channel: CHANNEL_LEFT / CHANNEL_RIGHT + /// - paddingMs: 补偿静音时长(ms),默认 400 + func feedTailSilence(channel: UInt8, paddingMs: Int = 400) { + guard isDualEncoding, paddingMs > 0 else { return } + let silence = Data(repeating: 0, count: paddingMs * Self.PCM_BYTES_PER_MS) + feedPcm(silence, channel: channel) + print("句尾补偿静音已喂入 channel=\(channel == Self.CHANNEL_LEFT ? "左" : "右") paddingMs=\(paddingMs) bytes=\(silence.count)") + } + + /// 把 PCM 切成整帧送进对应声道的编码器;不足一帧的尾巴留在 remainder,等下一段 PCM 拼接。 + /// 全程在该声道的串行队列上执行,保证「音频 → 句尾静音」的先后顺序不被打乱。 + private func feedPcm(_ pcm: Data, channel: UInt8) { + guard isDualEncoding, !pcm.isEmpty else { return } + let isLeft = (channel == Self.CHANNEL_LEFT) + let queue = isLeft ? leftEncodeQueue : rightEncodeQueue + + queue.async { [weak self] in + guard let self = self, self.isDualEncoding else { return } + guard let encoder = isLeft ? self.leftEncoder : self.rightEncoder else { return } + + if isLeft { self.leftPcmRemainder.append(pcm) } else { self.rightPcmRemainder.append(pcm) } + + while true { + guard self.isDualEncoding else { return } + let count = isLeft ? self.leftPcmRemainder.count : self.rightPcmRemainder.count + guard count >= self.chunkSize else { break } + + // Data.prefix 返回的切片 startIndex 非零,用 Data(...) 重新 base 到 0 再交给编码器 + let chunk: Data + if isLeft { + chunk = Data(self.leftPcmRemainder.prefix(self.chunkSize)) + self.leftPcmRemainder.removeFirst(self.chunkSize) } else { - break + chunk = Data(self.rightPcmRemainder.prefix(self.chunkSize)) + self.rightPcmRemainder.removeFirst(self.chunkSize) } + encoder.opusEncode(chunk) } } } - - /// 清空PCM数据队列 - private func clearPCMQueue() { - queueLock.lock() - pcmDataQueue.removeAll() - queueLock.unlock() - print("PCM数据队列已清空") + + /// 启动静音帧模板捕获:用独立编码器编码一段零 PCM,捕获输出首帧作为补齐用静音帧。 + private func startSilenceFrameCapture() { + guard let encoder = silenceEncoder else { return } + let zeros = Data(repeating: 0, count: Self.SILENCE_PCM_BYTES) + var offset = 0 + while offset + chunkSize <= zeros.count { + encoder.opusEncode(zeros.subdata(in: offset..<(offset + chunkSize))) + offset += chunkSize + } } - - + /// 清理所有资源 func cleanup() { stopDecoding() - stopEncoding() + stopDualEncodeStream() } } @@ -517,7 +546,8 @@ class SwiftOpusAudioProcessor: NSObject { @available(iOS 13.0, *) extension SwiftOpusAudioProcessor: JLOpusEncoderDelegate { - /// JLOpusEncoder编码回调 + /// JLOpusEncoder编码回调:按编码器实例分流到左/右声道,或捕获静音帧模板。 + /// 不切线程——直接在编码线程上回给 BleService 入队,保证同声道内帧序严格递增。 /// - Parameters: /// - encoder: 编码器实例 /// - data: 编码后的Opus数据 @@ -529,10 +559,20 @@ extension SwiftOpusAudioProcessor: JLOpusEncoderDelegate { } guard let opusData = data, !opusData.isEmpty else { return } - - // 回调编码后的数据到主线程 - DispatchQueue.main.async { [weak self] in - self?.callback?.onEncodedDataReceived(data: opusData) + + if encoder === silenceEncoder { + // 只取首帧作为静音模板,后续帧丢弃 + guard capturedSilenceFrame == nil else { return } + capturedSilenceFrame = opusData + print("静音帧模板已生成 size=\(opusData.count)B") + callback?.onSilenceFrameReady(frame: opusData) + return + } + + if encoder === leftEncoder { + callback?.onAudioFrameEncoded(channel: Self.CHANNEL_LEFT, frame: opusData) + } else if encoder === rightEncoder { + callback?.onAudioFrameEncoded(channel: Self.CHANNEL_RIGHT, frame: opusData) } } } diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart index ae77aec7a..b5eb4c3e8 100644 --- a/local_plugins/ble_service/lib/ble_service.dart +++ b/local_plugins/ble_service/lib/ble_service.dart @@ -175,30 +175,15 @@ class BleService { /// 设置通话翻译调试参数(运行时生效) /// [sendIntervalMs] 音频下行发送间隔(ms) /// [bundleFrameCount] 下行合包帧数 - /// [tier1FreeBytes]/[tier1SendMs] 第一档(最快):空余下限 / 发送间隔(ms) - /// [tier2FreeBytes]/[tier2SendMs] 第二档(中速):空余下限 / 发送间隔(ms) - /// [tier3FreeBytes]/[tier3SendMs] 第三档(最慢):空余下限(兜底) / 发送间隔(ms) Future setCallTranslationDebugParams({ int? sendIntervalMs, int? bundleFrameCount, - int? tier1FreeBytes, - int? tier1SendMs, - int? tier2FreeBytes, - int? tier2SendMs, - int? tier3FreeBytes, - int? tier3SendMs, }) async { try { final result = await _methodChannel .invokeMethod('setCallTranslationDebugParams', { if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs, if (bundleFrameCount != null) 'bundleFrameCount': bundleFrameCount, - if (tier1FreeBytes != null) 'tier1FreeBytes': tier1FreeBytes, - if (tier1SendMs != null) 'tier1SendMs': tier1SendMs, - if (tier2FreeBytes != null) 'tier2FreeBytes': tier2FreeBytes, - if (tier2SendMs != null) 'tier2SendMs': tier2SendMs, - if (tier3FreeBytes != null) 'tier3FreeBytes': tier3FreeBytes, - if (tier3SendMs != null) 'tier3SendMs': tier3SendMs, }); return result ?? false; } catch (e) { @@ -208,8 +193,7 @@ class BleService { } /// 获取当前通话翻译调试参数 - /// 返回 {'sendIntervalMs','bundleFrameCount','tier1FreeBytes','tier1SendMs', - /// 'tier2FreeBytes','tier2SendMs','tier3FreeBytes','tier3SendMs'},失败返回空 Map + /// 返回 {'sendIntervalMs','bundleFrameCount'},失败返回空 Map Future> getCallTranslationDebugParams() async { try { final result = await _methodChannel @@ -219,12 +203,6 @@ class BleService { return { 'sendIntervalMs': readInt('sendIntervalMs'), 'bundleFrameCount': readInt('bundleFrameCount'), - 'tier1FreeBytes': readInt('tier1FreeBytes'), - 'tier1SendMs': readInt('tier1SendMs'), - 'tier2FreeBytes': readInt('tier2FreeBytes'), - 'tier2SendMs': readInt('tier2SendMs'), - 'tier3FreeBytes': readInt('tier3FreeBytes'), - 'tier3SendMs': readInt('tier3SendMs'), }; } catch (e) { print('获取通话翻译调试参数失败: $e'); diff --git a/pubspec.yaml b/pubspec.yaml index 03287522c..edb2e1aec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: voitrans description: "Voitrans - AI Voice Assistant." publish_to: "none" -version: 1.0.30+105 +version: 1.0.32+111 environment: sdk: ">=3.3.0 <4.0.0"