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

21
lib/data/services/ble_manager.dart

@ -384,14 +384,6 @@ class BleManager extends GetxService {
_updateBatteryInfoFromData(data); _updateBatteryInfoFromData(data);
break; break;
case BleCommands.CONTROL_CODEC: 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); final bool codecSuccess = _isCodecControlSuccess(data);
if (_awaitingCodecOpen) { if (_awaitingCodecOpen) {
_isCodecActive.value = codecSuccess; _isCodecActive.value = codecSuccess;
@ -1257,27 +1249,14 @@ class BleManager extends GetxService {
/// 设置通话翻译调试参数(运行时生效) /// 设置通话翻译调试参数(运行时生效)
/// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数 /// [sendIntervalMs] 音频下行发送间隔(ms);[bundleFrameCount] 下行合包帧数
/// tierN* 为三档控流:每档 空余下限(FreeBytes) + 发送间隔(SendMs)
Future<bool> setCallTranslationDebugParams({ Future<bool> setCallTranslationDebugParams({
int? sendIntervalMs, int? sendIntervalMs,
int? bundleFrameCount, int? bundleFrameCount,
int? tier1FreeBytes,
int? tier1SendMs,
int? tier2FreeBytes,
int? tier2SendMs,
int? tier3FreeBytes,
int? tier3SendMs,
}) async { }) async {
try { try {
return await _bleService.setCallTranslationDebugParams( return await _bleService.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs, sendIntervalMs: sendIntervalMs,
bundleFrameCount: bundleFrameCount, bundleFrameCount: bundleFrameCount,
tier1FreeBytes: tier1FreeBytes,
tier1SendMs: tier1SendMs,
tier2FreeBytes: tier2FreeBytes,
tier2SendMs: tier2SendMs,
tier3FreeBytes: tier3FreeBytes,
tier3SendMs: tier3SendMs,
); );
} catch (e) { } catch (e) {
Logger.e('CALL_TRANS_DEBUG', '设置通话翻译调试参数失败: ${e.toString()}'); 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'; import '../../../core/utils/logger.dart';
/// 通话翻译调试控制器 /// 通话翻译调试控制器
/// 负责调试参数(音频下行发送间隔、下行合包帧数、三档控流)的读取、保存、下发, /// 负责调试参数(音频下行发送间隔、下行合包帧数)的读取、保存、下发,
/// 以及通话翻译编解码的启动/停止。 /// 以及通话翻译编解码的启动/停止。
class CallTranslationDebugController extends GetxController { class CallTranslationDebugController extends GetxController {
final BleManager _bleManager = Get.find<BleManager>(); 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 _kSendInterval = 'call_trans_debug_send_interval';
static const String _kBundleFrameCount = 'call_trans_debug_bundle_frames'; 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 defaultSendIntervalMs = 100;
static const int defaultBundleFrameCount = 5; 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; static const int minSendIntervalMs = 20;
@ -38,14 +25,6 @@ class CallTranslationDebugController extends GetxController {
static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms static const int sendIntervalStepMs = 20; // 音频下行间隔步长 20ms
static const int minBundleFrameCount = 1; static const int minBundleFrameCount = 1;
static const int maxBundleFrameCount = 20; 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) /// 音频下行发送间隔(ms)
final RxInt sendIntervalMs = defaultSendIntervalMs.obs; final RxInt sendIntervalMs = defaultSendIntervalMs.obs;
@ -53,14 +32,6 @@ class CallTranslationDebugController extends GetxController {
/// 下行合包帧数 /// 下行合包帧数
final RxInt bundleFrameCount = defaultBundleFrameCount.obs; 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; RxBool get isCodecActive => _bleManager.isCodecActiveRx;
@ -87,17 +58,6 @@ class CallTranslationDebugController extends GetxController {
bundleFrameCount.value = bundleFrameCount.value =
savedFrames.clamp(minBundleFrameCount, maxBundleFrameCount); 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); 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) { } catch (e) {
Logger.e('CALL_TRANS_DEBUG', '同步原生参数失败: $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) { void setSendInterval(int value) {
final snapped = (value / sendIntervalStepMs).round() * sendIntervalStepMs; final snapped = (value / sendIntervalStepMs).round() * sendIntervalStepMs;
sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs); sendIntervalMs.value = snapped.clamp(minSendIntervalMs, maxSendIntervalMs);
@ -153,53 +93,22 @@ class CallTranslationDebugController extends GetxController {
value.clamp(minBundleFrameCount, maxBundleFrameCount); 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() { void resetDefaults() {
sendIntervalMs.value = defaultSendIntervalMs; sendIntervalMs.value = defaultSendIntervalMs;
bundleFrameCount.value = defaultBundleFrameCount; 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 { Future<void> applyParams() async {
_storage.write(_kSendInterval, sendIntervalMs.value); _storage.write(_kSendInterval, sendIntervalMs.value);
_storage.write(_kBundleFrameCount, bundleFrameCount.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( final ok = await _bleManager.setCallTranslationDebugParams(
sendIntervalMs: sendIntervalMs.value, sendIntervalMs: sendIntervalMs.value,
bundleFrameCount: bundleFrameCount.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', Logger.i('CALL_TRANS_DEBUG',
'下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} ' '下发调试参数 interval=${sendIntervalMs.value}ms frames=${bundleFrameCount.value} result=$ok');
'档1(≥${tier1Free.value}B→${tier1Send.value}ms) '
'档2(≥${tier2Free.value}B→${tier2Send.value}ms) '
'档3(其余→${tier3Send.value}ms) result=$ok');
Get.snackbar( Get.snackbar(
'通话翻译调试', '通话翻译调试',
ok ? '参数已应用' : '参数下发失败(请确认设备已连接)', ok ? '参数已应用' : '参数下发失败(请确认设备已连接)',

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

@ -36,8 +36,6 @@ class CallTranslationDebugView
_buildSendIntervalCard(isDark), _buildSendIntervalCard(isDark),
SizedBox(height: 16.h), SizedBox(height: 16.h),
_buildBundleFrameCard(isDark), _buildBundleFrameCard(isDark),
SizedBox(height: 16.h),
_buildFlowControlCard(isDark),
SizedBox(height: 24.h), SizedBox(height: 24.h),
_buildActionButtons(isDark), _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) { Widget _title(bool isDark, String title, String subtitle) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, 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 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 * Azure Speech Plugin
* *
@ -1283,11 +1289,15 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC
let pcmData = audioData let pcmData = audioData
print("[\(serviceId)] 合成音频生成,PCM大小=\(pcmData.count),合成ID=\(utteranceId)") print("[\(serviceId)] 合成音频生成,PCM大小=\(pcmData.count),合成ID=\(utteranceId)")
if pcmData.count > 0 { if pcmData.count > 0 {
// B 路:对方翻译后的音频,灌入立体声录音器右声道(仅在录音开启时)
if serviceId == "B" { if serviceId == "B" {
// B 路:对方翻译后的音频,灌入立体声录音器右声道(仅在录音开启时)
plugin?.recordAzureBTtsPcm(pcmData) 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) { func onPartialAudio(sessionId: String, data: Data) {
if data.isEmpty { return } if data.isEmpty { return }
if serviceId == "A" { if serviceId == "A" {
// A 路:翻译后的音频通过 BLE 写到耳机外放给对方 // A 路:本端译音通过 BLE 写到耳机左声道,外放给对方
BleService.shared.writeExternalAudioData(data: data) bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: data) }
} else { } else {
// B 路:对方的翻译音频,本地 PCM 播放器直接播 // B 路:对方的翻译音频,本地 PCM 播放器直接播,同时下发到耳机右声道
plugin?.pushCallBPcm(data) plugin?.pushCallBPcm(data)
bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: data) }
} }
} }
func onSessionFinished(sessionId: String, finalText: String, finalAudio: 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 key = "\(serviceId):\(sessionId)"
let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? "" let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? ""
os_log("[DoubaoCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d", 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) { func onPartialAudio(sessionId: String, data: Data) {
if data.isEmpty { return } if data.isEmpty { return }
if serviceId == "A" { if serviceId == "A" {
BleService.shared.writeExternalAudioData(data: data) // A 路:本端译音下发到耳机左声道
bleLeftWriteQueue.async { BleService.shared.writeExternalLeftAudioData(data: data) }
} else { } else {
// B 路:对方译音本地播放,同时下发到耳机右声道
plugin?.pushCallBPcm(data) plugin?.pushCallBPcm(data)
bleRightWriteQueue.async { BleService.shared.writeExternalRightAudioData(data: data) }
} }
} }
func onSessionFinished(sessionId: String, finalText: String, finalAudio: 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 key = "\(serviceId):\(sessionId)"
let original = sourceTextCache.removeValue(forKey: key) ?? "" let original = sourceTextCache.removeValue(forKey: key) ?? ""
os_log("[AliyunCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d", os_log("[AliyunCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d",

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

@ -85,13 +85,6 @@ class BleCommandSender {
*/ */
fun notifyWakeupSignalReceived() fun notifyWakeupSignalReceived()
/**
* 通知设备上报某声道解码缓存空余字节数(F4=左 / F5=右)。App 据此动态调整该声道下发速率。
* @param channel BleConst.AUDIO_CHANNEL_LEFT(左) / BleConst.AUDIO_CHANNEL_RIGHT(右)
* @param freeBytes 该声道解码缓存空余字节数
*/
fun notifyCallDecodeFreeReported(channel: Int, freeBytes: Int)
/** /**
* 通知开始 AI 单次对话 * 通知开始 AI 单次对话
*/ */
@ -384,8 +377,6 @@ class BleCommandSender {
BleConst.CODEC_CONTROL_A2DP_PLAY -> "A2DP播放模式" BleConst.CODEC_CONTROL_A2DP_PLAY -> "A2DP播放模式"
BleConst.CODEC_CONTROL_CALL_RECORD_PLAY -> "通话记录播放模式" BleConst.CODEC_CONTROL_CALL_RECORD_PLAY -> "通话记录播放模式"
BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码" BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码"
BleConst.CODEC_CONTROL_DECODE_FREE_LEFT -> "左声道解码空余上报"
BleConst.CODEC_CONTROL_DECODE_FREE_RIGHT -> "右声道解码空余上报"
else -> "未知状态($codecStatus)" else -> "未知状态($codecStatus)"
} }
@ -592,28 +583,10 @@ class BleCommandSender {
} }
BleConst.CMD_CONTROL_CODEC -> { BleConst.CMD_CONTROL_CODEC -> {
// 设备主动上报编解码控制状态,关心左/右声道解码缓存空余字节数上报 (0xF4 左 / 0xF5 右),用于下行流控 // 设备主动上报编解码控制状态。帧格式: 0xCC 0x05 [len] [codecStatus, ...] [crc]
// 帧格式: 0xCC 0x05 [len] [codecStatus, ...] [crc];F4/F5 载荷为 [code][int32 大端]
val codecStatus = val codecStatus =
if (notifyData.isNotEmpty()) notifyData[0].toInt() and 0xFF else -1 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( val resultMap = mapOf(
"success" to true, "success" to true,
"command" to infoType, "command" to infoType,
@ -814,14 +787,4 @@ class BleCommandSender {
} }
return crc.toByte() 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 const val CODEC_CONTROL_A2DP_PLAY = 0xA2
/** mic和dac(音乐或者通话远端)声音 */ /** mic和dac(音乐或者通话远端)声音 */
const val CODEC_CONTROL_CALL_RECORD_PLAY = 0xA3 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 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>() //private val audioDataBuffer = mutableListOf<Byte>()
// 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按声道节拍下发。 // 音频数据发送相关:左右声道各一条独立缓存队列 + 独立发送线程,两线程错开 20ms 启动,各自按拍下发。
// 缓存兼作重连暂停期间的暂存与 F4/F5 降速积压;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。 // 缓存兼作重连暂停期间的暂存;LinkedBlockingDeque 线程安全(生产者=编码回调线程,消费者=对应发送线程)。
private val leftSendBuffer = LinkedBlockingDeque<ByteArray>() private val leftSendBuffer = LinkedBlockingDeque<ByteArray>()
private val rightSendBuffer = LinkedBlockingDeque<ByteArray>() private val rightSendBuffer = LinkedBlockingDeque<ByteArray>()
private var leftSendThread: Thread? = null private var leftSendThread: Thread? = null
@ -203,13 +203,6 @@ object BleService {
// 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改 // 音频下行发送间隔(ms),调试界面可在运行时调整;通过 setCallTranslationDebugParams() 修改
@Volatile @Volatile
private var audioSendIntervalNormal = DEFAULT_AUDIO_SEND_INTERVAL_NORMAL 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,超限丢最旧帧并告警。 // 声道缓存安全上限(纯防 OOM 兜底):现在缓存元素是单帧 opus,超限丢最旧帧并告警。
private const val CHANNEL_HOLD_BUFFER_MAX = 10000 private const val CHANNEL_HOLD_BUFFER_MAX = 10000
// ---- 下行合包(已从 OpusAudioManager 上移到 BleService) ---- // ---- 下行合包(已从 OpusAudioManager 上移到 BleService) ----
@ -225,16 +218,6 @@ object BleService {
// 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null // 补齐用静音 opus 帧模板(OpusAudioManager 运行时编码零PCM生成,经 onSilenceFrameReady 送来);未就绪时为 null
@Volatile @Volatile
private var silenceFrame: ByteArray? = null 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字节再发送 // 音频数据缓冲区,用于累积数据到80字节再发送
private val audioBuffer = mutableListOf<Byte>() private val audioBuffer = mutableListOf<Byte>()
// 重发机制相关常量 // 重发机制相关常量
@ -447,14 +430,6 @@ object BleService {
this@BleService.notifyWakeupSignalReceived() this@BleService.notifyWakeupSignalReceived()
} }
/**
* 通知设备上报某声道解码缓存空余字节数(F4=左 / F5=右),用于下行流控调速
*/
override fun notifyCallDecodeFreeReported(channel: Int, freeBytes: Int) {
// 使用外部类的方法处理,避免无限递归
this@BleService.onDecodeFreeReported(channel, freeBytes)
}
/** /**
* 通知开始 AI 单次对话 * 通知开始 AI 单次对话
*/ */
@ -1517,7 +1492,7 @@ object BleService {
/** /**
* 开始Opus数据流编码(通话翻译模式:左右声道独立编码) * 开始Opus数据流编码(通话翻译模式:左右声道独立编码)
* 输出合包格式:[4B 序号(大端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B * 输出合包格式:[4B 序号(小端 uint32)] + [1B 声道(0=左/1=右)] + [5 × opus(40B)] = 205B
* @return 是否成功开始编码 * @return 是否成功开始编码
*/ */
private fun startOpusEncodeStream(): Boolean { private fun startOpusEncodeStream(): Boolean {
@ -1560,7 +1535,7 @@ object BleService {
/** /**
* 启动左右两条独立发送线程(各发一路声道),右声道相对左声道错开 RIGHT_SEND_THREAD_STAGGER_MS 启动, * 启动左右两条独立发送线程(各发一路声道),右声道相对左声道错开 RIGHT_SEND_THREAD_STAGGER_MS 启动,
* 避免两路同一时刻抢 BLE 写。每条线程按自身声道的 paceTicks 控制下发频率,互不影响。 * 避免两路同一时刻抢 BLE 写。每条线程按固定拍长下发,互不影响。
*/ */
private fun startAudioSendThread() { private fun startAudioSendThread() {
if (leftSendThread?.isAlive == true || rightSendThread?.isAlive == true) { if (leftSendThread?.isAlive == true || rightSendThread?.isAlive == true) {
@ -1575,7 +1550,7 @@ object BleService {
/** /**
* 构建单声道发送线程:固定 audioSendIntervalNormal 一拍(睡到绝对时刻,不随耗时抖动), * 构建单声道发送线程:固定 audioSendIntervalNormal 一拍(睡到绝对时刻,不随耗时抖动),
* 每拍按该声道 paceTicks 判断是否到点,到点则从该声道缓存取队首一包下发。 * 每拍从该声道缓存取队首一包下发。
* 重连暂停期间只跳过下发、不清缓存(译音继续积压待回连续发)。 * 重连暂停期间只跳过下发、不清缓存(译音继续积压待回连续发)。
* @param isLeft true=左声道 / false=右声道 * @param isLeft true=左声道 / false=右声道
* @param startOffsetMs 相对启动时刻的错开毫秒(右声道用来与左声道错峰) * @param startOffsetMs 相对启动时刻的错开毫秒(右声道用来与左声道错峰)
@ -1585,8 +1560,6 @@ object BleService {
return Thread { return Thread {
CallLog.i(TAG, "音频发送线程[$chName]已启动 offset=${startOffsetMs}ms") CallLog.i(TAG, "音频发送线程[$chName]已启动 offset=${startOffsetMs}ms")
var nextTickAt = System.currentTimeMillis() + startOffsetMs var nextTickAt = System.currentTimeMillis() + startOffsetMs
var tick = 0L
var lastSendTick = -1L
// 首拍错开:右线程先睡 startOffsetMs 再进入循环,使两路发送时刻相互错峰 // 首拍错开:右线程先睡 startOffsetMs 再进入循环,使两路发送时刻相互错峰
val initDelay = nextTickAt - System.currentTimeMillis() val initDelay = nextTickAt - System.currentTimeMillis()
if (initDelay > 0) { if (initDelay > 0) {
@ -1597,14 +1570,10 @@ object BleService {
// 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发) // 重连暂停期间只跳过下发,不动缓存(译音继续积压,回连后续发)
if (!isDownlinkPaused) { if (!isDownlinkPaused) {
val buffer = if (isLeft) leftSendBuffer else rightSendBuffer val buffer = if (isLeft) leftSendBuffer else rightSendBuffer
val paceTicks = if (isLeft) leftPaceTicks else rightPaceTicks if (buffer.isNotEmpty()) trySendChannelChunk(isLeft)
if (buffer.isNotEmpty() && (lastSendTick < 0 || tick - lastSendTick >= paceTicks)) {
if (trySendChannelChunk(isLeft)) lastSendTick = tick
}
} }
// 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动 // 固定一拍:睡到下一拍绝对时刻,节拍稳定不随发送耗时抖动
nextTickAt += audioSendIntervalNormal nextTickAt += audioSendIntervalNormal
tick++
val sleepMs = nextTickAt - System.currentTimeMillis() val sleepMs = nextTickAt - System.currentTimeMillis()
if (sleepMs > 0) { if (sleepMs > 0) {
Thread.sleep(sleepMs) Thread.sleep(sleepMs)
@ -1699,7 +1668,7 @@ object BleService {
} }
val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap" val gapTag = if (seqGap == 1L) "" else " !gap=$seqGap"
CallLog.i(TAG, "音频下行发送 ch=$chTag seq=$seq Δ=${deltaMs}ms 帧=${realFrames.size}+静音$padCount size=${packet.size}B " + 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})") "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})")
// [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包) // [LAT-TRACE] 点3:右声道(对方译音)首包写到 BLE(每句首包)
@ -1744,15 +1713,13 @@ object BleService {
} }
/** /**
* 清空下行发送队列/左右声道缓存,并复位档位、包序、节拍与流控计数。 * 清空下行发送队列/左右声道缓存,并复位包序、节拍与写入拥塞计数。
* 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。 * 供停止发送线程(会话结束/清理)时调用;重连暂停期间**不**调用它——那时要保留缓存的译音待回连后续发。
*/ */
private fun resetDownlinkBuffers() { private fun resetDownlinkBuffers() {
leftSendBuffer.clear() // 清空左声道下行缓存队列 leftSendBuffer.clear() // 清空左声道下行缓存队列
rightSendBuffer.clear() // 清空右声道下行缓存队列 rightSendBuffer.clear() // 清空右声道下行缓存队列
audioBuffer.clear() // 清空音频缓冲区 audioBuffer.clear() // 清空音频缓冲区
leftPaceTicks = 1 // 档位复位为全速
rightPaceTicks = 1
leftPacketSeq = 0 // 复位左右声道下行包序号 leftPacketSeq = 0 // 复位左右声道下行包序号
rightPacketSeq = 0 rightPacketSeq = 0
lastAudioSendTime = -1L // 重置发送节拍计时,下次首包 Δ 从 0 开始 lastAudioSendTime = -1L // 重置发送节拍计时,下次首包 Δ 从 0 开始
@ -1778,18 +1745,14 @@ object BleService {
} }
/** /**
* 退出下行暂停:保留暂停期间缓存的译音,仅复位下发档位/发送拍号为全速, * 退出下行暂停:保留暂停期间缓存的译音,仅复位发送节拍计时,
* 让积压的译音尽快开始下发(设备刚回连解码缓存是空的),之后仍由 F4/F5 反馈动态调速防溢出。 * 让积压的译音按固定拍长继续下发。
*/ */
private fun exitDownlinkPause() { private fun exitDownlinkPause() {
if (!isDownlinkPaused) return if (!isDownlinkPaused) return
// 复位档位(不动缓存):从全速开始排空积压,F4/F5 会随即接管调速。
// 两声道线程内的 lastSendTick 会因 tick 已推进而立即到点,无需外部复位。
leftPaceTicks = 1
rightPaceTicks = 1
lastAudioSendTime = -1L lastAudioSendTime = -1L
isDownlinkPaused = false isDownlinkPaused = false
CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,档位复位全速续发缓存译音 " + CallLog.i(TAG, "[CALL_RECONNECT] 下行音频已恢复,续发缓存译音 " +
"buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})") "buf(L=${leftSendBuffer.size},R=${rightSendBuffer.size})")
} }
@ -2029,16 +1992,10 @@ object BleService {
* 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。 * 设置通话翻译调试参数(运行时生效,无需重连)。任一参数为 null 或非法值则不修改。
* @param sendIntervalMs 音频下行发送间隔(ms) * @param sendIntervalMs 音频下行发送间隔(ms)
* @param bundleFrameCount 下行合包帧数 * @param bundleFrameCount 下行合包帧数
* @param t1FreeBytes/t1SendMs 第一档(最快):空余下限 / 发送间隔(ms)
* @param t2FreeBytes/t2SendMs 第二档(中速):空余下限 / 发送间隔(ms)
* @param t3FreeBytes/t3SendMs 第三档(最慢):空余下限(兜底) / 发送间隔(ms)
*/ */
fun setCallTranslationDebugParams( fun setCallTranslationDebugParams(
sendIntervalMs: Long?, sendIntervalMs: Long?,
bundleFrameCount: Int?, bundleFrameCount: Int?
t1FreeBytes: Int? = null, t1SendMs: Long? = null,
t2FreeBytes: Int? = null, t2SendMs: Long? = null,
t3FreeBytes: Int? = null, t3SendMs: Long? = null
) { ) {
if (sendIntervalMs != null && sendIntervalMs > 0) { if (sendIntervalMs != null && sendIntervalMs > 0) {
audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L) audioSendIntervalNormal = sendIntervalMs.coerceIn(1L, 1000L)
@ -2048,36 +2005,16 @@ object BleService {
this.bundleFrameCount = bundleFrameCount.coerceIn(1, 20) this.bundleFrameCount = bundleFrameCount.coerceIn(1, 20)
Log.i(TAG, "[CALL_TRANS_DEBUG] 设置下行合包帧数: ${this.bundleFrameCount}") 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> { fun getCallTranslationDebugParams(): Map<String, Any> {
return mapOf( return mapOf(
"sendIntervalMs" to audioSendIntervalNormal, "sendIntervalMs" to audioSendIntervalNormal,
"bundleFrameCount" to bundleFrameCount, "bundleFrameCount" to bundleFrameCount
"tier1FreeBytes" to tier1FreeBytes,
"tier1SendMs" to tier1SendMs,
"tier2FreeBytes" to tier2FreeBytes,
"tier2SendMs" to tier2SendMs,
"tier3FreeBytes" to tier3FreeBytes,
"tier3SendMs" to tier3SendMs
) )
} }
@ -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 单次对话 * 向所有回调监听器分发开始 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" -> { "setCallTranslationDebugParams" -> {
val sendIntervalMs = (call.argument<Number>("sendIntervalMs"))?.toLong() val sendIntervalMs = (call.argument<Number>("sendIntervalMs"))?.toLong()
val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt() val bundleFrameCount = (call.argument<Number>("bundleFrameCount"))?.toInt()
val t1FreeBytes = (call.argument<Number>("tier1FreeBytes"))?.toInt() BleService.setCallTranslationDebugParams(sendIntervalMs, bundleFrameCount)
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
)
result.success(true) result.success(true)
} }
"getCallTranslationDebugParams" -> { "getCallTranslationDebugParams" -> {
@ -484,6 +475,4 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
// 发送升级失败事件 // 发送升级失败事件
sendEvent(statusEventSink, resultMap, "发送升级失败异常") 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_CLOSE: UInt8 = 0x00
/** 音乐或者通话远端声音 */ /** 音乐或者通话远端声音 */
static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1 static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1
/** mic和dac(音乐或者通话远端)声音 */ /** mic和dac(音乐或者通话远端)声音+翻译后重新编码 */
static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2 static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2
/** mic和dac(音乐或者通话远端)声音 */ /** mic和dac(音乐或者通话远端)声音 */
static let CODEC_CONTROL_CALL_RECORD_PLAY: UInt8 = 0xA3 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 static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1
/** 左声道 */ /** 左声道 */

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

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

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

2
pubspec.yaml

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

Loading…
Cancel
Save