From cfee5bd7662dd16e4747436e8336b3d3e60f3621 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Tue, 14 Jul 2026 16:11:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/ble_manager.dart | 11 +- .../call_translation_debug_controller.dart | 52 ++- .../views/call_translation_debug_view.dart | 50 ++- .../yunqiinnovation/ble_service/BleService.kt | 295 ++++++++++-------- .../ble_service/BleServicePlugin.kt | 3 +- .../Sources/ble_service/BleService.swift | 269 ++++++++++------ .../ble_service/SwiftBleServicePlugin.swift | 14 +- .../ble_service/lib/ble_service.dart | 15 +- 8 files changed, 463 insertions(+), 246 deletions(-) diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 790bd82a6..40e6f6ebe 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -1248,15 +1248,19 @@ class BleManager extends GetxService { } /// 设置通话翻译调试参数(运行时生效) - /// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数 + /// [sendIntervalMs] 音频下行发送间隔(ms) + /// [bundleFrameCount] 每包每声道的 opus 帧数(1..6),包长 = 2*帧数*40B + /// [continuousSend] 是否持续发送静音(无译音时也保持音频流不断) Future setCallTranslationDebugParams({ int? sendIntervalMs, int? bundleFrameCount, + bool? continuousSend, }) async { try { return await _bleService.setCallTranslationDebugParams( sendIntervalMs: sendIntervalMs, bundleFrameCount: bundleFrameCount, + continuousSend: continuousSend, ); } catch (e) { Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}'); @@ -1264,8 +1268,9 @@ class BleManager extends GetxService { } } - /// 获取当前通话翻译调试参数 {'sendIntervalMs': int, 'bundleFrameCount': int} - Future> getCallTranslationDebugParams() async { + /// 获取当前通话翻译调试参数 + /// {'sendIntervalMs': int, 'bundleFrameCount': int, 'continuousSend': bool} + Future> getCallTranslationDebugParams() async { try { return await _bleService.getCallTranslationDebugParams(); } catch (e) { 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 f5ad69687..32fe6d9ac 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,8 +5,11 @@ import '../../../data/services/ble_manager.dart'; import '../../../core/utils/logger.dart'; /// 通话翻译调试控制器 -/// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发, +/// 负责调试参数(音频下行发送间隔、每声道合包帧数、是否持续发送)的读取、保存、下发, /// 以及通话翻译编解码的启动/停止。 +/// +/// 下行包为无包头、左右帧级交替格式:[L0 40B][R0 40B]...[L(n-1)][R(n-1)],包长 = 2*n*40B。 +/// 每帧 opus 承载 20ms 音频,故「间隔 == 帧数 × 20ms」时下发速率与实时语速 1:1。 class CallTranslationDebugController extends GetxController { final BleManager _bleManager = Get.find(); final GetStorage _storage = GetStorage(); @@ -14,24 +17,39 @@ 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 _kContinuousSend = 'call_trans_debug_continuous_send'; - // 默认值(与原生默认保持一致) - static const int defaultSendIntervalMs = 100; - static const int defaultBundleFrameCount = 5; + // 单帧 opus 承载的音频时长(ms),用于推算与实时语速匹配的发送间隔 + static const int frameDurationMs = 20; + + // 默认值(与原生默认保持一致:2 帧/声道 + 40ms 一拍 = 实时 1:1) + static const int defaultSendIntervalMs = 40; + static const int defaultBundleFrameCount = 2; + static const bool defaultContinuousSend = false; // 取值范围 static const int minSendIntervalMs = 20; static const int maxSendIntervalMs = 1000; static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms static const int minBundleFrameCount = 1; - static const int maxBundleFrameCount = 20; + // 包长 2*n*40B 必须塞得进一次 BLE 写(MTU 517 → 可写 514B),6 帧=480B 是安全上限 + static const int maxBundleFrameCount = 6; /// 音频下行发送间隔(ms) final RxInt sendIntervalMs = defaultSendIntervalMs.obs; - /// 下行合包帧数 + /// 每包每声道的 opus 帧数 final RxInt bundleFrameCount = defaultBundleFrameCount.obs; + /// 是否持续发送音频(无译音时也按拍下发全静音包,保持设备侧音频流不断) + final RxBool continuousSend = defaultContinuousSend.obs; + + /// 当前配置下的下行包长(字节):左右交替、每声道 n 帧、每帧 40B + int get packetSizeBytes => 2 * bundleFrameCount.value * 40; + + /// 与实时语速 1:1 匹配的发送间隔(ms) = 帧数 × 20ms;供界面提示用 + int get realtimeIntervalMs => bundleFrameCount.value * frameDurationMs; + /// 通话翻译编解码是否已开启 RxBool get isCodecActive => _bleManager.isCodecActiveRx; @@ -50,6 +68,7 @@ class CallTranslationDebugController extends GetxController { void _loadFromStorage() { final savedInterval = _storage.read(_kSendInterval); final savedFrames = _storage.read(_kBundleFrameCount); + final savedContinuous = _storage.read(_kContinuousSend); if (savedInterval != null) { sendIntervalMs.value = savedInterval.clamp(minSendIntervalMs, maxSendIntervalMs); @@ -58,6 +77,9 @@ class CallTranslationDebugController extends GetxController { bundleFrameCount.value = savedFrames.clamp(minBundleFrameCount, maxBundleFrameCount); } + if (savedContinuous != null) { + continuousSend.value = savedContinuous; + } } /// 用原生当前实际值同步显示(仅在本地未保存过该项时采用原生返回值) @@ -66,18 +88,22 @@ class CallTranslationDebugController extends GetxController { final params = await _bleManager.getCallTranslationDebugParams(); if (params.isEmpty) return; if (_storage.read(_kSendInterval) == null) { - final ni = params['sendIntervalMs']; + final ni = params['sendIntervalMs'] as int?; if (ni != null && ni > 0) { sendIntervalMs.value = ni.clamp(minSendIntervalMs, maxSendIntervalMs); } } if (_storage.read(_kBundleFrameCount) == null) { - final nf = params['bundleFrameCount']; + final nf = params['bundleFrameCount'] as int?; if (nf != null && nf > 0) { bundleFrameCount.value = nf.clamp(minBundleFrameCount, maxBundleFrameCount); } } + if (_storage.read(_kContinuousSend) == null) { + final nc = params['continuousSend'] as bool?; + if (nc != null) continuousSend.value = nc; + } } catch (e) { Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $e'); } @@ -93,22 +119,30 @@ class CallTranslationDebugController extends GetxController { value.clamp(minBundleFrameCount, maxBundleFrameCount); } + void setContinuousSend(bool value) { + continuousSend.value = value; + } + /// 恢复默认值 void resetDefaults() { sendIntervalMs.value = defaultSendIntervalMs; bundleFrameCount.value = defaultBundleFrameCount; + continuousSend.value = defaultContinuousSend; } /// 保存并下发参数到原生(运行时立即生效) Future applyParams() async { _storage.write(_kSendInterval, sendIntervalMs.value); _storage.write(_kBundleFrameCount, bundleFrameCount.value); + _storage.write(_kContinuousSend, continuousSend.value); final ok = await _bleManager.setCallTranslationDebugParams( sendIntervalMs: sendIntervalMs.value, bundleFrameCount: bundleFrameCount.value, + continuousSend: continuousSend.value, ); Logger.i('CALL_TRANS_DEBUG', - '下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} result=$ok'); + '下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value}/声道 ' + '包长=${packetSizeBytes}B 持续发送=${continuousSend.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 7221bf98b..a22f974f9 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 @@ -5,7 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../controllers/call_translation_debug_controller.dart'; /// 通话翻译调试界面 -/// 可调整音频下行发送间隔与下行合包帧数,应用后启动/停止通话翻译。 +/// 可调整音频下行发送间隔、每声道合包帧数与持续发送开关,应用后启动/停止通话翻译。 class CallTranslationDebugView extends GetView { const CallTranslationDebugView({Key? key}) : super(key: key); @@ -36,6 +36,8 @@ class CallTranslationDebugView _buildSendIntervalCard(isDark), SizedBox(height: 16.h), _buildBundleFrameCard(isDark), + SizedBox(height: 16.h), + _buildContinuousSendCard(isDark), SizedBox(height: 24.h), _buildActionButtons(isDark), ], @@ -151,15 +153,15 @@ class CallTranslationDebugView ); } - // 下行合包帧数 + // 每包每声道帧数 Widget _buildBundleFrameCard(bool isDark) { return _card( isDark, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _title(isDark, '下行合包帧数', - '左右声道各自累积多少帧 opus 合并为一包后下发,值越大单包越大、发送频率越低(${CallTranslationDebugController.minBundleFrameCount}~${CallTranslationDebugController.maxBundleFrameCount} 帧)'), + _title(isDark, '每声道合包帧数', + '一包里左右声道各放多少帧 opus,按 左/右/左/右 交替写入、无包头(${CallTranslationDebugController.minBundleFrameCount}~${CallTranslationDebugController.maxBundleFrameCount} 帧)'), SizedBox(height: 8.h), Obx( () => Row( @@ -188,11 +190,51 @@ class CallTranslationDebugView ], ), ), + SizedBox(height: 4.h), + // 包长与实时节拍提示:每帧 20ms,间隔 == 帧数×20ms 时下发速率与语速 1:1 + Obx( + () => Text( + '包长 ${controller.packetSizeBytes} 字节(左右各 ${controller.bundleFrameCount.value} 帧 × 40B)' + ',与实时语速匹配的间隔为 ${controller.realtimeIntervalMs} ms' + '${controller.sendIntervalMs.value == controller.realtimeIntervalMs ? '(当前已匹配)' : '(当前 ${controller.sendIntervalMs.value} ms,' + '${controller.sendIntervalMs.value > controller.realtimeIntervalMs ? '偏慢,音频会积压' : '偏快,会超发'})'}', + style: TextStyle( + fontSize: 11.sp, + color: controller.sendIntervalMs.value == + controller.realtimeIntervalMs + ? (isDark ? Colors.white38 : Colors.black38) + : Colors.orange, + height: 1.3, + ), + ), + ), ], ), ); } + // 持续发送音频开关 + Widget _buildContinuousSendCard(bool isDark) { + return _card( + isDark, + child: Obx( + () => Row( + children: [ + Expanded( + child: _title(isDark, '持续发送音频', + '开启后即使没有译音,也按节拍持续下发静音帧,保持设备侧音频流不断;关闭则左右都没数据时跳过不发'), + ), + SizedBox(width: 12.w), + Switch( + value: controller.continuousSend.value, + onChanged: controller.setContinuousSend, + ), + ], + ), + ), + ); + } + Widget _title(bool isDark, String title, String subtitle) { return Column( crossAxisAlignment: CrossAxisAlignment.start, 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 9f2bb64cc..999d30548 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,25 +133,23 @@ object BleService { // 音频数据缓存 //private val audioDataBuffer = mutableListOf() - // 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按拍下发。 - // 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。 + // 音频数据发送相关:左右声道各一条缓存队列,共用**一条**发送线程,每拍从左右队列各取 N 帧交替合成一包下发。 + // 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=发送线程)。 private val leftSendBuffer = LinkedBlockingDeque() private val rightSendBuffer = LinkedBlockingDeque() - private var leftSendThread: Thread? = null - private var rightSendThread: Thread? = null - // 两线程写的是同一个 callWriteChar(共享可变 .value)+同一 gatt,真正的写必须串行化,否则并发写会互相覆盖发错数据。 + private var audioSendThread: Thread? = null + // 写的是同一个 callWriteChar(共享可变 .value)+同一 gatt,写必须串行化;单发送线程下仍保留,防其他路径并发写。 private val bleWriteLock = Any() - // 右声道发送线程相对左声道错开启动的毫秒数,避免两路同一时刻抢 BLE 写、平摊链路占用。 - private const val RIGHT_SEND_THREAD_STAGGER_MS = 40L private val audioSendHandler = Handler(Looper.getMainLooper()) private val isAudioSending = AtomicBoolean(false) // 上一包音频实际发送的时间戳(ms),仅用于发送节拍日志统计间隔;-1 表示尚未发送过 private var lastAudioSendTime = -1L - // 左/右声道各自上一包已发送的序号,用于在日志里核对每个声道的包序是否连续(+1);-1 表示尚未发送过 - private var lastLeftSeq = -1L - private var lastRightSeq = -1L // 下行写入连续失败(底层发送缓冲拥塞)的计数,用于节流告警与恢复提示;写成功即清零 private var writeFailRetryCount = 0 + // 因静音帧模板尚未就绪而暂缓下发的连续拍数,用于节流告警;一旦模板就绪即清零 + private var silenceNotReadyCount = 0 + // 持续发送模式下连续发出的纯静音包数,仅用于日志节流;一旦有真实译音即清零 + private var silentPacketCount = 0 // 延迟追踪:右声道(对方译音)每句首包写到 BLE 的时刻,配合 azure_speech 侧 [LAT-TRACE] 点1/点2 // 相减即得 app 内部"收到译音→写到耳机"耗时;间隔>700ms 视为新一句,避免逐包刷屏 private var latLastBleRightTs = 0L @@ -199,23 +197,33 @@ object BleService { // 音频数据分块发送的常量 private val AUDIO_CHUNK_SIZE = 120 // 每次发送120字节 - private const val DEFAULT_AUDIO_SEND_INTERVAL_NORMAL = 80L // 默认音频下行发送间隔(ms) + // 默认音频下行发送间隔(ms):一拍发 bundleFrameCount 帧/声道,每帧 20ms 音频, + // 故 间隔 == bundleFrameCount * 20ms 时下发速率与实时语速 1:1(默认 2 帧 → 40ms)。 + private const val DEFAULT_AUDIO_SEND_INTERVAL_NORMAL = 40L // 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改 @Volatile private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL - // 声道缓存安全上限(纯防 OOM 兜底):现在缓存元素是单帧 opus,超限丢最旧帧并告警。 + // 声道缓存安全上限(纯防 OOM 兜底):缓存元素是单帧 opus,超限丢最旧帧并告警。 private const val CHANNEL_HOLD_BUFFER_MAX = 10000 - // ---- 下行合包(已从 OpusAudioManager 上移到 BleService) ---- - // 每包合并的 opus 帧数(默认5,调试界面可调 1..20):发送线程每拍从声道队列取最多 bundleFrameCount 帧, - // 不足则用静音帧补齐、队列空则不发;组包布局 [4B 序号(小端)] + [1B 声道] + [N × opus 帧]。 + + // ---- 下行合包:左右交替、无包头 ---- + // 单帧 opus 固定长度(编码器 CBR packetSize=40B)。无包头协议靠固定帧长切分,长度必须严格恒定, + // 否则设备侧整包错位 —— 故入队前校验帧长,非 40B 一律丢弃。 + private const val OPUS_FRAME_BYTES = 40 + // 每包每声道的 opus 帧数(默认2,调试界面可调 1..MAX_BUNDLE_FRAME_COUNT)。 + // 组包布局(无包头):[L0 40B][R0 40B][L1 40B][R1 40B]...[L(n-1)][R(n-1)],包长恒为 2*n*40B。 + @Volatile + private var bundleFrameCount = 2 + // 每包帧数上限:包长 2*n*40 必须塞得进一次 BLE 写(MTU 517 → 可写 514B),6 帧=480B 已是安全上限。 + private const val MAX_BUNDLE_FRAME_COUNT = 6 + // 持续发送开关:为 true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断流; + // 为 false 时两侧皆空则跳过该拍不发(默认,省链路占用)。 @Volatile - private var bundleFrameCount = 5 - // 合包头部字节数:bytes0-3 序号(小端 uint32) + byte4 声道 - private const val BUNDLE_HEADER_SIZE = 5 - // 左右声道各自的下行包序号(每成功发一包自增),供设备侧核对连续性;仅各自声道线程访问 - private var leftPacketSeq = 0 - private var rightPacketSeq = 0 - // 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null + private var continuousSendEnabled = false + // 下行包序号(每成功发一包自增),无包头协议下不写入包内,仅用于日志核对节拍连续性 + private var packetSeq = 0 + // 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null。 + // 无包头协议要求包长恒定,故它未就绪时无法补齐,整拍不发。 @Volatile private var silenceFrame: ByteArray? = null // 音频数据缓冲区,用于累积数据到80字节再发送 @@ -349,12 +357,17 @@ object BleService { } override fun onAudioFrameEncoded(channelPrefix: Byte, frame: ByteArray) { - // 单帧入对应声道队列,由发送线程按拍取 bundleFrameCount 帧合包下发 + // 单帧入对应声道队列,由发送线程按拍取左右各 bundleFrameCount 帧交替合包下发 enqueueOpusFrame(channelPrefix, frame) } override fun onSilenceFrameReady(frame: ByteArray) { - // 缓存静音帧模板,用于合包时不足帧补齐 + // 缓存静音帧模板,用于合包时不足帧补齐。 + // 无包头协议按固定 40B 切分,模板长度不对会让整包左右对位全错,故只接受恰好 OPUS_FRAME_BYTES 的模板 + if (frame.size != OPUS_FRAME_BYTES) { + CallLog.w(TAG, "静音帧模板长度异常 size=${frame.size}B (期望 ${OPUS_FRAME_BYTES}B),拒绝采用") + return + } silenceFrame = frame } @@ -1492,12 +1505,12 @@ object BleService { /** * 开始Opus数据流编码(通话翻译模式:左右声道独立编码) - * 输出合包格式:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B + * 输出下行包格式(无包头,左右交替):[L0 40B][R0 40B]...[L(n-1)][R(n-1)],n=bundleFrameCount(默认2 → 160B) * @return 是否成功开始编码 */ private fun startOpusEncodeStream(): Boolean { startAudioSendThread() - return opusAudioManager.startDualEncodeStream(false, 16000, 40) + return opusAudioManager.startDualEncodeStream(false, 16000, OPUS_FRAME_BYTES) } /** @@ -1513,13 +1526,20 @@ object BleService { } /** - * 将一帧 opus 按声道加入对应下行队列。合包(取 N 帧/补静音/组头)在发送线程按拍完成。 + * 将一帧 opus 按声道加入对应下行队列。合包(左右各取 N 帧/交替拼接/补静音)在发送线程按拍完成。 * 重连窗口内不丢弃:继续入队暂存,回连成功后续发;仅超 OOM 兜底上限才丢最旧帧。 + * + * 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错, + * 故非 OPUS_FRAME_BYTES 的帧在入队处直接拦掉,保证队列里的帧长恒定。 * @param channelPrefix 声道标识(CHANNEL_LEFT=0 / CHANNEL_RIGHT=1) * @param frame 一帧 opus 编码数据 */ private fun enqueueOpusFrame(channelPrefix: Byte, frame: ByteArray) { if (frame.isEmpty()) return + if (frame.size != OPUS_FRAME_BYTES) { + CallLog.w(TAG, "丢弃异常长度 opus 帧 ch=$channelPrefix size=${frame.size}B (期望 ${OPUS_FRAME_BYTES}B)") + return + } val buffer = when (channelPrefix) { OpusAudioManager.CHANNEL_RIGHT -> rightSendBuffer // 左声道及未知声道统一归入左队列 @@ -1534,43 +1554,33 @@ object BleService { /** - * 启动左右两条独立发送线程(各发一路声道),右声道相对左声道错开 RIGHT_SEND_THREAD_STAGGER_MS 启动, - * 避免两路同一时刻抢 BLE 写。每条线程按固定拍长下发,互不影响。 + * 启动下行发送线程(单线程,一包同时承载左右两路)。 + * 每拍从左右队列各取最多 bundleFrameCount 帧,按 L/R 交替拼成一个无包头包下发。 */ private fun startAudioSendThread() { - if (leftSendThread?.isAlive == true || rightSendThread?.isAlive == true) { + if (audioSendThread?.isAlive == true) { Log.d(TAG, "音频发送线程已在运行") return } isAudioSending.set(true) - leftSendThread = createChannelSendThread(isLeft = true, startOffsetMs = 0L).apply { start() } - rightSendThread = createChannelSendThread(isLeft = false, startOffsetMs = RIGHT_SEND_THREAD_STAGGER_MS).apply { start() } - CallLog.i(TAG, "音频发送线程已启动(左/右双线程,右错开 ${RIGHT_SEND_THREAD_STAGGER_MS}ms)") + audioSendThread = createAudioSendThread().apply { start() } + val n = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT) + CallLog.i(TAG, "音频发送线程已启动(左右合包/无包头) 间隔=${audioSendIntervalNormal}ms " + + "帧数=$n/声道 包长=${2 * n * OPUS_FRAME_BYTES}B 持续发送=$continuousSendEnabled") } /** - * 构建单声道发送线程:固定 audioSendIntervalNormal 一拍(睡到绝对时刻,不随耗时抖动), - * 每拍从该声道缓存取队首一包下发。 + * 构建下行发送线程:固定 audioSendIntervalNormal 一拍(睡到绝对时刻,不随耗时抖动),每拍下发一包左右合成音频。 * 重连暂停期间只跳过下发、不清缓存(译音继续积压待回连续发)。 - * @param isLeft true=左声道 / false=右声道 - * @param startOffsetMs 相对启动时刻的错开毫秒(右声道用来与左声道错峰) */ - private fun createChannelSendThread(isLeft: Boolean, startOffsetMs: Long): Thread { - val chName = if (isLeft) "L" else "R" + private fun createAudioSendThread(): Thread { return Thread { - CallLog.i(TAG, "音频发送线程[$chName]已启动 offset=${startOffsetMs}ms") - var nextTickAt = System.currentTimeMillis() + startOffsetMs - // 首拍错开:右线程先睡 startOffsetMs 再进入循环,使两路发送时刻相互错峰 - val initDelay = nextTickAt - System.currentTimeMillis() - if (initDelay > 0) { - try { Thread.sleep(initDelay) } catch (e: InterruptedException) { return@Thread } - } + var nextTickAt = System.currentTimeMillis() while (isAudioSending.get() && !Thread.currentThread().isInterrupted) { try { // 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发) if (!isDownlinkPaused) { - val buffer = if (isLeft) leftSendBuffer else rightSendBuffer - if (buffer.isNotEmpty()) trySendChannelChunk(isLeft) + trySendMixedChunk() } // 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动 nextTickAt += audioSendIntervalNormal @@ -1582,55 +1592,62 @@ object BleService { nextTickAt = System.currentTimeMillis() } } catch (e: InterruptedException) { - Log.d(TAG, "音频发送线程[$chName]被中断") + Log.d(TAG, "音频发送线程被中断") break } catch (e: Exception) { - Log.e(TAG, "音频发送线程[$chName]异常: ${e.message}", e) + Log.e(TAG, "音频发送线程异常: ${e.message}", e) } } - CallLog.i(TAG, "音频发送线程[$chName]已停止") - }.apply { name = "AudioSendThread-$chName" } + CallLog.i(TAG, "音频发送线程已停止") + }.apply { name = "AudioSendThread" } } /** - * 从指定声道队列取最多 bundleFrameCount 帧,不足用静音帧补齐,组包后下发。仅在该声道发送线程内调用。 - * - 队列空:不发,返回 false; - * - 成功:写调试录音、打印发送日志、推进该声道包序,返回 true; - * - 失败(拥塞):取出的真实帧按原序放回队头(静音补齐帧丢弃、下拍重组),返回 false,下一拍重试,绝不丢帧。 + * 一拍下发一包:左右队列各取最多 bundleFrameCount 帧,按 L0 R0 L1 R1 ... 交替拼成无包头包下发。 + * - 某一路帧不足(含一帧没有):缺的位用静音帧补齐,保证包长恒定、左右严格对位; + * - 两路皆空:continuousSendEnabled=true 时发全静音包保持设备侧音频流不断,否则跳过本拍不发; + * - 静音帧模板未就绪:包长无法恒定 → 本拍不发,已取出的帧原样退回队头(仅编码器启动初期的极短窗口); + * - 写失败(拥塞):取出的真实帧按原序退回各自队头,静音补齐帧丢弃,下一拍重组重试,绝不丢帧。 + * @return 是否成功发出一包 */ - private fun trySendChannelChunk(isLeft: Boolean): Boolean { - val buffer = if (isLeft) leftSendBuffer else rightSendBuffer - val n = bundleFrameCount.coerceIn(1, 20) - // 取最多 n 帧真实数据 - val realFrames = ArrayList(n) - while (realFrames.size < n) { - val f = buffer.pollFirst() ?: break - realFrames.add(f) - } - if (realFrames.isEmpty()) return false // 队列空则不发 + private fun trySendMixedChunk(): Boolean { + val n = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT) + // 左右各取最多 n 帧真实数据 + val leftFrames = ArrayList(n) + while (leftFrames.size < n) leftFrames.add(leftSendBuffer.pollFirst() ?: break) + val rightFrames = ArrayList(n) + while (rightFrames.size < n) rightFrames.add(rightSendBuffer.pollFirst() ?: break) - val channel = if (isLeft) OpusAudioManager.CHANNEL_LEFT else OpusAudioManager.CHANNEL_RIGHT - val chTag = if (isLeft) "L" else "R" - val seq = if (isLeft) leftPacketSeq else rightPacketSeq + val hasRealAudio = leftFrames.isNotEmpty() || rightFrames.isNotEmpty() + // 两路皆空且未开持续发送:本拍不发 + if (!hasRealAudio && !continuousSendEnabled) return false - // 组包帧 = 真实帧 + 静音帧补齐到 n(静音帧未就绪时只发真实帧数,罕见的启动窗口) - val frames = ArrayList(n) - frames.addAll(realFrames) + // 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪则本拍放弃,帧退回队列不丢 val silence = silenceFrame - var padCount = 0 - if (silence != null) { - while (frames.size < n) { frames.add(silence); padCount++ } + if (silence == null) { + for (i in leftFrames.indices.reversed()) leftSendBuffer.offerFirst(leftFrames[i]) + for (i in rightFrames.indices.reversed()) rightSendBuffer.offerFirst(rightFrames[i]) + silenceNotReadyCount++ + if (silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0) { + CallLog.w(TAG, "静音帧模板未就绪,暂缓下发(累计 $silenceNotReadyCount 拍),译音已退回队列不丢") + } + return false } - val packet = buildBundlePacket(seq, channel, frames) + silenceNotReadyCount = 0 + + val padLeft = n - leftFrames.size + val padRight = n - rightFrames.size + val packet = buildInterleavedPacket(n, leftFrames, rightFrames, silence) // 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 val sent = sendAudioChunkBlocking(packet) if (!sent) { - // 写失败:真实帧按原顺序放回队头(倒序 offerFirst),下一拍重试;静音补齐帧丢弃,下次按新帧重组 - for (i in realFrames.indices.reversed()) buffer.offerFirst(realFrames[i]) + // 写失败:真实帧按原顺序退回各自队头(倒序 offerFirst),下一拍重组重试;静音补齐帧丢弃 + for (i in leftFrames.indices.reversed()) leftSendBuffer.offerFirst(leftFrames[i]) + for (i in rightFrames.indices.reversed()) rightSendBuffer.offerFirst(rightFrames[i]) writeFailRetryCount++ if (writeFailRetryCount == 1 || writeFailRetryCount % 50 == 0) { - CallLog.w(TAG, "下行写入拥塞,重试中 ch=$chTag seq=$seq 连续失败=$writeFailRetryCount " + + CallLog.w(TAG, "下行写入拥塞,重试中 seq=$packetSeq 连续失败=$writeFailRetryCount " + "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") } return false @@ -1639,40 +1656,30 @@ object BleService { CallLog.i(TAG, "下行写入已恢复,之前连续失败=$writeFailRetryCount") writeFailRetryCount = 0 } - // 成功后推进该声道包序 - if (isLeft) leftPacketSeq++ else rightPacketSeq++ + val seq = packetSeq++ - // 调试录音:剥离 5 字节包头后按声道写入(含静音补齐部分,听辨即静音) - if (packet.size > BUNDLE_HEADER_SIZE) { - val opusPayload = packet.copyOfRange(BUNDLE_HEADER_SIZE, packet.size) - when (channel) { - OpusAudioManager.CHANNEL_LEFT -> recordfile1Left?.saveAudioDataToWav(opusPayload) - OpusAudioManager.CHANNEL_RIGHT -> recordfile1Right?.saveAudioDataToWav(opusPayload) - } - } + // 调试录音:按声道各自落地(真实帧+静音补齐,与该路实际下发内容一致,听辨即静音) + recordfile1Left?.saveAudioDataToWav(padToFrames(n, leftFrames, silence)) + recordfile1Right?.saveAudioDataToWav(padToFrames(n, rightFrames, silence)) - // 发送节拍统计与包序日志 + // 发送节拍统计与包序日志;持续发送的纯静音包按 25 拍节流,避免刷屏 val now = System.currentTimeMillis() val deltaMs = if (lastAudioSendTime < 0) 0 else now - lastAudioSendTime lastAudioSendTime = now - val seqGap = when (channel) { - OpusAudioManager.CHANNEL_LEFT -> { - val gap = if (lastLeftSeq < 0) 1L else seq.toLong() - lastLeftSeq - lastLeftSeq = seq.toLong(); gap - } - OpusAudioManager.CHANNEL_RIGHT -> { - val gap = if (lastRightSeq < 0) 1L else seq.toLong() - lastRightSeq - lastRightSeq = seq.toLong(); gap + if (hasRealAudio) { + silentPacketCount = 0 + CallLog.i(TAG, "音频下行发送 seq=$seq Δ=${deltaMs}ms L=${leftFrames.size}+静音$padLeft " + + "R=${rightFrames.size}+静音$padRight size=${packet.size}B " + + "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") + } else { + silentPacketCount++ + if (silentPacketCount % 25 == 1) { + CallLog.i(TAG, "音频下行持续发送(全静音) seq=$seq Δ=${deltaMs}ms size=${packet.size}B 累计=$silentPacketCount 包") } - else -> 0L } - 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 " + - "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") // [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包) - if (channel == OpusAudioManager.CHANNEL_RIGHT) { + if (rightFrames.isNotEmpty()) { if (now - latLastBleRightTs > 700) { CallLog.i(TAG, "[LAT-TRACE] 3.译音写到BLE(右/对方) ts=$now seq=$seq") } @@ -1682,34 +1689,46 @@ object BleService { } /** - * 组下行包:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [N 帧 opus 顺次拼接] + * 组下行包(无包头,左右帧级交替):[L0][R0][L1][R1]...[L(n-1)][R(n-1)] + * 每帧恒为 OPUS_FRAME_BYTES 字节,包长恒为 2*n*OPUS_FRAME_BYTES;某侧该位缺帧则填静音帧,保证左右严格对位。 */ - private fun buildBundlePacket(seq: Int, channel: Byte, frames: List): ByteArray { - var payloadSize = 0 - for (f in frames) payloadSize += f.size - val packet = ByteArray(BUNDLE_HEADER_SIZE + payloadSize) - packet[0] = (seq and 0xFF).toByte() - packet[1] = (seq ushr 8 and 0xFF).toByte() - packet[2] = (seq ushr 16 and 0xFF).toByte() - packet[3] = (seq ushr 24 and 0xFF).toByte() - packet[4] = channel - var offset = BUNDLE_HEADER_SIZE - for (f in frames) { - System.arraycopy(f, 0, packet, offset, f.size) - offset += f.size + private fun buildInterleavedPacket( + n: Int, + leftFrames: List, + rightFrames: List, + silence: ByteArray + ): ByteArray { + val packet = ByteArray(2 * n * OPUS_FRAME_BYTES) + var offset = 0 + for (i in 0 until n) { + System.arraycopy(leftFrames.getOrNull(i) ?: silence, 0, packet, offset, OPUS_FRAME_BYTES) + offset += OPUS_FRAME_BYTES + System.arraycopy(rightFrames.getOrNull(i) ?: silence, 0, packet, offset, OPUS_FRAME_BYTES) + offset += OPUS_FRAME_BYTES } return packet } + /** 把某一路的真实帧补静音至 n 帧后顺次拼接(仅供调试录音落地,内容与该路实际下发的一致) */ + private fun padToFrames(n: Int, frames: List, silence: ByteArray): ByteArray { + val out = ByteArray(n * OPUS_FRAME_BYTES) + var offset = 0 + for (i in 0 until n) { + System.arraycopy(frames.getOrNull(i) ?: silence, 0, out, offset, OPUS_FRAME_BYTES) + offset += OPUS_FRAME_BYTES + } + return out + } + /** - * 停止左右两条音频发送线程并清空缓存 + * 停止音频发送线程并清空缓存 */ private fun stopAudioSendThread() { isAudioSending.set(false) - leftSendThread?.interrupt() - rightSendThread?.interrupt() + audioSendThread?.interrupt() + audioSendThread = null resetDownlinkBuffers() - CallLog.i(TAG, "音频发送线程(左/右)已停止,队列和缓冲区已清空") + CallLog.i(TAG, "音频发送线程已停止,队列和缓冲区已清空") } /** @@ -1720,12 +1739,11 @@ object BleService { leftSendBuffer.clear() // 清空左声道下行缓存队列 rightSendBuffer.clear() // 清空右声道下行缓存队列 audioBuffer.clear() // 清空音频缓冲区 - leftPacketSeq = 0 // 复位左右声道下行包序号 - rightPacketSeq = 0 + packetSeq = 0 // 复位下行包序号 lastAudioSendTime = -1L // 重置发送节拍计时,下次首包 Δ 从 0 开始 - lastLeftSeq = -1L // 重置左右声道包序追踪 - lastRightSeq = -1L writeFailRetryCount = 0 // 重置写入拥塞计数 + silenceNotReadyCount = 0 + silentPacketCount = 0 latLastBleRightTs = 0L // 重置延迟追踪计时 } @@ -1991,30 +2009,43 @@ object BleService { /** * 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。 * @param sendIntervalMs 音频下行发送间隔(ms) - * @param bundleFrameCount 下行合包帧数 + * @param bundleFrameCount 每包每声道的 opus 帧数(1..MAX_BUNDLE_FRAME_COUNT),包长 = 2*n*40B + * @param continuousSend 是否持续发送:true 时无译音也按拍下发全静音包,保持设备侧音频流不断 */ fun setCallTranslationDebugParams( sendIntervalMs: Long?, - bundleFrameCount: Int? + bundleFrameCount: Int?, + continuousSend: Boolean? ) { if (sendIntervalMs != null && sendIntervalMs > 0) { audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L) Log.i(TAG, "[CALL_TRANS_DEBUG] 设置音频下行发送间隔: ${audioSendIntervalNormal}ms") } if (bundleFrameCount != null && bundleFrameCount > 0) { - this.bundleFrameCount = bundleFrameCount.coerceIn(1, 20) - Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${this.bundleFrameCount}") + this.bundleFrameCount = bundleFrameCount.coerceIn(1, MAX_BUNDLE_FRAME_COUNT) + val packetSize = 2 * this.bundleFrameCount * OPUS_FRAME_BYTES + Log.i(TAG, "[CALL_TRANS_DEBUG] 设置每声道合包帧数: ${this.bundleFrameCount} (包长 ${packetSize}B)") + // 一次 BLE 写的净荷上限是 MTU-3,超了底层会写失败/截断,这里提前告警。 + // 仅在 MTU 已协商时才判(未连接时 currentMtu 还是默认 23,判了必然误报) + if (currentMtu > 23 && packetSize > currentMtu - 3) { + CallLog.w(TAG, "[CALL_TRANS_DEBUG] 包长 ${packetSize}B 超出当前 MTU 可写上限(${currentMtu - 3}B),下行会写失败") + } + } + if (continuousSend != null) { + continuousSendEnabled = continuousSend + Log.i(TAG, "[CALL_TRANS_DEBUG] 设置持续发送音频: $continuousSendEnabled") } } /** * 获取当前通话翻译调试参数。 - * @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) + * @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) / continuousSend(Boolean) */ fun getCallTranslationDebugParams(): Map { return mapOf( "sendIntervalMs" to audioSendIntervalNormal, - "bundleFrameCount" to bundleFrameCount + "bundleFrameCount" to bundleFrameCount, + "continuousSend" to continuousSendEnabled ) } 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 4ce100c69..43b0affd9 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,7 +249,8 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, "setCallTranslationDebugParams" -> { val sendIntervalMs = (call.argument("sendIntervalMs"))?.toLong() val bundleFrameCount = (call.argument("bundleFrameCount"))?.toInt() - BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount) + val continuousSend = call.argument("continuousSend") + BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount, continuousSend) result.success(true) } "getCallTranslationDebugParams" -> { 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 5b01edd44..0ef8bc467 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 @@ -110,32 +110,40 @@ public class BleService: NSObject { private var frameSize: Int32 = 640 // MARK: - 音频下行发送(与 Android BleService 对齐) - // 左右声道各一条缓存队列 + 一条独立发送定时器,右声道错开 RIGHT_SEND_STAGGER 启动,避免两路同时抢 BLE 写。 - // 队列元素是单帧 opus(40B);发送线程每拍取最多 BUNDLE_FRAME_COUNT 帧,不足用静音帧补齐后组包下发。 + // 左右声道各一条缓存队列,共用**一条**发送定时器:每拍从左右队列各取 N 帧, + // 按 [L0][R0][L1][R1]... 帧级交替拼成一个无包头包下发;某侧不足的位用静音帧补齐。 + // 队列元素是单帧 opus(恒 OPUS_FRAME_BYTES=40B)。 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 var audioSendTimer: DispatchSourceTimer? + private let audioSendQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.audio_send", qos: .userInitiated) + /// 写同一个 callWriteCharacteristic + 同一 peripheral,写入必须串行化 private let bleWriteLock = NSLock() - /// 以下几个状态跨「左发送队列 / 右发送队列 / 编码回调线程 / 调用线程」共享, + /// 以下几个状态跨「发送队列 / 编码回调线程 / 调用线程」共享, /// 对应 Android 的 @Volatile 字段,统一用 sendStateLock 保护。 private let sendStateLock = NSLock() private var _isAudioSending = false - /// 补齐用静音 opus 帧模板(编码器启动后异步送来);未就绪时为 nil,此时只发真实帧 + /// 补齐用静音 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 + /// 每包每声道的 opus 帧数(1..MAX_BUNDLE_FRAME_COUNT),运行时可调 + private var _bundleFrameCount = 2 + /// 音频下行发送间隔(秒),运行时可调 + private var _audioSendInterval: TimeInterval = 0.04 + /// 持续发送开关:true 时即使左右队列都空也按拍发全静音包,保持设备侧音频流不断 + private var _continuousSendEnabled = false + + /// 下行包序号(每成功发一包自增);无包头协议下不写入包内,仅用于日志核对节拍连续性。 + /// 只被发送队列访问,无需加锁。 + private var packetSeq: UInt32 = 0 + /// 因静音帧模板未就绪而暂缓下发的连续拍数 / 持续发送模式下连续发出的纯静音包数,均仅用于日志节流 + private var silenceNotReadyCount = 0 + private var silentPacketCount = 0 private var isAudioSending: Bool { get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _isAudioSending } @@ -145,12 +153,24 @@ public class BleService: NSObject { get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _silenceFrame } set { sendStateLock.lock(); _silenceFrame = newValue; sendStateLock.unlock() } } + private var bundleFrameCount: Int { + get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _bundleFrameCount } + set { sendStateLock.lock(); _bundleFrameCount = newValue; sendStateLock.unlock() } + } + private var audioSendInterval: TimeInterval { + get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _audioSendInterval } + set { sendStateLock.lock(); _audioSendInterval = newValue; sendStateLock.unlock() } + } + private var continuousSendEnabled: Bool { + get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _continuousSendEnabled } + set { sendStateLock.lock(); _continuousSendEnabled = 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 声道 + /// 单帧 opus 固定长度(编码器 CBR)。无包头协议靠固定帧长切分,长度必须严格恒定,否则设备侧整包错位。 + private let OPUS_FRAME_BYTES = 40 + /// 每包帧数上限:包长 2*n*40 必须塞得进一次 BLE 写,6 帧=480B 已是安全上限 + private let MAX_BUNDLE_FRAME_COUNT = 6 private let CHANNEL_HOLD_BUFFER_MAX = 10000 // 声道缓存安全上限(防 OOM 兜底) //private var currentCommand: Command? @@ -801,7 +821,8 @@ public class BleService: NSObject { startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280) // 通话翻译:左右声道独立编码,每帧 40B/20ms(与 Android setPacketSize(40) 对齐) - _ = opusProcessor?.startDualEncodeStream(sampleRate: 16000, packetBytes: 40) + // 下行包格式(无包头,左右交替):[L0 40B][R0 40B]...[L(n-1)][R(n-1)],n=bundleFrameCount(默认2 → 160B) + _ = opusProcessor?.startDualEncodeStream(sampleRate: 16000, packetBytes: Int32(OPUS_FRAME_BYTES)) startAudioSendThreads() os_log("[CALL_TRANS_CMD] 打开A2DP解码并开始录制(通话翻译)...", log: logger, type: .info) @@ -1562,8 +1583,14 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback { enqueueOpusFrame(channel: channel, frame: frame) } - /// 静音帧模板就绪,缓存起来用于合包时不足帧补齐 + /// 静音帧模板就绪,缓存起来用于合包时不足帧补齐。 + /// 无包头协议按固定 40B 切分,模板长度不对会让整包左右对位全错,故只接受恰好 OPUS_FRAME_BYTES 的模板。 func onSilenceFrameReady(frame: Data) { + guard frame.count == OPUS_FRAME_BYTES else { + os_log("静音帧模板长度异常 size=%dB (期望 %dB),拒绝采用", + log: logger, type: .error, frame.count, OPUS_FRAME_BYTES) + return + } silenceFrame = frame } } @@ -1572,10 +1599,18 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback { @available(iOS 13.0, *) extension BleService { - /// 将一帧 opus 按声道加入对应下行队列。合包(取 N 帧/补静音/组头)在发送定时器按拍完成。 + /// 将一帧 opus 按声道加入对应下行队列。合包(左右各取 N 帧/交替拼接/补静音)在发送定时器按拍完成。 /// 仅超 OOM 兜底上限才丢最旧帧。 + /// + /// 无包头协议下设备侧按固定 40B 切分左右声道,任何一帧长度异常都会让整包及之后的声道对位全错, + /// 故非 OPUS_FRAME_BYTES 的帧在入队处直接拦掉,保证队列里的帧长恒定。 private func enqueueOpusFrame(channel: UInt8, frame: Data) { guard !frame.isEmpty else { return } + guard frame.count == OPUS_FRAME_BYTES else { + os_log("丢弃异常长度 opus 帧 ch=%d size=%dB (期望 %dB)", + log: logger, type: .error, Int(channel), frame.count, OPUS_FRAME_BYTES) + return + } sendBufferLock.lock() defer { sendBufferLock.unlock() } @@ -1594,27 +1629,27 @@ extension BleService { } } - /// 启动左右两条独立发送定时器(各发一路声道),右声道错开 RIGHT_SEND_STAGGER 启动。 + /// 启动下行发送定时器(单路,一包同时承载左右两声道) func startAudioSendThreads() { guard !isAudioSending else { os_log("音频发送定时器已在运行", log: logger, type: .debug) return } isAudioSending = true - leftSendTimer = makeChannelSendTimer(isLeft: true) - rightSendTimer = makeChannelSendTimer(isLeft: false) - os_log("音频发送定时器已启动(左/右双路,右错开 %.0fms)", log: logger, type: .info, RIGHT_SEND_STAGGER * 1000) + audioSendTimer = makeAudioSendTimer() + let n = min(max(bundleFrameCount, 1), MAX_BUNDLE_FRAME_COUNT) + os_log("音频发送定时器已启动(左右合包/无包头) 间隔=%.0fms 帧数=%d/声道 包长=%dB 持续发送=%{public}@", + log: logger, type: .info, audioSendInterval * 1000, n, + 2 * n * OPUS_FRAME_BYTES, continuousSendEnabled ? "开" : "关") } - /// 停止左右两条发送定时器并清空缓存 + /// 停止发送定时器并清空缓存 func stopAudioSendThreads() { isAudioSending = false - leftSendTimer?.cancel() - rightSendTimer?.cancel() - leftSendTimer = nil - rightSendTimer = nil + audioSendTimer?.cancel() + audioSendTimer = nil resetDownlinkBuffers() - os_log("音频发送定时器(左/右)已停止,队列和缓冲区已清空", log: logger, type: .info) + os_log("音频发送定时器已停止,队列和缓冲区已清空", log: logger, type: .info) } /// 清空下行发送队列,并复位包序、节拍与写入拥塞计数 @@ -1624,8 +1659,9 @@ extension BleService { rightSendBuffer.removeAll() sendBufferLock.unlock() - leftPacketSeq = 0 - rightPacketSeq = 0 + packetSeq = 0 + silenceNotReadyCount = 0 + silentPacketCount = 0 sendStateLock.lock() _lastAudioSendTime = -1 @@ -1633,63 +1669,67 @@ extension BleService { 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)) + /// 构建下行发送定时器:固定 audioSendInterval 一拍,每拍下发一包左右合成音频。 + private func makeAudioSendTimer() -> DispatchSourceTimer { + let timer = DispatchSource.makeTimerSource(queue: audioSendQueue) + timer.schedule(deadline: .now(), repeating: audioSendInterval, leeway: .milliseconds(2)) timer.setEventHandler { [weak self] in guard let self = self, self.isAudioSending else { return } - _ = self.trySendChannelChunk(isLeft: isLeft) + _ = self.trySendMixedChunk() } timer.resume() return timer } - /// 从指定声道队列取最多 BUNDLE_FRAME_COUNT 帧,不足用静音帧补齐,组包后下发。 - /// - 队列空:不发,返回 false - /// - 失败(拥塞):取出的真实帧按原序放回队头(静音补齐帧丢弃、下拍重组),返回 false,绝不丢帧 - private func trySendChannelChunk(isLeft: Bool) -> Bool { - // 取最多 N 帧真实数据 + /// 一拍下发一包:左右队列各取最多 bundleFrameCount 帧,按 L0 R0 L1 R1 ... 交替拼成无包头包下发。 + /// - 某一路帧不足(含一帧没有):缺的位用静音帧补齐,保证包长恒定、左右严格对位 + /// - 两路皆空:continuousSendEnabled=true 时发全静音包保持设备侧音频流不断,否则跳过本拍不发 + /// - 静音帧模板未就绪:包长无法恒定 → 本拍不发,已取出的帧原样退回队头 + /// - 写失败(拥塞):取出的真实帧按原序退回各自队头,静音补齐帧丢弃,下一拍重组重试,绝不丢帧 + private func trySendMixedChunk() -> Bool { + let n = min(max(bundleFrameCount, 1), MAX_BUNDLE_FRAME_COUNT) + + // 左右各取最多 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()) - } + var leftFrames = [Data]() + while leftFrames.count < n, !leftSendBuffer.isEmpty { + leftFrames.append(leftSendBuffer.removeFirst()) + } + var rightFrames = [Data]() + while rightFrames.count < n, !rightSendBuffer.isEmpty { + rightFrames.append(rightSendBuffer.removeFirst()) } sendBufferLock.unlock() - guard !realFrames.isEmpty else { return false } // 队列空则不发 - - let channel = isLeft ? SwiftOpusAudioProcessor.CHANNEL_LEFT : SwiftOpusAudioProcessor.CHANNEL_RIGHT - let chTag = isLeft ? "L" : "R" - let seq = isLeft ? leftPacketSeq : rightPacketSeq - - // 组包帧 = 真实帧 + 静音帧补齐到 N(静音帧未就绪时只发真实帧,罕见的启动窗口) - var frames = realFrames - var padCount = 0 - if let silence = silenceFrame { - while frames.count < BUNDLE_FRAME_COUNT { - frames.append(silence) - padCount += 1 + + let hasRealAudio = !leftFrames.isEmpty || !rightFrames.isEmpty + // 两路皆空且未开持续发送:本拍不发 + if !hasRealAudio && !continuousSendEnabled { return false } + + // 无包头协议靠固定帧长切分,缺帧必须补静音才能保证包长恒定;模板未就绪则本拍放弃,帧退回队列不丢 + guard let silence = silenceFrame else { + sendBufferLock.lock() + leftSendBuffer.insert(contentsOf: leftFrames, at: 0) + rightSendBuffer.insert(contentsOf: rightFrames, at: 0) + sendBufferLock.unlock() + silenceNotReadyCount += 1 + if silenceNotReadyCount == 1 || silenceNotReadyCount % 50 == 0 { + os_log("静音帧模板未就绪,暂缓下发(累计 %d 拍),译音已退回队列不丢", + log: logger, type: .error, silenceNotReadyCount) } + return false } - let packet = buildBundlePacket(seq: seq, channel: channel, frames: frames) + silenceNotReadyCount = 0 + + let padLeft = n - leftFrames.count + let padRight = n - rightFrames.count + let packet = buildInterleavedPacket(n: n, leftFrames: leftFrames, rightFrames: rightFrames, silence: silence) // 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号 guard sendAudioPacket(packet) else { - // 写失败:真实帧按原顺序放回队头,下一拍重试;静音补齐帧丢弃,下次按新帧重组 + // 写失败:真实帧按原顺序退回各自队头,下一拍重组重试;静音补齐帧丢弃 sendBufferLock.lock() - if isLeft { - leftSendBuffer.insert(contentsOf: realFrames, at: 0) - } else { - rightSendBuffer.insert(contentsOf: realFrames, at: 0) - } + leftSendBuffer.insert(contentsOf: leftFrames, at: 0) + rightSendBuffer.insert(contentsOf: rightFrames, at: 0) sendBufferLock.unlock() sendStateLock.lock() @@ -1697,8 +1737,7 @@ extension BleService { 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) + os_log("下行写入拥塞,重试中 seq=%u 连续失败=%d", log: logger, type: .error, packetSeq, fails) } return false } @@ -1714,32 +1753,82 @@ extension BleService { os_log("下行写入已恢复,之前连续失败=%d", log: logger, type: .info, prevFails) } - // 成功后推进该声道包序(各自只被本声道发送队列访问) - if isLeft { leftPacketSeq &+= 1 } else { rightPacketSeq &+= 1 } + let seq = packetSeq + packetSeq &+= 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) + + // 持续发送的纯静音包按 25 拍节流,避免刷屏 + if hasRealAudio { + silentPacketCount = 0 + os_log("音频下行发送 seq=%u Δ=%dms L=%d+静音%d R=%d+静音%d size=%dB buf(L=%d,R=%d)", + log: logger, type: .debug, seq, deltaMs, + leftFrames.count, padLeft, rightFrames.count, padRight, packet.count, lBuf, rBuf) + } else { + silentPacketCount += 1 + if silentPacketCount % 25 == 1 { + os_log("音频下行持续发送(全静音) seq=%u Δ=%dms size=%dB 累计=%d 包", + log: logger, type: .debug, seq, deltaMs, packet.count, silentPacketCount) + } + } return true } - /// 组下行包:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [N 帧 opus 顺次拼接] - private func buildBundlePacket(seq: UInt32, channel: UInt8, frames: [Data]) -> Data { + /// 组下行包(无包头,左右帧级交替):[L0][R0][L1][R1]...[L(n-1)][R(n-1)] + /// 每帧恒为 OPUS_FRAME_BYTES 字节,包长恒为 2*n*OPUS_FRAME_BYTES;某侧该位缺帧则填静音帧,保证左右严格对位。 + private func buildInterleavedPacket(n: Int, leftFrames: [Data], rightFrames: [Data], silence: 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) } + packet.reserveCapacity(2 * n * OPUS_FRAME_BYTES) + for i in 0.. 0 { + let clamped = TimeInterval(min(max(ms, 1), 1000)) / 1000.0 + if clamped != audioSendInterval { + audioSendInterval = clamped + intervalChanged = true + } + os_log("[CALL_TRANS_DEBUG] 设置音频下行发送间隔: %.0fms", log: logger, type: .info, audioSendInterval * 1000) + } + if let frames = bundleFrameCount, frames > 0 { + self.bundleFrameCount = min(max(frames, 1), MAX_BUNDLE_FRAME_COUNT) + os_log("[CALL_TRANS_DEBUG] 设置每声道合包帧数: %d (包长 %dB)", log: logger, type: .info, + self.bundleFrameCount, 2 * self.bundleFrameCount * OPUS_FRAME_BYTES) + } + if let continuous = continuousSend { + continuousSendEnabled = continuous + os_log("[CALL_TRANS_DEBUG] 设置持续发送音频: %{public}@", log: logger, type: .info, continuous ? "开" : "关") + } + // 间隔变了才重建定时器;帧数/持续发送开关每拍现读,无需重建 + if intervalChanged, isAudioSending { + audioSendTimer?.cancel() + audioSendTimer = makeAudioSendTimer() + os_log("[CALL_TRANS_DEBUG] 发送间隔变更,已按新节拍重建定时器", log: logger, type: .info) + } + } + + /// 获取当前通话翻译调试参数 + func getCallTranslationDebugParams() -> [String: Any] { + return [ + "sendIntervalMs": Int((audioSendInterval * 1000).rounded()), + "bundleFrameCount": bundleFrameCount, + "continuousSend": continuousSendEnabled + ] + } + /// 写一包到通话音频特征,返回是否成功塞入底层发送缓冲。 /// canSendWriteWithoutResponse == false 即背压信号:底层缓冲已满,调用方应放回队头下一拍重试。 private func sendAudioPacket(_ packet: Data) -> Bool { diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index d09902911..719bcb1a7 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift @@ -117,7 +117,19 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { case "playA2DP": result(BleService.shared.playA2DP()) - + + case "setCallTranslationDebugParams": + let arguments = call.arguments as? [String: Any] + BleService.shared.setCallTranslationDebugParams( + sendIntervalMs: (arguments?["sendIntervalMs"] as? NSNumber)?.intValue, + bundleFrameCount: (arguments?["bundleFrameCount"] as? NSNumber)?.intValue, + continuousSend: arguments?["continuousSend"] as? Bool + ) + result(true) + + case "getCallTranslationDebugParams": + result(BleService.shared.getCallTranslationDebugParams()) + case "startScan": let arguments = call.arguments as? [String: Any] let timeout = (arguments?["timeout"] as? Double) ?? 15.0 diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart index b5eb4c3e8..340ffb076 100644 --- a/local_plugins/ble_service/lib/ble_service.dart +++ b/local_plugins/ble_service/lib/ble_service.dart @@ -174,16 +174,19 @@ class BleService { /// 设置通话翻译调试参数(运行时生效) /// [sendIntervalMs] 音频下行发送间隔(ms) - /// [bundleFrameCount] 下行合包帧数 + /// [bundleFrameCount] 每包每声道的 opus 帧数(1..6);下行包无包头、左右帧级交替,包长 = 2*帧数*40B + /// [continuousSend] 是否持续发送:true 时无译音也按拍下发全静音包,保持设备侧音频流不断 Future setCallTranslationDebugParams({ int? sendIntervalMs, int? bundleFrameCount, + bool? continuousSend, }) async { try { final result = await _methodChannel .invokeMethod('setCallTranslationDebugParams', { if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs, if (bundleFrameCount != null) 'bundleFrameCount': bundleFrameCount, + if (continuousSend != null) 'continuousSend': continuousSend, }); return result ?? false; } catch (e) { @@ -193,16 +196,16 @@ class BleService { } /// 获取当前通话翻译调试参数 - /// 返回 {'sendIntervalMs','bundleFrameCount'},失败返回空 Map - Future> getCallTranslationDebugParams() async { + /// 返回 {'sendIntervalMs': int, 'bundleFrameCount': int, 'continuousSend': bool},失败返回空 Map + Future> getCallTranslationDebugParams() async { try { final result = await _methodChannel .invokeMethod('getCallTranslationDebugParams'); if (result == null) return {}; - int readInt(String k) => (result[k] as num?)?.toInt() ?? 0; return { - 'sendIntervalMs': readInt('sendIntervalMs'), - 'bundleFrameCount': readInt('bundleFrameCount'), + 'sendIntervalMs': (result['sendIntervalMs'] as num?)?.toInt() ?? 0, + 'bundleFrameCount': (result['bundleFrameCount'] as num?)?.toInt() ?? 0, + 'continuousSend': result['continuousSend'] as bool? ?? false, }; } catch (e) { print('获取通话翻译调试参数失败: $e');