Browse Source

上传代码

newdev_chengguofeng
liwei1dao 3 months ago
parent
commit
cfee5bd766
  1. 11
      lib/data/services/ble_manager.dart
  2. 52
      lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart
  3. 50
      lib/modules/call_translation_debug/views/call_translation_debug_view.dart
  4. 295
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  5. 3
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  6. 269
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  7. 12
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift
  8. 15
      local_plugins/ble_service/lib/ble_service.dart

11
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<bool> 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<Map<String, int>> getCallTranslationDebugParams() async {
/// 获取当前通话翻译调试参数
/// {'sendIntervalMs': int, 'bundleFrameCount': int, 'continuousSend': bool}
Future<Map<String, dynamic>> getCallTranslationDebugParams() async {
try {
return await _bleService.getCallTranslationDebugParams();
} catch (e) {

52
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<BleManager>();
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<int>(_kSendInterval);
final savedFrames = _storage.read<int>(_kBundleFrameCount);
final savedContinuous = _storage.read<bool>(_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<int>(_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<int>(_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<bool>(_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<void> 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 ? '参数已应用' : '参数下发失败(请确认设备已连接)',

50
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<CallTranslationDebugController> {
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,

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

@ -133,25 +133,23 @@ object BleService {
// 音频数据缓存
//private val audioDataBuffer = mutableListOf<Byte>()
// 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按拍下发。
// 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。
// 音频数据发送相关:左右声道各一条缓存队列,共用**一条**发送线程,每拍从左右队列各取 N 帧交替合成一包下发。
// 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=发送线程)。
private val leftSendBuffer = LinkedBlockingDeque<ByteArray>()
private val rightSendBuffer = LinkedBlockingDeque<ByteArray>()
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<ByteArray>(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<ByteArray>(n)
while (leftFrames.size < n) leftFrames.add(leftSendBuffer.pollFirst() ?: break)
val rightFrames = ArrayList<ByteArray>(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<ByteArray>(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>): 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<ByteArray>,
rightFrames: List<ByteArray>,
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<ByteArray>, 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<String, Any> {
return mapOf(
"sendIntervalMs" to audioSendIntervalNormal,
"bundleFrameCount" to bundleFrameCount
"bundleFrameCount" to bundleFrameCount,
"continuousSend" to continuousSendEnabled
)
}

3
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<Number>("sendIntervalMs"))?.toLong()
val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt()
BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount)
val continuousSend = call.argument<Boolean>("continuousSend")
BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount, continuousSend)
result.success(true)
}
"getCallTranslationDebugParams" -> {

269
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..<n {
packet.append(i < leftFrames.count ? leftFrames[i] : silence)
packet.append(i < rightFrames.count ? rightFrames[i] : silence)
}
return packet
}
// MARK: - 通话翻译调试参数(运行时可调,与 Android 对齐)
/// 设置通话翻译调试参数(运行时生效)。任一参数为 nil 或非法值则不修改。
/// 改动发送间隔会重建定时器(若正在发送)。
func setCallTranslationDebugParams(sendIntervalMs: Int?, bundleFrameCount: Int?, continuousSend: Bool?) {
var intervalChanged = false
if let ms = sendIntervalMs, ms > 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 {

12
local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift

@ -118,6 +118,18 @@ 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

15
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<bool> setCallTranslationDebugParams({
int? sendIntervalMs,
int? bundleFrameCount,
bool? continuousSend,
}) async {
try {
final result = await _methodChannel
.invokeMethod<bool>('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<Map<String, int>> getCallTranslationDebugParams() async {
/// 返回 {'sendIntervalMs': int, 'bundleFrameCount': int, 'continuousSend': bool},失败返回空 Map
Future<Map<String, dynamic>> getCallTranslationDebugParams() async {
try {
final result = await _methodChannel
.invokeMethod<Map>('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');

Loading…
Cancel
Save