Browse Source

上传陈工的代码

newdev_chengguofeng
liwei1dao 3 months ago
parent
commit
03d73815ff
  1. 14
      ios/Runner.xcodeproj/project.pbxproj
  2. 21
      lib/data/services/ble_manager.dart
  3. 95
      lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart
  4. 100
      lib/modules/call_translation_debug/views/call_translation_debug_view.dart
  5. 41
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  6. 41
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCommandSender.kt
  7. 11
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt
  8. 134
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  9. 13
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  10. 4
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift
  11. 434
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  12. 294
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift
  13. 24
      local_plugins/ble_service/lib/ble_service.dart
  14. 2
      pubspec.yaml

14
ios/Runner.xcodeproj/project.pbxproj

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -251,7 +251,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@ -346,14 +346,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -367,14 +363,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@ -789,7 +781,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};

21
lib/data/services/ble_manager.dart

@ -384,14 +384,6 @@ class BleManager extends GetxService {
_updateBatteryInfoFromData(data);
break;
case BleCommands.CONTROL_CODEC:
// F4/F5 解码空余上报也复用 command=0x05,但它是下行流控上报(native 已内部处理调速),
// 不是"开/关编解码"的应答。必须先排除:否则每条 F4/F5 都会被当成应答,其第二字节(空余
// 字节数 int32 大端最高位)恒为 0 → 判"失败",既刷屏"编解码控制失败",又可能污染
// _isCodecActive / 吞掉真正的应答(在等待开关应答的窗口内先收到 F4/F5 时)。
final int? codecStatus = (data['codecStatus'] as num?)?.toInt();
if (codecStatus == 0xF4 || codecStatus == 0xF5) {
break;
}
final bool codecSuccess = _isCodecControlSuccess(data);
if (_awaitingCodecOpen) {
_isCodecActive.value = codecSuccess;
@ -1257,27 +1249,14 @@ class BleManager extends GetxService {
/// 设置通话翻译调试参数(运行时生效)
/// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数
/// tierN* 为三档控流:每档 空余下限(FreeBytes) + 发送间隔(SendMs)
Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs,
int? bundleFrameCount,
int? tier1FreeBytes,
int? tier1SendMs,
int? tier2FreeBytes,
int? tier2SendMs,
int? tier3FreeBytes,
int? tier3SendMs,
}) async {
try {
return await _bleService.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs,
bundleFrameCount: bundleFrameCount,
tier1FreeBytes: tier1FreeBytes,
tier1SendMs: tier1SendMs,
tier2FreeBytes: tier2FreeBytes,
tier2SendMs: tier2SendMs,
tier3FreeBytes: tier3FreeBytes,
tier3SendMs: tier3SendMs,
);
} catch (e) {
Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}');

95
lib/modules/call_translation_debug/controllers/call_translation_debug_controller.dart

@ -5,7 +5,7 @@ import '../../../data/services/ble_manager.dart';
import '../../../core/utils/logger.dart';
/// 通话翻译调试控制器
/// 负责调试参数(音频下行发送间隔、下行合包帧数、三档控流)的读取、保存、下发,
/// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发,
/// 以及通话翻译编解码的启动/停止。
class CallTranslationDebugController extends GetxController {
final BleManager _bleManager = Get.find<BleManager>();
@ -14,23 +14,10 @@ class CallTranslationDebugController extends GetxController {
// 持久化键
static const String _kSendInterval = 'call_trans_debug_send_interval';
static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames';
static const String _kTier1Free = 'call_trans_debug_tier1_free';
static const String _kTier1Send = 'call_trans_debug_tier1_send';
static const String _kTier2Free = 'call_trans_debug_tier2_free';
static const String _kTier2Send = 'call_trans_debug_tier2_send';
static const String _kTier3Free = 'call_trans_debug_tier3_free';
static const String _kTier3Send = 'call_trans_debug_tier3_send';
// 默认值(与原生默认保持一致)
static const int defaultSendIntervalMs = 100;
static const int defaultBundleFrameCount = 5;
// 三档控流默认:≥2880B→40ms / ≥1120B→160ms / 其余→320ms(对齐原生默认)
static const int defaultTier1Free = 2880;
static const int defaultTier1Send = 40;
static const int defaultTier2Free = 1120;
static const int defaultTier2Send = 160;
static const int defaultTier3Free = 0;
static const int defaultTier3Send = 320;
// 取值范围
static const int minSendIntervalMs = 20;
@ -38,14 +25,6 @@ class CallTranslationDebugController extends GetxController {
static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms
static const int minBundleFrameCount = 1;
static const int maxBundleFrameCount = 20;
// 三档:剩余字节数门限范围(设备按约 160B/包 步进上报,故用 160 步长)
static const int minTierFree = 0;
static const int maxTierFree = 4000;
static const int tierFreeStep = 160;
// 三档:发送间隔范围(取拍长整数倍更干净,用 40ms 步长)
static const int minTierSendMs = 40;
static const int maxTierSendMs = 800;
static const int tierSendStepMs = 40;
/// 音频下行发送间隔(ms)
final RxInt sendIntervalMs = defaultSendIntervalMs.obs;
@ -53,14 +32,6 @@ class CallTranslationDebugController extends GetxController {
/// 下行合包帧数
final RxInt bundleFrameCount = defaultBundleFrameCount.obs;
/// 三档控流:每档 剩余字节数门限 + 发送间隔(ms)
final RxInt tier1Free = defaultTier1Free.obs;
final RxInt tier1Send = defaultTier1Send.obs;
final RxInt tier2Free = defaultTier2Free.obs;
final RxInt tier2Send = defaultTier2Send.obs;
final RxInt tier3Free = defaultTier3Free.obs;
final RxInt tier3Send = defaultTier3Send.obs;
/// 通话翻译编解码是否已开启
RxBool get isCodecActive => _bleManager.isCodecActiveRx;
@ -87,17 +58,6 @@ class CallTranslationDebugController extends GetxController {
bundleFrameCount.value =
savedFrames.clamp(minBundleFrameCount, maxBundleFrameCount);
}
_loadTier(_kTier1Free, tier1Free, minTierFree, maxTierFree);
_loadTier(_kTier1Send, tier1Send, minTierSendMs, maxTierSendMs);
_loadTier(_kTier2Free, tier2Free, minTierFree, maxTierFree);
_loadTier(_kTier2Send, tier2Send, minTierSendMs, maxTierSendMs);
_loadTier(_kTier3Free, tier3Free, minTierFree, maxTierFree);
_loadTier(_kTier3Send, tier3Send, minTierSendMs, maxTierSendMs);
}
void _loadTier(String key, RxInt target, int lo, int hi) {
final v = _storage.read<int>(key);
if (v != null) target.value = v.clamp(lo, hi);
}
/// 用原生当前实际值同步显示(仅在本地未保存过该项时采用原生返回值)
@ -118,31 +78,11 @@ class CallTranslationDebugController extends GetxController {
nf.clamp(minBundleFrameCount, maxBundleFrameCount);
}
}
_syncTier(params, 'tier1FreeBytes', _kTier1Free, tier1Free, minTierFree,
maxTierFree);
_syncTier(params, 'tier1SendMs', _kTier1Send, tier1Send, minTierSendMs,
maxTierSendMs);
_syncTier(params, 'tier2FreeBytes', _kTier2Free, tier2Free, minTierFree,
maxTierFree);
_syncTier(params, 'tier2SendMs', _kTier2Send, tier2Send, minTierSendMs,
maxTierSendMs);
_syncTier(params, 'tier3FreeBytes', _kTier3Free, tier3Free, minTierFree,
maxTierFree);
_syncTier(params, 'tier3SendMs', _kTier3Send, tier3Send, minTierSendMs,
maxTierSendMs);
} catch (e) {
Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $e');
}
}
void _syncTier(Map<String, int> params, String nativeKey, String storeKey,
RxInt target, int lo, int hi) {
// 剩余字节数下限可为 0,故不用 >0 过滤;仅在本地未保存过时采用原生值
if (_storage.read<int>(storeKey) != null) return;
final v = params[nativeKey];
if (v != null) target.value = v.clamp(lo, hi);
}
void setSendInterval(int value) {
final snapped = (value / sendIntervalStepMs).round() * sendIntervalStepMs;
sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs);
@ -153,53 +93,22 @@ class CallTranslationDebugController extends GetxController {
value.clamp(minBundleFrameCount, maxBundleFrameCount);
}
void setTierFree(RxInt target, int value) {
final snapped = (value / tierFreeStep).round() * tierFreeStep;
target.value = snapped.clamp(minTierFree, maxTierFree);
}
void setTierSend(RxInt target, int value) {
final snapped = (value / tierSendStepMs).round() * tierSendStepMs;
target.value = snapped.clamp(minTierSendMs, maxTierSendMs);
}
/// 恢复默认值
void resetDefaults() {
sendIntervalMs.value = defaultSendIntervalMs;
bundleFrameCount.value = defaultBundleFrameCount;
tier1Free.value = defaultTier1Free;
tier1Send.value = defaultTier1Send;
tier2Free.value = defaultTier2Free;
tier2Send.value = defaultTier2Send;
tier3Free.value = defaultTier3Free;
tier3Send.value = defaultTier3Send;
}
/// 保存并下发参数到原生(运行时立即生效)
Future<void> applyParams() async {
_storage.write(_kSendInterval, sendIntervalMs.value);
_storage.write(_kBundleFrameCount, bundleFrameCount.value);
_storage.write(_kTier1Free, tier1Free.value);
_storage.write(_kTier1Send, tier1Send.value);
_storage.write(_kTier2Free, tier2Free.value);
_storage.write(_kTier2Send, tier2Send.value);
_storage.write(_kTier3Free, tier3Free.value);
_storage.write(_kTier3Send, tier3Send.value);
final ok = await _bleManager.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs.value,
bundleFrameCount: bundleFrameCount.value,
tier1FreeBytes: tier1Free.value,
tier1SendMs: tier1Send.value,
tier2FreeBytes: tier2Free.value,
tier2SendMs: tier2Send.value,
tier3FreeBytes: tier3Free.value,
tier3SendMs: tier3Send.value,
);
Logger.i('CALL_TRANS_DEBUG',
'下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} '
'档1(≥${tier1Free.value}B→${tier1Send.value}ms) '
'档2(≥${tier2Free.value}B→${tier2Send.value}ms) '
'档3(其余→${tier3Send.value}ms) result=$ok');
'下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} result=$ok');
Get.snackbar(
'通话翻译调试',
ok ? '参数已应用' : '参数下发失败(请确认设备已连接)',

100
lib/modules/call_translation_debug/views/call_translation_debug_view.dart

@ -36,8 +36,6 @@ class CallTranslationDebugView
_buildSendIntervalCard(isDark),
SizedBox(height: 16.h),
_buildBundleFrameCard(isDark),
SizedBox(height: 16.h),
_buildFlowControlCard(isDark),
SizedBox(height: 24.h),
_buildActionButtons(isDark),
],
@ -195,104 +193,6 @@ class CallTranslationDebugView
);
}
// 下行控流三档:每档 剩余字节数门限 + 发送间隔
Widget _buildFlowControlCard(bool isDark) {
return _card(
isDark,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_title(isDark, '下行控流三档',
'按设备上报的解码空余字节数分三档控制发送频率:空余越少发得越慢,防设备解码溢出丢包。'
'规则:从第一档往下取第一个满足「空余 ≥ 门限」的档,用该档发送间隔。'),
SizedBox(height: 8.h),
_tierBlock(isDark, '第一档(最快)', controller.tier1Free, controller.tier1Send),
Divider(height: 24.h, color: isDark ? Colors.white12 : Colors.black12),
_tierBlock(isDark, '第二档(中速)', controller.tier2Free, controller.tier2Send),
Divider(height: 24.h, color: isDark ? Colors.white12 : Colors.black12),
_tierBlock(isDark, '第三档(最慢·其余)', controller.tier3Free, controller.tier3Send),
],
),
);
}
// 单个档位:两条滑杆(剩余字节数门限 + 发送间隔ms)
Widget _tierBlock(bool isDark, String label, RxInt freeRx, RxInt sendRx) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
SizedBox(height: 4.h),
_tierSliderRow(
isDark,
'剩余字节数门限',
freeRx,
min: CallTranslationDebugController.minTierFree,
max: CallTranslationDebugController.maxTierFree,
step: CallTranslationDebugController.tierFreeStep,
unit: 'B',
onChanged: (v) => controller.setTierFree(freeRx, v),
),
_tierSliderRow(
isDark,
'发送间隔',
sendRx,
min: CallTranslationDebugController.minTierSendMs,
max: CallTranslationDebugController.maxTierSendMs,
step: CallTranslationDebugController.tierSendStepMs,
unit: 'ms',
onChanged: (v) => controller.setTierSend(sendRx, v),
),
],
);
}
Widget _tierSliderRow(
bool isDark,
String name,
RxInt valueRx, {
required int min,
required int max,
required int step,
required String unit,
required ValueChanged<int> onChanged,
}) {
return Row(
children: [
SizedBox(
width: 96.w,
child: Text(
name,
style: TextStyle(
fontSize: 12.sp,
color: isDark ? Colors.white60 : Colors.black54,
),
),
),
Expanded(
child: Obx(
() => Slider(
value: valueRx.value.clamp(min, max).toDouble(),
min: min.toDouble(),
max: max.toDouble(),
divisions: (max - min) ~/ step,
label: '${valueRx.value} $unit',
onChanged: (v) => onChanged(v.round()),
),
),
),
Obx(() => _valueChip(isDark, '${valueRx.value} $unit')),
],
);
}
Widget _title(bool isDark, String title, String subtitle) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,

41
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -8,6 +8,12 @@ import os.log
// 导入蓝牙服务模块,用于处理蓝牙设备回调
import ble_service
// 通话翻译下行:每声道一条串行队列(对应 Android 的 bleLeftMutex / bleRightMutex)。
// 保证同一声道内「译音 PCM → 句尾补偿静音」严格按调用顺序进编码器,
// 否则尾音静音可能抢在最后一段真实音频之前,把句尾顶丢。
private let bleLeftWriteQueue = DispatchQueue(label: "com.azure.speech.ble.write.left")
private let bleRightWriteQueue = DispatchQueue(label: "com.azure.speech.ble.write.right")
/**
* Azure Speech Plugin
*
@ -1283,11 +1289,15 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC
let pcmData = audioData
print("[\(serviceId)] 合成音频生成,PCM大小=\(pcmData.count),合成ID=\(utteranceId)")
if pcmData.count > 0 {
// B 路:对方翻译后的音频,灌入立体声录音器右声道(仅在录音开启时)
if serviceId == "B" {
// B 路:对方翻译后的音频,灌入立体声录音器右声道(仅在录音开启时)
plugin?.recordAzureBTtsPcm(pcmData)
// 通话翻译:对端译音下发到耳机右声道
bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: pcmData) }
} else {
// A 路:本端译音下发到耳机左声道
bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: pcmData) }
}
BleService.shared.writeExternalAudioData(data: pcmData)
}
}
}
@ -1578,15 +1588,24 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback {
func onPartialAudio(sessionId: String, data: Data) {
if data.isEmpty { return }
if serviceId == "A" {
// A 路:翻译后的音频通过 BLE 写到耳机外放给对方
BleService.shared.writeExternalAudioData(data: data)
// A 路:本端译音通过 BLE 写到耳机左声道,外放给对方
bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: data) }
} else {
// B 路:对方的翻译音频,本地 PCM 播放器直接播
// B 路:对方的翻译音频,本地 PCM 播放器直接播,同时下发到耳机右声道
plugin?.pushCallBPcm(data)
bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: data) }
}
}
func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) {
// 句尾补偿静音:本句音频经 onPartialAudio 全部下发后触发,走与音频同一条声道队列,
// 保证补在最后一段真实音频之后,把编码器内滞留的不足一帧尾音顶出。
if serviceId == "A" {
bleLeftWriteQueue.async { BleService.shared.feedLeftTailSilence() }
} else {
bleRightWriteQueue.async { BleService.shared.feedRightTailSilence() }
}
let key = "\(serviceId):\(sessionId)"
let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? ""
os_log("[DoubaoCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d",
@ -1711,13 +1730,23 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback {
func onPartialAudio(sessionId: String, data: Data) {
if data.isEmpty { return }
if serviceId == "A" {
BleService.shared.writeExternalAudioData(data: data)
// A 路:本端译音下发到耳机左声道
bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: data) }
} else {
// B 路:对方译音本地播放,同时下发到耳机右声道
plugin?.pushCallBPcm(data)
bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: data) }
}
}
func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) {
// 句尾补偿静音:走与音频同一条声道队列,补在最后一段真实音频之后
if serviceId == "A" {
bleLeftWriteQueue.async { BleService.shared.feedLeftTailSilence() }
} else {
bleRightWriteQueue.async { BleService.shared.feedRightTailSilence() }
}
let key = "\(serviceId):\(sessionId)"
let original = sourceTextCache.removeValue(forKey: key) ?? ""
os_log("[AliyunCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d",

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

@ -85,13 +85,6 @@ class BleCommandSender {
*/
fun notifyWakeupSignalReceived()
/**
* 通知设备上报某声道解码缓存空余字节数(F4=左 / F5=右)。App 据此动态调整该声道下发速率。
* @param channel BleConst.AUDIO_CHANNEL_LEFT(左) / BleConst.AUDIO_CHANNEL_RIGHT(右)
* @param freeBytes 该声道解码缓存空余字节数
*/
fun notifyCallDecodeFreeReported(channel: Int, freeBytes: Int)
/**
* 通知开始 AI 单次对话
*/
@ -384,8 +377,6 @@ class BleCommandSender {
BleConst.CODEC_CONTROL_A2DP_PLAY -> "A2DP播放模式"
BleConst.CODEC_CONTROL_CALL_RECORD_PLAY -> "通话记录播放模式"
BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码"
BleConst.CODEC_CONTROL_DECODE_FREE_LEFT -> "左声道解码空余上报"
BleConst.CODEC_CONTROL_DECODE_FREE_RIGHT -> "右声道解码空余上报"
else -> "未知状态($codecStatus)"
}
@ -592,28 +583,10 @@ class BleCommandSender {
}
BleConst.CMD_CONTROL_CODEC -> {
// 设备主动上报编解码控制状态,关心左/右声道解码缓存空余字节数上报 (0xF4 左 / 0xF5 右),用于下行流控
// 帧格式: 0xCC 0x05 [len] [codecStatus, ...] [crc];F4/F5 载荷为 [code][int32 大端]
// 设备主动上报编解码控制状态。帧格式: 0xCC 0x05 [len] [codecStatus, ...] [crc]
val codecStatus =
if (notifyData.isNotEmpty()) notifyData[0].toInt() and 0xFF else -1
when (codecStatus) {
BleConst.CODEC_CONTROL_DECODE_FREE_LEFT,
BleConst.CODEC_CONTROL_DECODE_FREE_RIGHT -> {
// F4=左 / F5=右,紧跟一个大端 int32 空余字节数;notifyData[0]=code,[1..4]=int32,共需 >=5 字节
val channel = if (codecStatus == BleConst.CODEC_CONTROL_DECODE_FREE_LEFT)
BleConst.AUDIO_CHANNEL_LEFT else BleConst.AUDIO_CHANNEL_RIGHT
if (notifyData.size >= 5) {
val freeBytes = readInt32BE(notifyData, 1)
CallLog.i(TAG, "收到主动上报: ${if (channel == BleConst.AUDIO_CHANNEL_LEFT) "左" else "右"}声道解码空余=$freeBytes 字节 (code=0x${codecStatus.toString(16)})")
callback?.notifyCallDecodeFreeReported(channel, freeBytes)
} else {
Log.w(TAG, "解码空余上报数据长度不足: ${notifyData.size}(期望>=5),忽略 code=0x${codecStatus.toString(16)}")
}
}
else -> {
Log.d(TAG, "收到主动上报编解码控制: codecStatus=0x${codecStatus.toString(16)}")
}
}
Log.d(TAG, "收到主动上报编解码控制: codecStatus=0x${codecStatus.toString(16)}")
val resultMap = mapOf(
"success" to true,
"command" to infoType,
@ -814,14 +787,4 @@ class BleCommandSender {
}
return crc.toByte()
}
/**
* 从 data[offset] 起按大端读取一个 int32(设备解码空余字节数 F4/F5 采用大端)。
* 若后续固件改为小端,只需把此处移位顺序反转即可,调用方无需改动。
*/
private fun readInt32BE(data: ByteArray, offset: Int): Int =
((data[offset].toInt() and 0xFF) shl 24) or
((data[offset + 1].toInt() and 0xFF) shl 16) or
((data[offset + 2].toInt() and 0xFF) shl 8) or
(data[offset + 3].toInt() and 0xFF)
}

11
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt

@ -121,17 +121,6 @@ object BleConst {
const val CODEC_CONTROL_A2DP_PLAY = 0xA2
/** mic和dac(音乐或者通话远端)声音 */
const val CODEC_CONTROL_CALL_RECORD_PLAY = 0xA3
/**
* 设备主动上报"左声道"解码缓存空余字节数(下行流控)。
* 帧载荷: [0xF4][int32 大端],共 5 字节,单位字节。空余越小说明设备解码越来不及,App 据此把
* 左声道下发节拍在 40ms / 80ms / 160ms 三档间动态切换,缓解设备端解码缓存溢出丢包。
*/
const val CODEC_CONTROL_DECODE_FREE_LEFT = 0xF4
/**
* 设备主动上报"右声道"解码缓存空余字节数(下行流控)。
* 帧载荷: [0xF5][int32 大端],共 5 字节,单位字节。含义与 F4 一致、声道相反。
*/
const val CODEC_CONTROL_DECODE_FREE_RIGHT = 0xF5
/** 打开编码指令 */
const val CODEC_CONTROL_ENCODE_ON = 0xB1
/** 左声道 */

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

@ -133,8 +133,8 @@ object BleService {
// 音频数据缓存
//private val audioDataBuffer = mutableListOf<Byte>()
// 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按声道节拍下发。
// 缓存兼作重连暂停期间的暂存与 F4/F5 降速积压;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。
// 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按拍下发。
// 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。
private val leftSendBuffer = LinkedBlockingDeque<ByteArray>()
private val rightSendBuffer = LinkedBlockingDeque<ByteArray>()
private var leftSendThread: Thread? = null
@ -203,13 +203,6 @@ object BleService {
// 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改
@Volatile
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL
// 左/右声道下发节拍倍数(以一个 audioSendIntervalNormal 拍为单位):
// 1 拍=1×拍长/包(全速)、4 拍、8 拍(三档)。由设备 F4(左)/F5(右) 上报的解码空余动态切换:空余越少越降速。
// 各声道发送线程按 tick - lastSendTick(线程内局部) >= paceTicks 控制该声道下发频率。
@Volatile
private var leftPaceTicks: Int = 1
@Volatile
private var rightPaceTicks: Int = 1
// 声道缓存安全上限(纯防 OOM 兜底):现在缓存元素是单帧 opus,超限丢最旧帧并告警。
private const val CHANNEL_HOLD_BUFFER_MAX = 10000
// ---- 下行合包(已从 OpusAudioManager 上移到 BleService) ----
@ -225,16 +218,6 @@ object BleService {
// 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null
@Volatile
private var silenceFrame: ByteArray? = null
// ---- F4/F5 解码空余字节数 → 下发档位:简单三档(可在通话调试界面运行时调整) ----
// 每档 = (剩余字节数下限 门限, 发送间隔 sendMs)。设备上报某声道解码空余 freeBytes 后,
// 从第一档往下取第一个满足 freeBytes>=门限 的档,用该档发送间隔下发(内部按 audioSendIntervalNormal 换算为拍数)。
// 无迟滞(直观好调);如遇边界抖动可再引入固定小迟滞。默认对齐历史行为(≥2880→40ms / ≥1120→160ms / 其余→320ms)。
@Volatile private var tier1FreeBytes = 2880 // 第一档(最快):空余 ≥ 此值
@Volatile private var tier1SendMs = 40L // 第一档发送间隔(ms/包)
@Volatile private var tier2FreeBytes = 1120 // 第二档(中速):空余 ≥ 此值
@Volatile private var tier2SendMs = 160L // 第二档发送间隔(ms/包)
@Volatile private var tier3FreeBytes = 0 // 第三档(最慢):其余(空余 < 第二档门限),此门限为兜底下界
@Volatile private var tier3SendMs = 320L // 第三档发送间隔(ms/包)
// 音频数据缓冲区,用于累积数据到80字节再发送
private val audioBuffer = mutableListOf<Byte>()
// 重发机制相关常量
@ -447,14 +430,6 @@ object BleService {
this@BleService.notifyWakeupSignalReceived()
}
/**
* 通知设备上报某声道解码缓存空余字节数(F4=左 / F5=右),用于下行流控调速
*/
override fun notifyCallDecodeFreeReported(channel: Int, freeBytes: Int) {
// 使用外部类的方法处理,避免无限递归
this@BleService.onDecodeFreeReported(channel, freeBytes)
}
/**
* 通知开始 AI 单次对话
*/
@ -1517,7 +1492,7 @@ object BleService {
/**
* 开始Opus数据流编码(通话翻译模式:左右声道独立编码)
* 输出合包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B
* 输出合包格式:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B
* @return 是否成功开始编码
*/
private fun startOpusEncodeStream(): Boolean {
@ -1560,7 +1535,7 @@ object BleService {
/**
* 启动左右两条独立发送线程(各发一路声道),右声道相对左声道错开 RIGHT_SEND_THREAD_STAGGER_MS 启动,
* 避免两路同一时刻抢 BLE 写。每条线程按自身声道的 paceTicks 控制下发频率,互不影响。
* 避免两路同一时刻抢 BLE 写。每条线程按固定拍长下发,互不影响。
*/
private fun startAudioSendThread() {
if (leftSendThread?.isAlive == true || rightSendThread?.isAlive == true) {
@ -1575,7 +1550,7 @@ object BleService {
/**
* 构建单声道发送线程:固定 audioSendIntervalNormal 一拍(睡到绝对时刻,不随耗时抖动),
* 每拍按该声道 paceTicks 判断是否到点,到点则从该声道缓存取队首一包下发。
* 每拍从该声道缓存取队首一包下发。
* 重连暂停期间只跳过下发、不清缓存(译音继续积压待回连续发)。
* @param isLeft true=左声道 / false=右声道
* @param startOffsetMs 相对启动时刻的错开毫秒(右声道用来与左声道错峰)
@ -1585,8 +1560,6 @@ object BleService {
return Thread {
CallLog.i(TAG, "音频发送线程[$chName]已启动 offset=${startOffsetMs}ms")
var nextTickAt = System.currentTimeMillis() + startOffsetMs
var tick = 0L
var lastSendTick = -1L
// 首拍错开:右线程先睡 startOffsetMs 再进入循环,使两路发送时刻相互错峰
val initDelay = nextTickAt - System.currentTimeMillis()
if (initDelay > 0) {
@ -1597,14 +1570,10 @@ object BleService {
// 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发)
if (!isDownlinkPaused) {
val buffer = if (isLeft) leftSendBuffer else rightSendBuffer
val paceTicks = if (isLeft) leftPaceTicks else rightPaceTicks
if (buffer.isNotEmpty() && (lastSendTick < 0 || tick - lastSendTick >= paceTicks)) {
if (trySendChannelChunk(isLeft)) lastSendTick = tick
}
if (buffer.isNotEmpty()) trySendChannelChunk(isLeft)
}
// 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动
nextTickAt += audioSendIntervalNormal
tick++
val sleepMs = nextTickAt - System.currentTimeMillis()
if (sleepMs > 0) {
Thread.sleep(sleepMs)
@ -1699,7 +1668,7 @@ object BleService {
}
val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap"
CallLog.i(TAG, "音频下行发送 ch=$chTag seq=$seq Δ=${deltaMs}ms 帧=${realFrames.size}+静音$padCount size=${packet.size}B " +
"lastSeq(L=$lastLeftSeq,R=$lastRightSeq)$gapTag pace(L=${leftPaceTicks}x,R=${rightPaceTicks}x) " +
"lastSeq(L=$lastLeftSeq,R=$lastRightSeq)$gapTag " +
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})")
// [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包)
@ -1744,15 +1713,13 @@ object BleService {
}
/**
* 清空下行发送队列/左右声道缓存,并复位档位、包序、节拍与流控计数。
* 清空下行发送队列/左右声道缓存,并复位包序、节拍与写入拥塞计数。
* 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。
*/
private fun resetDownlinkBuffers() {
leftSendBuffer.clear() // 清空左声道下行缓存队列
rightSendBuffer.clear() // 清空右声道下行缓存队列
audioBuffer.clear() // 清空音频缓冲区
leftPaceTicks = 1 // 档位复位为全速
rightPaceTicks = 1
leftPacketSeq = 0 // 复位左右声道下行包序号
rightPacketSeq = 0
lastAudioSendTime = -1L // 重置发送节拍计时,下次首包 Δ 从 0 开始
@ -1778,18 +1745,14 @@ object BleService {
}
/**
* 退出下行暂停:保留暂停期间缓存的译音,仅复位下发档位/发送拍号为全速,
* 让积压的译音尽快开始下发(设备刚回连解码缓存是空的),之后仍由 F4/F5 反馈动态调速防溢出。
* 退出下行暂停:保留暂停期间缓存的译音,仅复位发送节拍计时,
* 让积压的译音按固定拍长继续下发。
*/
private fun exitDownlinkPause() {
if (!isDownlinkPaused) return
// 复位档位(不动缓存):从全速开始排空积压,F4/F5 会随即接管调速。
// 两声道线程内的 lastSendTick 会因 tick 已推进而立即到点,无需外部复位。
leftPaceTicks = 1
rightPaceTicks = 1
lastAudioSendTime = -1L
isDownlinkPaused = false
CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,档位复位全速续发缓存译音 " +
CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,续发缓存译音 " +
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})")
}
@ -2029,16 +1992,10 @@ object BleService {
* 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。
* @param sendIntervalMs 音频下行发送间隔(ms)
* @param bundleFrameCount 下行合包帧数
* @param t1FreeBytes/t1SendMs 第一档(最快):空余下限 / 发送间隔(ms)
* @param t2FreeBytes/t2SendMs 第二档(中速):空余下限 / 发送间隔(ms)
* @param t3FreeBytes/t3SendMs 第三档(最慢):空余下限(兜底) / 发送间隔(ms)
*/
fun setCallTranslationDebugParams(
sendIntervalMs: Long?,
bundleFrameCount: Int?,
t1FreeBytes: Int? = null, t1SendMs: Long? = null,
t2FreeBytes: Int? = null, t2SendMs: Long? = null,
t3FreeBytes: Int? = null, t3SendMs: Long? = null
bundleFrameCount: Int?
) {
if (sendIntervalMs != null && sendIntervalMs > 0) {
audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L)
@ -2048,36 +2005,16 @@ object BleService {
this.bundleFrameCount = bundleFrameCount.coerceIn(1, 20)
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${this.bundleFrameCount}")
}
// 三档控流:门限允许 0(第三档兜底),故用 >=0 判定;发送间隔要求 >0
if (t1FreeBytes != null && t1FreeBytes >= 0) tier1FreeBytes = t1FreeBytes
if (t1SendMs != null && t1SendMs > 0) tier1SendMs = t1SendMs.coerceIn(1L, 2000L)
if (t2FreeBytes != null && t2FreeBytes >= 0) tier2FreeBytes = t2FreeBytes
if (t2SendMs != null && t2SendMs > 0) tier2SendMs = t2SendMs.coerceIn(1L, 2000L)
if (t3FreeBytes != null && t3FreeBytes >= 0) tier3FreeBytes = t3FreeBytes
if (t3SendMs != null && t3SendMs > 0) tier3SendMs = t3SendMs.coerceIn(1L, 2000L)
if (t1FreeBytes != null || t1SendMs != null || t2FreeBytes != null ||
t2SendMs != null || t3FreeBytes != null || t3SendMs != null) {
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置三档控流: " +
"档1(≥${tier1FreeBytes}B→${tier1SendMs}ms) " +
"档2(≥${tier2FreeBytes}B→${tier2SendMs}ms) " +
"档3(其余→${tier3SendMs}ms)")
}
}
/**
* 获取当前通话翻译调试参数。
* @return Map: sendIntervalMs(Long) / bundleFrameCount(Int) / 三档门限与间隔
* @return Map: sendIntervalMs(Long) / bundleFrameCount(Int)
*/
fun getCallTranslationDebugParams(): Map<String, Any> {
return mapOf(
"sendIntervalMs" to audioSendIntervalNormal,
"bundleFrameCount" to bundleFrameCount,
"tier1FreeBytes" to tier1FreeBytes,
"tier1SendMs" to tier1SendMs,
"tier2FreeBytes" to tier2FreeBytes,
"tier2SendMs" to tier2SendMs,
"tier3FreeBytes" to tier3FreeBytes,
"tier3SendMs" to tier3SendMs
"bundleFrameCount" to bundleFrameCount
)
}
@ -2511,49 +2448,6 @@ object BleService {
}
}
/**
* 收到设备主动上报的某声道解码缓存空余字节数 (F4=左 / F5=右) 时调用,用于下行流控。
* 空余越少说明设备解码越来不及,则把该声道下发节拍降速(40→80→160ms),缓解解码缓存溢出丢包;
* 空余恢复后再升回全速。仅更新对应声道档位,由发送线程在下一拍按新档位执行。
* @param channel BleConst.AUDIO_CHANNEL_LEFT(左) / BleConst.AUDIO_CHANNEL_RIGHT(右)
* @param freeBytes 该声道解码缓存空余字节数
*/
private fun onDecodeFreeReported(channel: Int, freeBytes: Int) {
when (channel) {
BleConst.AUDIO_CHANNEL_LEFT -> {
val pace = nextPaceTicks(freeBytes)
if (leftPaceTicks != pace) {
CallLog.i(TAG, "左声道解码空余=$freeBytes 字节,下发档位 ${leftPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)")
}
leftPaceTicks = pace
}
BleConst.AUDIO_CHANNEL_RIGHT -> {
val pace = nextPaceTicks(freeBytes)
if (rightPaceTicks != pace) {
CallLog.i(TAG, "右声道解码空余=$freeBytes 字节,下发档位 ${rightPaceTicks}x→${pace}x (${pace * audioSendIntervalNormal}ms/包)")
}
rightPaceTicks = pace
}
else -> Log.w(TAG, "收到未知声道解码空余上报: channel=$channel")
}
}
/**
* 简单三档流控(无迟滞):按最新空余 freeBytes 从第一档往下取第一个满足 freeBytes>=门限 的档,
* 得到该档发送间隔 sendMs,再按当前拍长 audioSendIntervalNormal 换算为拍数(paceTicks)。
* 三档门限/间隔均可在通话调试界面运行时调整;发送线程按 paceTicks 控制该声道下发频率。
* @return 拍数(≥1),即"每 paceTicks 拍发一包"。
*/
private fun nextPaceTicks(freeBytes: Int): Int {
val sendMs = when {
freeBytes >= tier1FreeBytes -> tier1SendMs
freeBytes >= tier2FreeBytes -> tier2SendMs
else -> tier3SendMs
}
// ms → 拍数:四舍五入到最近的整数拍,至少 1 拍(不快于全速拍长)
return Math.round(sendMs.toDouble() / audioSendIntervalNormal).toInt().coerceAtLeast(1)
}
/**
* 向所有回调监听器分发开始 AI 单次对话
*/

13
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt

@ -249,16 +249,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
"setCallTranslationDebugParams" -> {
val sendIntervalMs = (call.argument<Number>("sendIntervalMs"))?.toLong()
val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt()
val t1FreeBytes = (call.argument<Number>("tier1FreeBytes"))?.toInt()
val t1SendMs = (call.argument<Number>("tier1SendMs"))?.toLong()
val t2FreeBytes = (call.argument<Number>("tier2FreeBytes"))?.toInt()
val t2SendMs = (call.argument<Number>("tier2SendMs"))?.toLong()
val t3FreeBytes = (call.argument<Number>("tier3FreeBytes"))?.toInt()
val t3SendMs = (call.argument<Number>("tier3SendMs"))?.toLong()
BleService.setCallTranslationDebugParams(
sendIntervalMs, bundleFrameCount,
t1FreeBytes, t1SendMs, t2FreeBytes, t2SendMs, t3FreeBytes, t3SendMs
)
BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount)
result.success(true)
}
"getCallTranslationDebugParams" -> {
@ -484,6 +475,4 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
// 发送升级失败事件
sendEvent(statusEventSink, resultMap, "发送升级失败异常")
}
// 注:F4/F5 解码空余字节数仅用于 native 内部下行流控(动态调整左右下发速率),不再向 Flutter 推送声道休眠/恢复事件。
}

4
local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift

@ -112,12 +112,10 @@ class BleConst {
static let CODEC_CONTROL_CLOSE: UInt8 = 0x00
/** 音乐或者通话远端声音 */
static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1
/** mic和dac(音乐或者通话远端)声音 */
/** mic和dac(音乐或者通话远端)声音+翻译后重新编码 */
static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2
/** mic和dac(音乐或者通话远端)声音 */
static let CODEC_CONTROL_CALL_RECORD_PLAY: UInt8 = 0xA3
/** 通话休眠 */
static let CODEC_CONTROL_CALL_Sleep: UInt8 = 0xFF
/** 打开编码指令 */
static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1
/** 左声道 */

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

@ -100,28 +100,58 @@ public class BleService: NSObject {
private var replyTimeoutTimer: Timer?
private let REPLY_TIMEOUT_MS: TimeInterval = 1.0
/// 接收帧 CRC 校验开关(调试期屏蔽):false = 校验失败只告警不丢帧,改回 true 恢复强校验。
/// 仅作用于接收方向;发往设备的命令帧仍照常附带 CRC。
private let ENABLE_RX_CRC_CHECK = false
// 新增:音频配置参数
private var sampleRate: Int32 = 16000
private var channels: Int32 = 1
private var frameSize: Int32 = 640
// MARK: - 音频数据发送相关属性
private var audioBuffer = [UInt8]() // 音频数据缓冲区
private var audioSendQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.audio_send", qos: .userInitiated)
private var audioDataQueue = [Data]() // 音频数据发送队列
private var isAudioSending = false // 音频发送状态
private var audioSendTimer: Timer? // 音频发送定时器
// 音频发送配置常量
private let AUDIO_CHUNK_SIZE = 120 // 每次发送120字节
private let AUDIO_SEND_INTERVAL: TimeInterval = 0.06 // 60毫秒间隔
// 收到 CODEC_CONTROL_CALL_Sleep 时插入的单次暂停时长(单位:秒)
private let AUDIO_SEND_SLEEP_PAUSE: TimeInterval = 1.0
// 在该时间点之前 processAudioSendQueue 不消费队列,实现"插入一次 1000ms 暂停"
private var nextSendEarliestAt: Date?
private let nextSendEarliestLock = NSLock()
private let audioQueueLock = NSLock() // 队列操作锁
// MARK: - 音频下行发送(与 Android BleService 对齐)
// 左右声道各一条缓存队列 + 一条独立发送定时器,右声道错开 RIGHT_SEND_STAGGER 启动,避免两路同时抢 BLE 写。
// 队列元素是单帧 opus(40B);发送线程每拍取最多 BUNDLE_FRAME_COUNT 帧,不足用静音帧补齐后组包下发。
private var leftSendBuffer = [Data]()
private var rightSendBuffer = [Data]()
private let sendBufferLock = NSLock()
private var leftSendTimer: DispatchSourceTimer?
private var rightSendTimer: DispatchSourceTimer?
private let leftSendQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.audio_send.left", qos: .userInitiated)
private let rightSendQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.audio_send.right", qos: .userInitiated)
/// 两路发送线程写同一个 callWriteCharacteristic + 同一 peripheral,写入必须串行化
private let bleWriteLock = NSLock()
/// 以下几个状态跨「左发送队列 / 右发送队列 / 编码回调线程 / 调用线程」共享,
/// 对应 Android 的 @Volatile 字段,统一用 sendStateLock 保护。
private let sendStateLock = NSLock()
private var _isAudioSending = false
/// 补齐用静音 opus 帧模板(编码器启动后异步送来);未就绪时为 nil,此时只发真实帧
private var _silenceFrame: Data?
/// 下行写入连续失败(底层发送缓冲拥塞)的计数,写成功即清零
private var _writeFailRetryCount = 0
/// 上一包音频实际发送的时间戳,仅用于发送节拍日志统计间隔;-1 表示尚未发送过
private var _lastAudioSendTime: TimeInterval = -1
/// 左/右声道各自的下行包序号(每成功发一包自增),供设备侧核对连续性。
/// 各自只被对应声道的发送队列访问,无需加锁。
private var leftPacketSeq: UInt32 = 0
private var rightPacketSeq: UInt32 = 0
private var isAudioSending: Bool {
get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _isAudioSending }
set { sendStateLock.lock(); _isAudioSending = newValue; sendStateLock.unlock() }
}
private var silenceFrame: Data? {
get { sendStateLock.lock(); defer { sendStateLock.unlock() }; return _silenceFrame }
set { sendStateLock.lock(); _silenceFrame = newValue; sendStateLock.unlock() }
}
// 音频发送配置常量(与 Android 默认值对齐)
private let AUDIO_SEND_INTERVAL_NORMAL: TimeInterval = 0.08 // 音频下行发送间隔 80ms/包
private let RIGHT_SEND_STAGGER: TimeInterval = 0.04 // 右声道相对左声道错开 40ms 启动
private let BUNDLE_FRAME_COUNT = 5 // 每包合并的 opus 帧数
private let BUNDLE_HEADER_SIZE = 5 // 4B 序号(小端) + 1B 声道
private let CHANNEL_HOLD_BUFFER_MAX = 10000 // 声道缓存安全上限(防 OOM 兜底)
//private var currentCommand: Command?
// 命令结构体
@ -481,16 +511,12 @@ public class BleService: NSObject {
if centralManager != nil {
isManualDisconnect = true // 标记为主动断开
// 停止音频发送定时器
stopAudioSendTimer()
// 停止双声道编码与左右发送定时器(内部清空下行缓存并复位包序)
opusProcessor?.stopDualEncodeStream()
stopAudioSendThreads()
silenceFrame = nil
stopOpusRecording()
// 清空音频缓冲区和队列
audioQueueLock.lock()
audioBuffer.removeAll()
audioDataQueue.removeAll()
audioQueueLock.unlock()
// 重置命令队列(与 Android reset 对齐)
replyTimeoutTimer?.invalidate()
replyTimeoutTimer = nil
@ -684,9 +710,30 @@ public class BleService: NSObject {
// MARK: - 编解码控制
public func writeExternalAudioData(data: Data) {
// 将外部音频数据写入编码流
opusProcessor?.encodePCMData(data)
/// 写入左声道 PCM(本端翻译音频),走左声道独立 Opus 编码器
/// - Parameter data: PCM(16kHz / 16bit / 单声道)
public func writeExternalLeftAudioData(data: Data) {
guard !data.isEmpty else { return }
opusProcessor?.writeLeftPcm(data)
}
/// 写入右声道 PCM(对端翻译音频),走右声道独立 Opus 编码器
/// - Parameter data: PCM(16kHz / 16bit / 单声道)
public func writeExternalRightAudioData(data: Data) {
guard !data.isEmpty else { return }
opusProcessor?.writeRightPcm(data)
}
/// 句尾补偿静音(本端/左声道):一句翻译音频的 PCM 全部喂完后调用,
/// 把编码器内滞留的不足一帧真实尾音顶出成整帧,避免耳机端句尾被截断。
/// 必须在本句最后一段真实 PCM 之后调用,保证编码顺序。
public func feedLeftTailSilence(paddingMs: Int = 400) {
opusProcessor?.feedTailSilence(channel: SwiftOpusAudioProcessor.CHANNEL_LEFT, paddingMs: paddingMs)
}
/// 句尾补偿静音(对端/右声道),语义同 feedLeftTailSilence
public func feedRightTailSilence(paddingMs: Int = 400) {
opusProcessor?.feedTailSilence(channel: SwiftOpusAudioProcessor.CHANNEL_RIGHT, paddingMs: paddingMs)
}
/// 打开编码器
@ -753,8 +800,9 @@ public class BleService: NSObject {
// 确保Opus解码器处于活跃状态(如果不是则启动)
startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280)
opusProcessor?.startEncoding(sampleRate: 16000, channels: 1, frameRate: 20)
// 通话翻译:左右声道独立编码,每帧 40B/20ms(与 Android setPacketSize(40) 对齐)
_ = opusProcessor?.startDualEncodeStream(sampleRate: 16000, packetBytes: 40)
startAudioSendThreads()
os_log("[CALL_TRANS_CMD] 打开A2DP解码并开始录制(通话翻译)...", log: logger, type: .info)
let paramData = Data([BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_STEREO])
@ -777,17 +825,10 @@ public class BleService: NSObject {
public func closeCodec() -> Bool {
os_log("关闭编解码并停止录制...", log: logger, type: .info)
// 停止音频发送定时器
stopAudioSendTimer()
// 清空音频缓冲区和队列
audioQueueLock.lock()
audioBuffer.removeAll()
audioDataQueue.removeAll()
audioQueueLock.unlock()
// 停止双声道编码与左右发送定时器(内部清空下行缓存并复位包序)
opusProcessor?.stopDualEncodeStream()
stopAudioSendThreads()
silenceFrame = nil
requestStopOpusRecording()
@ -1105,7 +1146,7 @@ public class BleService: NSObject {
let receivedCrc = data[data.count - 1]
if calculatedCrc != receivedCrc {
os_log("设备响应CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc)
return
if ENABLE_RX_CRC_CHECK { return }
}
// 提取响应数据
@ -1330,7 +1371,7 @@ public class BleService: NSObject {
if calculatedCrc != receivedCrc {
os_log("设备主动上报CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc)
return
if ENABLE_RX_CRC_CHECK { return }
}
// 提取上报数据
@ -1420,15 +1461,10 @@ public class BleService: NSObject {
]
notifyDeviceInfoReceived(infoType: Int(infoType), infoData: resultMap)
case BleConst.CMD_CONTROL_CODEC:
// 设备主动上报编解码控制状态,目前只关心 CODEC_CONTROL_CALL_Sleep (0xFF)
// 设备主动上报编解码控制状态
// 帧格式: 0xCC 0x05 [len] [codecStatus, channelMode, ...] [crc]
let codecStatus: Int = notifyData.count >= 1 ? Int(notifyData[0]) : -1
if codecStatus == Int(BleConst.CODEC_CONTROL_CALL_Sleep) {
os_log("收到主动上报: 通话休眠 (codecStatus=0xFF)", log: logger, type: .info)
handleCallSleepReceived()
} else {
os_log("收到主动上报编解码控制: codecStatus=0x%02X", log: logger, type: .debug, codecStatus)
}
os_log("收到主动上报编解码控制: codecStatus=0x%02X", log: logger, type: .debug, codecStatus)
let resultMap: [String: Any] = [
"success": true,
"command": Int(infoType),
@ -1514,161 +1550,210 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback {
notifyAudioDataReceived(data: data, channel: channel)
}
/// 接收到编码后的音频数据
/// - Parameter data: 编码后的音频数据
func onEncodedDataReceived(data: Data) {
// 保存编码数据到文件
recordingFile?.saveAudioData(data)
// 根据你的业务需求处理编码数据
// 例如:发送到蓝牙设备、保存到文件等
os_log("🎵 接收到编码音频数据: %d bytes", log: logger, type: .info, data.count)
// 修复条件绑定语句
guard let characteristic = callWriteCharacteristic,
let peripheral = peripheralDevice,
connectionState == BleConst.STATE_CONNECTED else {
os_log("通话音频特征未设置或设备未连接", log: logger, type: .error)
return
}
// 将编码音频数据加入到队列,按每次发送120字节,每次间隔60毫秒
addAudioDataToSendQueue(data: data)
// 启动音频发送定时器(如果尚未启动)
if !isAudioSending {
startAudioSendTimer()
/// 每编出一帧 opus 即回调:按声道入对应下行队列,合包由发送定时器按拍完成
func onAudioFrameEncoded(channel: UInt8, frame: Data) {
// 调试落地:保留原有「编码下行流」录制语义(此前只有本端译音走 BLE,即左声道)。
// 仍回主队列写文件,与改造前一致,避免与 closeFile 抢同一个 RecordingFile。
if channel == SwiftOpusAudioProcessor.CHANNEL_LEFT {
DispatchQueue.main.async { [weak self] in
self?.recordingFile?.saveAudioData(frame)
}
}
enqueueOpusFrame(channel: channel, frame: frame)
}
/**
* 将音频数据添加到发送队列
* 只有当缓冲区达到120字节时才发送数据
* @param data 音频数据字节数组
*/
private func addAudioDataToSendQueue(data: Data) {
audioQueueLock.lock()
defer { audioQueueLock.unlock() }
do {
// 将新数据添加到缓冲区
audioBuffer.append(contentsOf: data)
// 当缓冲区达到120字节时,发送数据
while audioBuffer.count >= AUDIO_CHUNK_SIZE {
// 取出120字节数据
let chunk = Data(audioBuffer.prefix(AUDIO_CHUNK_SIZE))
audioBuffer.removeFirst(AUDIO_CHUNK_SIZE)
// 将数据加入发送队列
audioDataQueue.append(chunk)
/// 静音帧模板就绪,缓存起来用于合包时不足帧补齐
func onSilenceFrameReady(frame: Data) {
silenceFrame = frame
}
}
// MARK: - 下行音频:入队 / 合包 / 按拍发送(与 Android BleService 对齐)
@available(iOS 13.0, *)
extension BleService {
/// 将一帧 opus 按声道加入对应下行队列。合包(取 N 帧/补静音/组头)在发送定时器按拍完成。
/// 仅超 OOM 兜底上限才丢最旧帧。
private func enqueueOpusFrame(channel: UInt8, frame: Data) {
guard !frame.isEmpty else { return }
sendBufferLock.lock()
defer { sendBufferLock.unlock() }
if channel == SwiftOpusAudioProcessor.CHANNEL_RIGHT {
while rightSendBuffer.count >= CHANNEL_HOLD_BUFFER_MAX {
rightSendBuffer.removeFirst()
os_log("声道积压缓存超上限(%d),丢弃最旧帧 ch=R", log: logger, type: .error, CHANNEL_HOLD_BUFFER_MAX)
}
// os_log("音频数据已加入缓冲区,当前缓冲区大小: %d 字节", log: logger, type: .debug, audioBuffer.count)
} catch {
os_log("处理音频数据异常: %{public}@", log: logger, type: .error, error.localizedDescription)
rightSendBuffer.append(frame)
} else {
while leftSendBuffer.count >= CHANNEL_HOLD_BUFFER_MAX {
leftSendBuffer.removeFirst()
os_log("声道积压缓存超上限(%d),丢弃最旧帧 ch=L", log: logger, type: .error, CHANNEL_HOLD_BUFFER_MAX)
}
leftSendBuffer.append(frame)
}
}
/**
* 启动音频数据发送定时器
*/
private func startAudioSendTimer() {
/// 启动左右两条独立发送定时器(各发一路声道),右声道错开 RIGHT_SEND_STAGGER 启动。
func startAudioSendThreads() {
guard !isAudioSending else {
os_log("音频发送定时器已在运行", log: logger, type: .debug)
return
}
isAudioSending = true
// 在主队列中创建定时器
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.audioSendTimer = Timer.scheduledTimer(withTimeInterval: self.AUDIO_SEND_INTERVAL, repeats: true) { [weak self] _ in
self?.processAudioSendQueue()
}
os_log("音频发送定时器已启动", log: self.logger, type: .info)
}
leftSendTimer = makeChannelSendTimer(isLeft: true)
rightSendTimer = makeChannelSendTimer(isLeft: false)
os_log("音频发送定时器已启动(左/右双路,右错开 %.0fms)", log: logger, type: .info, RIGHT_SEND_STAGGER * 1000)
}
/**
* 停止音频数据发送定时器
*/
private func stopAudioSendTimer() {
/// 停止左右两条发送定时器并清空缓存
func stopAudioSendThreads() {
isAudioSending = false
leftSendTimer?.cancel()
rightSendTimer?.cancel()
leftSendTimer = nil
rightSendTimer = nil
resetDownlinkBuffers()
os_log("音频发送定时器(左/右)已停止,队列和缓冲区已清空", log: logger, type: .info)
}
/// 清空下行发送队列,并复位包序、节拍与写入拥塞计数
private func resetDownlinkBuffers() {
sendBufferLock.lock()
leftSendBuffer.removeAll()
rightSendBuffer.removeAll()
sendBufferLock.unlock()
leftPacketSeq = 0
rightPacketSeq = 0
sendStateLock.lock()
_lastAudioSendTime = -1
_writeFailRetryCount = 0
sendStateLock.unlock()
}
/// 构建单声道发送定时器:固定 AUDIO_SEND_INTERVAL_NORMAL 一拍,每拍取队首若干帧组包下发。
private func makeChannelSendTimer(isLeft: Bool) -> DispatchSourceTimer {
let queue = isLeft ? leftSendQueue : rightSendQueue
let timer = DispatchSource.makeTimerSource(queue: queue)
let firstFire: DispatchTime = .now() + (isLeft ? 0 : RIGHT_SEND_STAGGER)
timer.schedule(deadline: firstFire, repeating: AUDIO_SEND_INTERVAL_NORMAL, leeway: .milliseconds(2))
timer.setEventHandler { [weak self] in
guard let self = self, self.isAudioSending else { return }
_ = self.trySendChannelChunk(isLeft: isLeft)
}
timer.resume()
return timer
}
/// 从指定声道队列取最多 BUNDLE_FRAME_COUNT 帧,不足用静音帧补齐,组包后下发。
/// - 队列空:不发,返回 false
/// - 失败(拥塞):取出的真实帧按原序放回队头(静音补齐帧丢弃、下拍重组),返回 false,绝不丢帧
private func trySendChannelChunk(isLeft: Bool) -> Bool {
// 取最多 N 帧真实数据
sendBufferLock.lock()
var realFrames = [Data]()
while realFrames.count < BUNDLE_FRAME_COUNT {
if isLeft {
guard !leftSendBuffer.isEmpty else { break }
realFrames.append(leftSendBuffer.removeFirst())
} else {
guard !rightSendBuffer.isEmpty else { break }
realFrames.append(rightSendBuffer.removeFirst())
}
}
sendBufferLock.unlock()
guard !realFrames.isEmpty else { return false } // 队列空则不发
DispatchQueue.main.async { [weak self] in
self?.audioSendTimer?.invalidate()
self?.audioSendTimer = nil
let channel = isLeft ? SwiftOpusAudioProcessor.CHANNEL_LEFT : SwiftOpusAudioProcessor.CHANNEL_RIGHT
let chTag = isLeft ? "L" : "R"
let seq = isLeft ? leftPacketSeq : rightPacketSeq
guard let self = self else { return }
os_log("音频发送定时器已停止", log: self.logger, type: .info)
// 组包帧 = 真实帧 + 静音帧补齐到 N(静音帧未就绪时只发真实帧,罕见的启动窗口)
var frames = realFrames
var padCount = 0
if let silence = silenceFrame {
while frames.count < BUNDLE_FRAME_COUNT {
frames.append(silence)
padCount += 1
}
}
}
let packet = buildBundlePacket(seq: seq, channel: channel, frames: frames)
/**
* 处理音频发送队列
*/
private func processAudioSendQueue() {
// 若收到过 CODEC_CONTROL_CALL_Sleep,本轮直接放弃发送,等到 nextSendEarliestAt 之后再消费队列
nextSendEarliestLock.lock()
let earliest = nextSendEarliestAt
nextSendEarliestLock.unlock()
if let earliest = earliest, Date() < earliest {
return
// 同步写入,拿到真实的成功/失败:false=底层发送缓冲已满(拥塞),作为背压信号
guard sendAudioPacket(packet) else {
// 写失败:真实帧按原顺序放回队头,下一拍重试;静音补齐帧丢弃,下次按新帧重组
sendBufferLock.lock()
if isLeft {
leftSendBuffer.insert(contentsOf: realFrames, at: 0)
} else {
rightSendBuffer.insert(contentsOf: realFrames, at: 0)
}
sendBufferLock.unlock()
sendStateLock.lock()
_writeFailRetryCount += 1
let fails = _writeFailRetryCount
sendStateLock.unlock()
if fails == 1 || fails % 50 == 0 {
os_log("下行写入拥塞,重试中 ch=%{public}@ seq=%u 连续失败=%d",
log: logger, type: .error, chTag, seq, fails)
}
return false
}
audioQueueLock.lock()
defer { audioQueueLock.unlock() }
guard !audioDataQueue.isEmpty else { return }
sendStateLock.lock()
let prevFails = _writeFailRetryCount
_writeFailRetryCount = 0
let now = Date().timeIntervalSince1970
let deltaMs = _lastAudioSendTime < 0 ? 0 : Int((now - _lastAudioSendTime) * 1000)
_lastAudioSendTime = now
sendStateLock.unlock()
if prevFails > 0 {
os_log("下行写入已恢复,之前连续失败=%d", log: logger, type: .info, prevFails)
}
// 从队列中取出音频数据块
let audioChunk = audioDataQueue.removeFirst()
// 成功后推进该声道包序(各自只被本声道发送队列访问)
if isLeft { leftPacketSeq &+= 1 } else { rightPacketSeq &+= 1 }
// 在后台队列中发送音频数据块
audioSendQueue.async { [weak self] in
self?.sendAudioChunk(audioChunk)
}
sendBufferLock.lock()
let lBuf = leftSendBuffer.count
let rBuf = rightSendBuffer.count
sendBufferLock.unlock()
os_log("音频下行发送 ch=%{public}@ seq=%u Δ=%dms 帧=%d+静音%d size=%dB buf(L=%d,R=%d)",
log: logger, type: .debug, chTag, seq, deltaMs,
realFrames.count, padCount, packet.count, lBuf, rBuf)
return true
}
/**
* 收到设备主动上报的 CODEC_CONTROL_CALL_Sleep 时调用,
* 让音频发送定时器在 AUDIO_SEND_SLEEP_PAUSE 内跳过发送,相当于插入一次较长暂停,
* 之后自动回到 60ms 节拍。期间重复触发会"刷新"暂停截止时间。
*/
private func handleCallSleepReceived() {
nextSendEarliestLock.lock()
nextSendEarliestAt = Date().addingTimeInterval(AUDIO_SEND_SLEEP_PAUSE)
nextSendEarliestLock.unlock()
os_log("收到通话休眠指令,音频发送将暂停 %.0f ms", log: logger, type: .info, AUDIO_SEND_SLEEP_PAUSE * 1000)
/// 组下行包:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [N 帧 opus 顺次拼接]
private func buildBundlePacket(seq: UInt32, channel: UInt8, frames: [Data]) -> Data {
var packet = Data()
packet.reserveCapacity(BUNDLE_HEADER_SIZE + frames.reduce(0) { $0 + $1.count })
packet.append(UInt8(seq & 0xFF))
packet.append(UInt8((seq >> 8) & 0xFF))
packet.append(UInt8((seq >> 16) & 0xFF))
packet.append(UInt8((seq >> 24) & 0xFF))
packet.append(channel)
for frame in frames { packet.append(frame) }
return packet
}
/**
* 发送音频数据块到蓝牙设备
* @param data 要发送的音频数据
*/
private func sendAudioChunk(_ data: Data) {
/// 写一包到通话音频特征,返回是否成功塞入底层发送缓冲。
/// canSendWriteWithoutResponse == false 即背压信号:底层缓冲已满,调用方应放回队头下一拍重试。
private func sendAudioPacket(_ packet: Data) -> Bool {
guard let characteristic = callWriteCharacteristic,
let peripheral = peripheralDevice,
connectionState == BleConst.STATE_CONNECTED else {
os_log("通话音频特征未设置或设备未连接,无法发送音频数据", log: logger, type: .error)
return
return false
}
bleWriteLock.lock()
defer { bleWriteLock.unlock() }
// 发送音频数据块
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
//判断是否发送成功
if peripheral.state == .connected {
os_log("🎵 发送音频数据块: %d bytes", log: logger, type: .debug, data.count)
} else {
os_log("🎵 发送音频数据块失败: %d bytes", log: logger, type: .debug, data.count)
}
guard peripheral.canSendWriteWithoutResponse else { return false }
peripheral.writeValue(packet, for: characteristic, type: .withoutResponse)
return true
}
}
@ -2120,6 +2205,11 @@ extension BleService: CBPeripheralDelegate {
}
}
/// 底层发送缓冲从「满」恢复为可写。发送定时器下一拍会自然重试队头帧,这里只记录一次恢复。
public func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
os_log("下行写入缓冲已就绪(canSendWriteWithoutResponse 恢复)", log: logger, type: .debug)
}
public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error {
return

294
local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift

@ -9,14 +9,27 @@ class SwiftOpusAudioProcessor: NSObject {
/// 音频数据回调协议
protocol AudioDataCallback: AnyObject {
func onAudioDataReceived(data: Data, channel: Int32)
func onEncodedDataReceived(data: Data) // 编码数据回调
/// 每编出一帧 opus 即回调(帧粒度,40B/20ms),由 BleService 按声道入队并合包下发。
/// - Parameters:
/// - channel: CHANNEL_LEFT(0=本端) / CHANNEL_RIGHT(1=对端)
/// - frame: 一帧 opus 编码数据
func onAudioFrameEncoded(channel: UInt8, frame: Data)
/// 静音帧模板就绪,供 BleService 合包时不足帧补齐
func onSilenceFrameReady(frame: Data)
}
/// 双声道下行包声道标识:0=左(本端翻译) / 1=右(对端翻译),与 Android OpusAudioManager 对齐
static let CHANNEL_LEFT: UInt8 = 0
static let CHANNEL_RIGHT: UInt8 = 1
/// 生成静音帧模板时喂入的零 PCM 长度(100ms@16k@16bit),足够编码器产出至少一帧
private static let SILENCE_PCM_BYTES = 3200
/// 16kHz / 16bit / 单声道:32 字节 = 1ms
private static let PCM_BYTES_PER_MS = 32
// MARK: - 私有属性
// 状态标志
private var isDecodingActive = false
private var isEncodingActive = false
private weak var callback: AudioDataCallback?
// Opus 解码器相关 - 使用 YbridOpus C API
@ -25,19 +38,23 @@ class SwiftOpusAudioProcessor: NSObject {
private var channels: Int32 = 1
private let maxFrameSize: Int32 = 640 // 为16kHz优化:40ms * 16000Hz = 640 samples
// MARK: - JLOpusEncoder 编码器相关属性
private var jlOpusEncoder: JLOpusEncoder?
private var encoderFormat: JLOpusFormat?
// MARK: - PCM数据队列相关属性
/// PCM数据缓存队列
private var pcmDataQueue = Data()
/// 队列访问锁,确保线程安全
private let queueLock = NSLock()
/// 每次处理的数据块大小(字节)
// MARK: - 双声道独立编码(与 Android OpusAudioManager.startDualEncodeStream 对齐)
// 左右各一个独立 JLOpusEncoder,互不污染预测状态;另有一个只用于生成静音帧模板的编码器。
private var leftEncoder: JLOpusEncoder?
private var rightEncoder: JLOpusEncoder?
private var silenceEncoder: JLOpusEncoder?
private var isDualEncoding = false
/// 静音帧模板,捕获首帧后不再更新
private var capturedSilenceFrame: Data?
/// 每次送入编码器的 PCM 块大小:320 samples × 2B = 640B = 20ms@16kHz。
/// 不足一块的尾巴留在 remainder 里等下一段 PCM,绝不补零——补零会在句中插入静音。
private let chunkSize = 640
/// 异步处理队列
private var processingQueue: DispatchQueue?
private var leftPcmRemainder = Data()
private var rightPcmRemainder = Data()
/// 每声道一条串行队列:保证「喂 PCM」与「句尾补偿静音」严格按调用顺序进编码器
private let leftEncodeQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.opus.encode.left", qos: .userInitiated)
private let rightEncodeQueue = DispatchQueue(label: "com.yunqiinnovation.ble_service.opus.encode.right", qos: .userInitiated)
// MARK: - 初始化和清理
@ -351,7 +368,12 @@ class SwiftOpusAudioProcessor: NSObject {
/// 获取编码器状态
/// - Returns: 是否正在编码
func isEncoding() -> Bool {
return isEncodingActive
return isDualEncoding
}
/// 是否正在进行双声道独立编码
func isDualEncodingActive() -> Bool {
return isDualEncoding
}
/// 获取当前配置信息
@ -367,149 +389,156 @@ class SwiftOpusAudioProcessor: NSObject {
}
/// 启动JLOpusEncoder编码(完善版本)
// ======================================================================================================
// 双声道独立编码(左右声道各用独立 JLOpusEncoder,互不污染预测状态)
// 每编出一帧即通过 onAudioFrameEncoded(声道, 帧) 回给 BleService;合包(取N帧/补静音/组头)由 BleService 完成。
// 与 Android OpusAudioManager.startDualEncodeStream 语义一致。
// ======================================================================================================
/// 启动双声道独立编码流
/// - Parameters:
/// - sampleRate: 采样率(默认16000Hz)
/// - channels: 声道数(默认1)
/// - frameRate: 帧长度(默认20ms)
/// - isHeader: 是否包含头部信息
/// - sampleRate: 采样率(默认 16000Hz)
/// - packetBytes: 每帧 opus 输出字节数(默认 40B,对应 20ms@16kbps,与 Android setPacketSize(40) 一致)
/// - Returns: 是否启动成功
func startEncoding(sampleRate: Int32 = 16000,
channels: Int32 = 1,
frameRate: Int32 = 20,
isHeader: Bool = false) -> Bool {
if isEncodingActive {
func startDualEncodeStream(sampleRate: Int32 = 16000, packetBytes: Int32 = 40) -> Bool {
if isDualEncoding {
print("双声道编码流已在运行")
return true
}
print("启动JLOpusEncoder编码 - 采样率: \(sampleRate)Hz, 声道: \(channels), 帧长度: \(frameRate)ms, 头部: \(isHeader)")
// 创建JLOpusFormat配置
encoderFormat = JLOpusFormat.defaultFormats()
encoderFormat?.sampleRate = Int32(sampleRate)
encoderFormat?.channels = Int32(channels)
encoderFormat?.frameDuration = Int32(frameRate)
encoderFormat?.hasDataHeader = isHeader
// 根据配置计算数据大小
if channels == 1 {
encoderFormat?.dataSize = frameRate == 20 ? 40 : 80 // 20ms=40bytes, 40ms=80bytes
} else {
encoderFormat?.dataSize = frameRate == 20 ? 80 : 160 // 双声道翻倍
}
guard let format = encoderFormat else {
print("编码器格式配置失败")
return false
capturedSilenceFrame = nil
leftPcmRemainder.removeAll()
rightPcmRemainder.removeAll()
// 每个编码器必须用独立的 format 实例,避免共享可变对象
func makeFormat() -> JLOpusFormat {
let f = JLOpusFormat.defaultFormats()
f.sampleRate = sampleRate
f.channels = 1
f.frameDuration = 20
f.dataSize = packetBytes
f.hasDataHeader = false // 与 Android setHasHead(false) 对齐
return f
}
// 初始化JLOpusEncoder
jlOpusEncoder = JLOpusEncoder(format: format, delegate: self)
leftEncoder = JLOpusEncoder(format: makeFormat(), delegate: self)
rightEncoder = JLOpusEncoder(format: makeFormat(), delegate: self)
silenceEncoder = JLOpusEncoder(format: makeFormat(), delegate: self)
guard jlOpusEncoder != nil else {
print("JLOpusEncoder初始化失败")
guard leftEncoder != nil, rightEncoder != nil, silenceEncoder != nil else {
print("JLOpusEncoder 初始化失败")
stopDualEncodeStream()
return false
}
isEncodingActive = true
print("JLOpusEncoder启动成功")
isDualEncoding = true
print("双声道独立编码流已启动 sampleRate=\(sampleRate) packetBytes=\(packetBytes)")
// 生成静音帧模板:喂一段零 PCM,捕获输出首帧
startSilenceFrameCapture()
return true
}
/// 停止JLOpusEncoder编码(完善版本)
/// - Returns: 是否停止成功
func stopEncoding() -> Bool {
if !isEncodingActive {
return false
/// 停止双声道独立编码流。
/// 左右编码器必须在各自的串行队列上释放:直接在调用线程 opusOnRelease() 会与队列里在飞的
/// opusEncode() 撞上,造成编码器内部状态已释放却仍被使用(UAF)。排到队尾释放即可保证
/// 之前入队的编码工作全部先跑完。
@discardableResult
func stopDualEncodeStream() -> Bool {
isDualEncoding = false
// 静音编码器只在 startDualEncodeStream 里同步喂过,没有在飞的异步编码,可直接释放
silenceEncoder?.opusOnRelease()
silenceEncoder = nil
capturedSilenceFrame = nil
leftEncodeQueue.async { [weak self] in
guard let self = self else { return }
self.leftEncoder?.opusOnRelease()
self.leftEncoder = nil
self.leftPcmRemainder.removeAll()
}
rightEncodeQueue.async { [weak self] in
guard let self = self else { return }
self.rightEncoder?.opusOnRelease()
self.rightEncoder = nil
self.rightPcmRemainder.removeAll()
}
isEncodingActive = false
// 清空PCM数据队列
clearPCMQueue()
// 释放JLOpusEncoder资源
jlOpusEncoder?.opusOnRelease()
jlOpusEncoder = nil
encoderFormat = nil
processingQueue = nil
print("JLOpusEncoder已停止")
print("双声道独立编码流已停止")
return true
}
/// 编码PCM音频数据
/// - Parameter pcmData: PCM音频数据
func encodePCMData(_ pcmData: Data) {
guard isEncodingActive, let encoder = jlOpusEncoder else { return }
guard !pcmData.isEmpty else { return }
// 将新数据加入队列
queueLock.lock()
pcmDataQueue.append(pcmData)
queueLock.unlock()
// 异步处理队列中的数据
processQueuedData()
/// 写入左声道 mono PCM(本端翻译音频)
func writeLeftPcm(_ pcm: Data) {
feedPcm(pcm, channel: Self.CHANNEL_LEFT)
}
/// 处理队列中的PCM数据
private func processQueuedData() {
guard isEncodingActive, let encoder = jlOpusEncoder else {
return
}
// 使用专用队列进行异步处理
if processingQueue == nil {
processingQueue = DispatchQueue(label: "com.opus.processing", qos: .userInitiated)
}
/// 写入右声道 mono PCM(对端翻译音频)
func writeRightPcm(_ pcm: Data) {
feedPcm(pcm, channel: Self.CHANNEL_RIGHT)
}
processingQueue?.async { [weak self, weak encoder] in
guard let self = self, let encoder = encoder else { return }
while self.isEncodingActive {
var chunkData: Data?
self.queueLock.lock()
if self.pcmDataQueue.count >= self.chunkSize {
chunkData = self.pcmDataQueue.subdata(in: 0..<self.chunkSize)
self.pcmDataQueue.removeSubrange(0..<self.chunkSize)
} else if self.pcmDataQueue.count > 0 {
let availableBytes = self.pcmDataQueue.count
let silentBytesNeeded = self.chunkSize - availableBytes
let silentData = Data(repeating: 0, count: silentBytesNeeded)
chunkData = Data()
chunkData?.append(self.pcmDataQueue)
chunkData?.append(silentData)
self.pcmDataQueue.removeAll()
}
self.queueLock.unlock()
/// 句尾补偿静音:一句翻译音频的真实 PCM 全部喂完后调用。
/// 往指定声道编码器再补一段零 PCM,把编码器内滞留的不足一帧真实尾音顶出成整帧,
/// 避免耳机端句尾被截断;多出的静音帧一并编码下发,给解码/DAC pipeline 排空余量。
/// - Parameters:
/// - channel: CHANNEL_LEFT / CHANNEL_RIGHT
/// - paddingMs: 补偿静音时长(ms),默认 400
func feedTailSilence(channel: UInt8, paddingMs: Int = 400) {
guard isDualEncoding, paddingMs > 0 else { return }
let silence = Data(repeating: 0, count: paddingMs * Self.PCM_BYTES_PER_MS)
feedPcm(silence, channel: channel)
print("句尾补偿静音已喂入 channel=\(channel == Self.CHANNEL_LEFT ? "左" : "右") paddingMs=\(paddingMs) bytes=\(silence.count)")
}
if let dataToEncode = chunkData {
guard self.isEncodingActive else { break }
encoder.opusEncode(dataToEncode)
/// 把 PCM 切成整帧送进对应声道的编码器;不足一帧的尾巴留在 remainder,等下一段 PCM 拼接。
/// 全程在该声道的串行队列上执行,保证「音频 → 句尾静音」的先后顺序不被打乱。
private func feedPcm(_ pcm: Data, channel: UInt8) {
guard isDualEncoding, !pcm.isEmpty else { return }
let isLeft = (channel == Self.CHANNEL_LEFT)
let queue = isLeft ? leftEncodeQueue : rightEncodeQueue
queue.async { [weak self] in
guard let self = self, self.isDualEncoding else { return }
guard let encoder = isLeft ? self.leftEncoder : self.rightEncoder else { return }
if isLeft { self.leftPcmRemainder.append(pcm) } else { self.rightPcmRemainder.append(pcm) }
while true {
guard self.isDualEncoding else { return }
let count = isLeft ? self.leftPcmRemainder.count : self.rightPcmRemainder.count
guard count >= self.chunkSize else { break }
// Data.prefix 返回的切片 startIndex 非零,用 Data(...) 重新 base 到 0 再交给编码器
let chunk: Data
if isLeft {
chunk = Data(self.leftPcmRemainder.prefix(self.chunkSize))
self.leftPcmRemainder.removeFirst(self.chunkSize)
} else {
break
chunk = Data(self.rightPcmRemainder.prefix(self.chunkSize))
self.rightPcmRemainder.removeFirst(self.chunkSize)
}
encoder.opusEncode(chunk)
}
}
}
/// 清空PCM数据队列
private func clearPCMQueue() {
queueLock.lock()
pcmDataQueue.removeAll()
queueLock.unlock()
print("PCM数据队列已清空")
/// 启动静音帧模板捕获:用独立编码器编码一段零 PCM,捕获输出首帧作为补齐用静音帧。
private func startSilenceFrameCapture() {
guard let encoder = silenceEncoder else { return }
let zeros = Data(repeating: 0, count: Self.SILENCE_PCM_BYTES)
var offset = 0
while offset + chunkSize <= zeros.count {
encoder.opusEncode(zeros.subdata(in: offset..<(offset + chunkSize)))
offset += chunkSize
}
}
/// 清理所有资源
func cleanup() {
stopDecoding()
stopEncoding()
stopDualEncodeStream()
}
}
@ -517,7 +546,8 @@ class SwiftOpusAudioProcessor: NSObject {
@available(iOS 13.0, *)
extension SwiftOpusAudioProcessor: JLOpusEncoderDelegate {
/// JLOpusEncoder编码回调
/// JLOpusEncoder编码回调:按编码器实例分流到左/右声道,或捕获静音帧模板。
/// 不切线程——直接在编码线程上回给 BleService 入队,保证同声道内帧序严格递增。
/// - Parameters:
/// - encoder: 编码器实例
/// - data: 编码后的Opus数据
@ -530,9 +560,19 @@ extension SwiftOpusAudioProcessor: JLOpusEncoderDelegate {
guard let opusData = data, !opusData.isEmpty else { return }
// 回调编码后的数据到主线程
DispatchQueue.main.async { [weak self] in
self?.callback?.onEncodedDataReceived(data: opusData)
if encoder === silenceEncoder {
// 只取首帧作为静音模板,后续帧丢弃
guard capturedSilenceFrame == nil else { return }
capturedSilenceFrame = opusData
print("静音帧模板已生成 size=\(opusData.count)B")
callback?.onSilenceFrameReady(frame: opusData)
return
}
if encoder === leftEncoder {
callback?.onAudioFrameEncoded(channel: Self.CHANNEL_LEFT, frame: opusData)
} else if encoder === rightEncoder {
callback?.onAudioFrameEncoded(channel: Self.CHANNEL_RIGHT, frame: opusData)
}
}
}

24
local_plugins/ble_service/lib/ble_service.dart

@ -175,30 +175,15 @@ class BleService {
/// 设置通话翻译调试参数(运行时生效)
/// [sendIntervalMs] 音频下行发送间隔(ms)
/// [bundleFrameCount] 下行合包帧数
/// [tier1FreeBytes]/[tier1SendMs] 第一档(最快):空余下限 / 发送间隔(ms)
/// [tier2FreeBytes]/[tier2SendMs] 第二档(中速):空余下限 / 发送间隔(ms)
/// [tier3FreeBytes]/[tier3SendMs] 第三档(最慢):空余下限(兜底) / 发送间隔(ms)
Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs,
int? bundleFrameCount,
int? tier1FreeBytes,
int? tier1SendMs,
int? tier2FreeBytes,
int? tier2SendMs,
int? tier3FreeBytes,
int? tier3SendMs,
}) async {
try {
final result = await _methodChannel
.invokeMethod<bool>('setCallTranslationDebugParams', {
if (sendIntervalMs != null) 'sendIntervalMs': sendIntervalMs,
if (bundleFrameCount != null) 'bundleFrameCount': bundleFrameCount,
if (tier1FreeBytes != null) 'tier1FreeBytes': tier1FreeBytes,
if (tier1SendMs != null) 'tier1SendMs': tier1SendMs,
if (tier2FreeBytes != null) 'tier2FreeBytes': tier2FreeBytes,
if (tier2SendMs != null) 'tier2SendMs': tier2SendMs,
if (tier3FreeBytes != null) 'tier3FreeBytes': tier3FreeBytes,
if (tier3SendMs != null) 'tier3SendMs': tier3SendMs,
});
return result ?? false;
} catch (e) {
@ -208,8 +193,7 @@ class BleService {
}
/// 获取当前通话翻译调试参数
/// 返回 {'sendIntervalMs','bundleFrameCount','tier1FreeBytes','tier1SendMs',
/// 'tier2FreeBytes','tier2SendMs','tier3FreeBytes','tier3SendMs'},失败返回空 Map
/// 返回 {'sendIntervalMs','bundleFrameCount'},失败返回空 Map
Future<Map<String, int>> getCallTranslationDebugParams() async {
try {
final result = await _methodChannel
@ -219,12 +203,6 @@ class BleService {
return {
'sendIntervalMs': readInt('sendIntervalMs'),
'bundleFrameCount': readInt('bundleFrameCount'),
'tier1FreeBytes': readInt('tier1FreeBytes'),
'tier1SendMs': readInt('tier1SendMs'),
'tier2FreeBytes': readInt('tier2FreeBytes'),
'tier2SendMs': readInt('tier2SendMs'),
'tier3FreeBytes': readInt('tier3FreeBytes'),
'tier3SendMs': readInt('tier3SendMs'),
};
} catch (e) {
print('获取通话翻译调试参数失败: $e');

2
pubspec.yaml

@ -1,7 +1,7 @@
name: voitrans
description: "Voitrans - AI Voice Assistant."
publish_to: "none"
version: 1.0.30+105
version: 1.0.32+111
environment:
sdk: ">=3.3.0 <4.0.0"

Loading…
Cancel
Save