Browse Source

上传iOS 的插件包

weicu
liwei1dao 5 months ago
parent
commit
dbdf1f9dca
  1. 31
      lib/data/services/ble_manager.dart
  2. 2
      lib/data/services/spotify_service.dart
  3. 35
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  4. 65
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  5. 25
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift
  6. 36
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift
  7. 48
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift
  8. 11
      local_plugins/ble_service/ios/ble_service/Package.swift
  9. 214
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift
  10. 84
      local_plugins/device_jieli/CHANGELOG.md
  11. 47
      local_plugins/device_jieli/README.md
  12. BIN
      local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar
  13. 2
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt
  14. 281
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt
  15. 17
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt
  16. 466
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt
  17. BIN
      local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta1_40255_20260324.aar
  18. BIN
      local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar
  19. 181
      local_plugins/device_jieli/ios/Classes/Audio/OpusStreamCodec.swift
  20. 27
      local_plugins/device_jieli/ios/Classes/Audio/PcmKit.swift
  21. 110
      local_plugins/device_jieli/ios/Classes/Bridge/EventDispatcher.swift
  22. 327
      local_plugins/device_jieli/ios/Classes/Bridge/MethodRouter.swift
  23. 177
      local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift
  24. 208
      local_plugins/device_jieli/ios/Classes/Feature/AssistantBridge.swift
  25. 193
      local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift
  26. 50
      local_plugins/device_jieli/ios/Classes/Feature/CustomCmdFeature.swift
  27. 67
      local_plugins/device_jieli/ios/Classes/Feature/DeviceInfoFeature.swift
  28. 202
      local_plugins/device_jieli/ios/Classes/Feature/DeviceRecordFeature.swift
  29. 165
      local_plugins/device_jieli/ios/Classes/Feature/JieliEventForwarders.swift
  30. 137
      local_plugins/device_jieli/ios/Classes/Feature/OtaFeature.swift
  31. 263
      local_plugins/device_jieli/ios/Classes/Feature/ScanFeature.swift
  32. 59
      local_plugins/device_jieli/ios/Classes/Feature/SpeechFeature.swift
  33. 217
      local_plugins/device_jieli/ios/Classes/Feature/Translation/Mode/CallTranslationModeHandler.swift
  34. 415
      local_plugins/device_jieli/ios/Classes/Feature/Translation/RcspTranslationRuntime.swift
  35. 134
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationAudioBridge.swift
  36. 110
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationCoordinator.swift
  37. 130
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationModeHandler.swift
  38. 35
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationStreams.swift
  39. 156
      local_plugins/device_jieli/ios/Classes/Feature/TranslationFeature.swift
  40. 31
      local_plugins/device_jieli/ios/Classes/Integration/JieliNativeDevicePlugin.swift
  41. 50
      local_plugins/device_jieli/ios/Classes/JielihomePlugin.swift
  42. 18
      local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/Headers/JLAV2Lib.h
  43. 96
      local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/Headers/jla_v2_codec_api.h
  44. BIN
      local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/JLAV2Lib
  45. 6
      local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/Modules/module.modulemap
  46. 41
      local_plugins/device_jieli/ios/device_jieli.podspec
  47. 44
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/Info.plist
  48. 26
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitKit.h
  49. 58
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitPlayer.h
  50. 66
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusDecoder.h
  51. 83
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusEncodeConfig.h
  52. 69
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusEncoder.h
  53. 59
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusFormat.h
  54. 67
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusToOgg.h
  55. 55
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWav.h
  56. 51
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWtg.h
  57. 54
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLSpeexDecoder.h
  58. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Info.plist
  59. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/JLAudioUnitKit
  60. 6
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Modules/module.modulemap
  61. 52
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/readme.txt
  62. 26
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLAudioUnitKit.h
  63. 58
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLAudioUnitPlayer.h
  64. 66
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusDecoder.h
  65. 83
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusEncodeConfig.h
  66. 69
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusEncoder.h
  67. 59
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusFormat.h
  68. 67
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusToOgg.h
  69. 55
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLPcmToWav.h
  70. 51
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLPcmToWtg.h
  71. 54
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLSpeexDecoder.h
  72. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Info.plist
  73. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/JLAudioUnitKit
  74. 6
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Modules/module.modulemap
  75. 234
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/_CodeSignature/CodeResources
  76. 52
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/readme.txt
  77. 44
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/Info.plist
  78. 95
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLBmpConvert.h
  79. 24
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLBmpConvertKit.h
  80. 89
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLBmpConvertOption.h
  81. 77
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLGifBin.h
  82. 34
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLImageConvertResult.h
  83. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Info.plist
  84. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/JLBmpConvertKit
  85. 6
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Modules/module.modulemap
  86. 95
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLBmpConvert.h
  87. 24
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLBmpConvertKit.h
  88. 89
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLBmpConvertOption.h
  89. 77
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLGifBin.h
  90. 34
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLImageConvertResult.h
  91. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Info.plist
  92. BIN
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/JLBmpConvertKit
  93. 6
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Modules/module.modulemap
  94. 192
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/_CodeSignature/CodeResources
  95. 44
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/Info.plist
  96. 127
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/DialManager.h
  97. 92
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/FatfsObject.h
  98. 27
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/JLDialSourceModel.h
  99. 30
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/JLDialUnit.h
  100. 115
      local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/JLDialUnitMgr.h

31
lib/data/services/ble_manager.dart

@ -311,6 +311,11 @@ class BleManager extends GetxService {
_handleExpandFunction(event);
} else if (event is TranslationAudioEvent) {
_handleTranslationAudio(event);
} else if (event is TranslationLogEvent) {
print('[BLE] [EVT] TranslationLog modeId=${event.modeId} ${event.content}');
} else if (event is TranslationErrorEvent) {
print('[BLE] [EVT] TranslationError modeId=${event.modeId} '
'code=${event.code} msg=${event.message}');
} else if (event is UnknownJieliEvent) {
print('[BLE] [EVT] 未识别事件: ${event.raw}');
}
@ -322,11 +327,17 @@ class BleManager extends GetxService {
}
void _handleConnectionState(ConnectionStateEvent e) {
// 关键诊断日志:原生 → Dart 链路上的最终一站。如果这里没打印,意味着
// 原生侧 `connectionState` 事件根本没到达 Dart(看 iOS `[JieliDispatcher]
// send #N type=connectionState` 是否成功 sink-ok)。
print('[BLE] [EVT] >>> _handleConnectionState addr=${e.address} state=${e.state} '
'(0=disconnect 1=ok 2=connecting)');
Logger.i('BleManager', 'JL 连接状态: ${e.address} → ${e.state}');
if (e.state == ConnectionStateEvent.connectionOk) {
currentDeviceAddress = e.address;
_connectionState.value = BleConnectionState.connected;
_isConnected.value = true;
print('[BLE] [EVT] >>> isConnected 翻成 true, currentDeviceAddress=${e.address}');
_ensureDeviceInfoStub();
// 用扫描期间最新一条 advertisement 立刻填上电量(SDK BleScanMessage 里就有
// leftDeviceQuantity / rightDeviceQuantity / chargingBinQuantity / 各 charging 位)
@ -335,11 +346,13 @@ class BleManager extends GetxService {
// 改到 RcspInitEvent 成功时再拉一次 + 之后再轮询几次保险。
} else if (e.state == ConnectionStateEvent.connectionConnecting) {
_connectionState.value = BleConnectionState.connecting;
print('[BLE] [EVT] >>> 连接中 (state=2)');
} else {
_connectionState.value = BleConnectionState.disconnected;
_isConnected.value = false;
_isCodecActive.value = false;
_stopBatteryWatchdog();
print('[BLE] [EVT] >>> isConnected 翻成 false (state=${e.state})');
}
}
@ -489,16 +502,29 @@ class BleManager extends GetxService {
'[BAT] 广播不再携带电量字段,连接后将通过 RCSP/BatteryEvent 补齐');
}
int _uplinkRxCount = 0;
int _downlinkRxCount = 0;
void _handleTranslationAudio(TranslationAudioEvent e) {
// streamId 来自 native TranslationStreams:in.uplink / in.downlink / in.mic ...
// 仅转发通话翻译两条输入流;out.* 由这里产生回灌指令而不是接收。
switch (e.streamId) {
case 'in.uplink':
_uplinkRxCount++;
if (_uplinkRxCount <= 5 || _uplinkRxCount % 100 == 1) {
Logger.i('CALL-RX',
'上行 #$_uplinkRxCount len=${e.pcm.length} sr=${e.sampleRate} ch=${e.channels} bits=${e.bitsPerSample} seq=${e.seq}');
}
if (!_inUplinkPcmController.isClosed) {
_inUplinkPcmController.add(e.pcm);
}
break;
case 'in.downlink':
_downlinkRxCount++;
if (_downlinkRxCount <= 5 || _downlinkRxCount % 100 == 1) {
Logger.i('CALL-RX',
'下行 #$_downlinkRxCount len=${e.pcm.length} sr=${e.sampleRate} ch=${e.channels} bits=${e.bitsPerSample} seq=${e.seq}');
}
if (!_inDownlinkPcmController.isClosed) {
_inDownlinkPcmController.add(e.pcm);
}
@ -761,15 +787,16 @@ class BleManager extends GetxService {
}
Future<bool> openA2DPDecoder() async {
Logger.i('CALL_TRANS_CMD', '启动通话翻译模式 (杰理 startTranslation)');
Logger.i('CALL_TRANS_CMD', '【启动】通话翻译模式 (杰理 startTranslation modeId=3)');
try {
await Jielihome.instance.startTranslation(
modeId: TranslationModeIds.callTranslation,
);
_isCodecActive.value = true;
Logger.i('CALL_TRANS_CMD', '✅【启动成功】通话翻译模式已下发 — 接下来等耳机回上行音频');
return true;
} catch (e) {
Logger.e('CALL_TRANS_CMD', '启动通话翻译模式失败: $e');
Logger.e('CALL_TRANS_CMD', '❌【启动失败】通话翻译模式: $e');
_isCodecActive.value = false;
return false;
}

2
lib/data/services/spotify_service.dart

@ -1,3 +1,4 @@
/* 暂时关闭:spotify_sdk plugin 已在 pubspec.yaml 注释掉,恢复时把本文件首尾的 block comment 解开即可。
import 'dart:async';
import 'dart:io';
import 'dart:convert';
@ -207,3 +208,4 @@ class SpotifService {
}
}
}
*/

35
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift

@ -74,9 +74,18 @@ public class AgentServicePlugin: NSObject, FlutterPlugin {
result(true)
return
}
print("liwei----------AgentServicePlugin startConversation")
let mode = (call.arguments as? [String: Any])?["mode"] as? String ?? "normal"
impl.startRecognition(mode: mode)
// 关键:Dart 端传过来的 `isExternalActive=true` 表示"音频从外部
// 设备(杰理耳机 RCSP 上行)灌进来,不要启动手机麦"。Android 在
// `AgentServicePlugin.kt:160` 里正确提取并传给 native;iOS 这里
// 之前完全没读这个参数,导致 `startRecognition` 用默认值 false,
// 永远走手机麦 → AI 对话即使耳机已连接也不会用耳机麦上行,TTS
// 也按 .microphone 路径配 audio session(默认到手机扬声器)。
// 现在补齐:取出来透传成 `useBle`,与 Android 行为对齐。
let args = call.arguments as? [String: Any]
let mode = args?["mode"] as? String ?? "normal"
let isExternalActive = args?["isExternalActive"] as? Bool ?? false
print("liwei----------AgentServicePlugin startConversation mode=\(mode) isExternalActive=\(isExternalActive)")
impl.startRecognition(useBle: isExternalActive, mode: mode)
result(true)
case "isInterrupt":
if let args = call.arguments as? [String: Any],
@ -92,6 +101,26 @@ public class AgentServicePlugin: NSObject, FlutterPlugin {
case "recognizeCallback":
impl.recognizeCallback()
result(true)
case "pushAudioData":
// Dart 端通过 device_jieli AssistantAudioEvent 拿到 PCM 后调用本方法
// 注入到 ASR。`isExternalActive=true` 模式下的唯一上行通路。
// 之前这里漏了 case,调用直接落到 default 抛 MissingPluginException,
// Dart 侧 catchError 静默吞掉 —— 结果 BLE 帧一直发但讯飞 audioFrames=0。
guard let args = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "参数缺失", details: nil))
return
}
let pcmData: Data
if let typed = args["data"] as? FlutterStandardTypedData {
pcmData = typed.data
} else if let bytes = args["data"] as? [UInt8] {
pcmData = Data(bytes)
} else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "data 参数缺失或类型错误", details: nil))
return
}
result(impl.pushAudioData(pcmData))
case "stopConversation":
// 防抖:短时间重复调用直接忽略

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

@ -128,6 +128,10 @@ import ble_service
// 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。
private var broadcastPeerTranslate: Bool = false
// 杰理 SCO PCM 推送计数(每 100 帧打一次日志,避免刷屏)
private var astAudioPushCountA: Int64 = 0
private var astAudioPushCountB: Int64 = 0
fileprivate func pushCallBPcm(_ data: Data) {
guard broadcastPeerTranslate else { return }
callBPcmPlayer.feed(data)
@ -145,6 +149,42 @@ import ble_service
return Data(count: data.count)
}
/// 把杰理 SCO 上行 PCM(己方麦克风)按当前 provider 推到 A 路 AST。
fileprivate func pushAstAudioToA(_ data: Data) {
astAudioPushCountA &+= 1
if astAudioPushCountA % 100 == 1 {
os_log("pushAstAudioToA: provider=%{public}@ size=%d count=%lld",
log: ctLog, type: .info, currentAstProvider, data.count, astAudioPushCountA)
}
let payload = gateAgainstBPlayback(data)
switch currentAstProvider {
case "volcano":
_ = doubaoAstHelperA.pushAudioData(payload)
case "alibaba":
_ = bailianAstHelperA.pushAudioData(payload)
default:
azureAstHelperA.pushAudioData(audioData: payload)
}
}
/// 把杰理 SCO 下行 PCM(对端通话音)按当前 provider 推到 B 路 AST。
fileprivate func pushAstAudioToB(_ data: Data) {
astAudioPushCountB &+= 1
if astAudioPushCountB % 100 == 1 {
os_log("pushAstAudioToB: provider=%{public}@ size=%d count=%lld",
log: ctLog, type: .info, currentAstProvider, data.count, astAudioPushCountB)
}
let payload = gateAgainstBPlayback(data)
switch currentAstProvider {
case "volcano":
_ = doubaoAstHelperB.pushAudioData(payload)
case "alibaba":
_ = bailianAstHelperB.pushAudioData(payload)
default:
azureAstHelperB.pushAudioData(audioData: payload)
}
}
// 音频数据相关
private var audioDataEventChannel: FlutterEventChannel?
internal var audioDataEventSink: FlutterEventSink?
@ -875,6 +915,31 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
os_log("[CALL] setBroadcastPeerTranslate=%{public}@", log: ctLog, type: .info, enabled ? "true" : "false")
result(true)
// Dart 把杰理 runtime 解码后的 SCO PCM 推进来(与 Android `pushExternalAudio` 对齐):
// leg = "A" / "UPLINK" → 本机麦克风 → A 路 AST
// leg = "B" / "DOWNLINK" → 对端通话音 → B 路 AST
// pcm 必须是 16kHz / mono / 16bit / little-endian。
case "pushExternalAudio":
let args = call.arguments as? [String: Any]
let leg = (args?["leg"] as? String)?.uppercased() ?? ""
let pcm: Data = {
if let typed = args?["pcm"] as? FlutterStandardTypedData { return typed.data }
return Data()
}()
if pcm.isEmpty {
result(false); return
}
switch leg {
case "A", "UPLINK":
pushAstAudioToA(pcm)
case "B", "DOWNLINK":
pushAstAudioToB(pcm)
default:
result(FlutterError(code: "BAD_LEG", message: "leg must be A/uplink or B/downlink, got=\(leg)", details: nil))
return
}
result(true)
case "dispose":
// 让 init 的 Task 在 sleep 之后比对 generation 时直接 return,避免落到已 dispose 的 helper 上
_ = nextAstInitGeneration()

25
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift

@ -1610,16 +1610,25 @@ private func applyAudioSessionForTTS() -> Bool {
if self.currentRecognitionMode == "phone_call" {
// 设计:对齐 Android `BleAgent` 的"设备录音模式" —— 上行 BLE,下行
// 让系统自由路由。Android 走 `AudioTrack` USAGE_MEDIA,系统连了 A2DP
// 自然走 A2DP,没连就走手机扬声器,不强制任何一边。
//
// 之前 iOS 在没耳机分支里塞 `.defaultToSpeaker` + 下面 1667 行还会再来
// 一次 `overrideOutputAudioPort(.speaker)` —— 等于"无论如何都打到手机
// 扬声器"。结果就是用户配对了 A2DP 也没用(被 override 抢回去),
// AI 对话播报永远从手机出。
//
// 改成 `.allowBluetoothA2DP` 两个分支都加;没配对 A2DP 的设备上
// iOS 默认走手机扬声器(不需要我们 override),配对了就自动走耳机。
desiredCategory = .playAndRecord
desiredMode = .default
desiredOptions = [.allowBluetoothA2DP]
if hasHeadphones {
desiredMode = .default
desiredOptions = [.allowBluetoothA2DP]
// 用手机麦做兜底(设备录音模式下其实不用,但保留与之前一致)
if let inputs = audioSession.availableInputs, let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) {
try? audioSession.setPreferredInput(builtInMic)
}
} else {
desiredMode = .voiceChat
desiredOptions = [.allowBluetooth, .defaultToSpeaker]
}
// 不再 mixWithOthers:TTS 期间需要打断外部/内部其他音频
} else if self.currentRecognitionMode == "push_to_talk" || self.currentRecognitionMode == "normal" || self.currentRecognitionMode == "ble_wakeup" {
@ -1664,9 +1673,9 @@ private func applyAudioSessionForTTS() -> Bool {
}
try audioSession.setActive(true)
self.internalSessionActive = true
if self.currentRecognitionMode == "phone_call", !hasHeadphones {
_ = try? audioSession.overrideOutputAudioPort(.speaker)
}
// 注:原来这里在 phone_call 没耳机时 override 到 speaker,
// 跟"设备录音模式 下行让系统自由路由"的设计冲突,已删除。
// 想强制扬声器的场景请在调用方显式做,不要藏在 TTS 内部。
break
} catch {
if attempt == 2 {

36
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/DoubaoE2ETranslateHelper.swift

@ -75,6 +75,11 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate {
private var pumpTimer: DispatchSourceTimer?
private let pumpQueue = DispatchQueue(label: "com.azure.speech.DoubaoE2E.pump", qos: .userInitiated)
// pushAudioData 日志节流:累计调用次数与字节数,每 50 次打印一次
private var pushAudioLogCount: Int = 0
private var pushAudioLogBytes: Int = 0
private let pushAudioLogInterval: Int = 50
/**
* 初始化助手(异步)
* 返回是否成功触发了连接逻辑
@ -202,20 +207,45 @@ class DoubaoE2ETranslateHelper: NSObject, URLSessionWebSocketDelegate {
* 等首条服务端消息到达再 flush 进 audioSendBuffer。
*/
func pushAudioData(_ data: Data) -> Bool {
if isDisposed { return false }
if data.isEmpty { return true }
if !isStarted || !isWebSocketConnected {
if isDisposed {
os_log("pushAudioData skipped: isDisposed=true, size=%{public}d", log: log, type: .info, data.count)
return false
}
if data.isEmpty {
os_log("pushAudioData received empty data", log: log, type: .info)
return true
}
let routedToPending = (!isStarted || !isWebSocketConnected)
if routedToPending {
lock.lock()
while pendingAudioData.count >= maxPendingAudioChunks {
pendingAudioData.removeFirst()
}
pendingAudioData.append(data)
let pendingCount = pendingAudioData.count
lock.unlock()
pushAudioLogCount += 1
pushAudioLogBytes += data.count
if pushAudioLogCount % pushAudioLogInterval == 1 {
os_log("pushAudioData -> pending: size=%{public}d, pendingCount=%{public}d, totalCalls=%{public}d, totalBytes=%{public}d, isStarted=%{public}@, isWsConnected=%{public}@",
log: log, type: .info,
data.count, pendingCount, pushAudioLogCount, pushAudioLogBytes,
isStarted ? "true" : "false",
isWebSocketConnected ? "true" : "false")
}
return true
}
audioSendBufferLock.lock()
audioSendBuffer.append(data)
let bufferSize = audioSendBuffer.count
audioSendBufferLock.unlock()
pushAudioLogCount += 1
pushAudioLogBytes += data.count
if pushAudioLogCount % pushAudioLogInterval == 1 {
os_log("pushAudioData -> buffer: size=%{public}d, bufferSize=%{public}d, totalCalls=%{public}d, totalBytes=%{public}d",
log: log, type: .info,
data.count, bufferSize, pushAudioLogCount, pushAudioLogBytes)
}
return true
}

48
local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -139,12 +139,19 @@ public class SimpleAudioReceiver: NSObject {
do {
try configureAudioSession()
let isHybridExternalMode = audioSourceType == .external && (currentRecognitionMode == "phone_call" || currentRecognitionMode == "push_to_talk")
if audioSourceType == .microphone || isHybridExternalMode {
// 见 configureAudioSession 上方注释:iOS 已对齐 Android `BleAgent` —
// useBle=true → audioSourceType=.external → 永远走 runExternalCapture,
// 由 device_jieli 的 AssistantAudioEvent → AgentService.pushAudioData 注入。
// 之前这里有个 isHybridExternalMode(external + phone_call/push_to_talk)
// 会错路到 runMicrophoneCapture(),试图开 AVAudioEngine 抓手机麦 —— 但
// configureAudioSession 在 .external 分支已经把 session 设成 .playback,
// 不允许 input,AVAudioEngine 起不来抛 OSStatus -50 paramErr,日志里
// 看到的 "配置或启动音频捕获失败" + "Failed to set properties, error: -50"
// 就是这条线。
if audioSourceType == .microphone {
runMicrophoneCapture()
} else { // True external mode
} else { // .external
runExternalCapture()
}
} catch {
@ -161,10 +168,26 @@ public class SimpleAudioReceiver: NSObject {
var desiredCategory: AVAudioSession.Category
var desiredMode: AVAudioSession.Mode
var desiredOptions: AVAudioSession.CategoryOptions
let isHybridExternalMode = audioSourceType == .external && (currentRecognitionMode == "phone_call" || currentRecognitionMode == "push_to_talk")
if audioSourceType == .microphone || isHybridExternalMode {
// 设计:参考 Android `BleAgent` 路径(上行 BLE / 下行交给系统路由)。
//
// 之前 iOS 把 (.external && phone_call/push_to_talk) 当成"混合模式",
// 仍按 .playAndRecord + 强制 builtInMic 走 — 等价于"用 BLE 上行 + 手机麦
// 兜底"。结果两个副作用:
// 1) 设了 `setPreferredInput(builtInMic)`,iOS 把音频会话归到 voice
// record 类,下行 TTS 默认路由就被拉到内置扬声器;
// 2) 在非 A2DP 配对场景里加了 `.defaultToSpeaker`,再次把 A2DP 出口顶掉。
// Android `BleAgent.kt` 这个模式根本不碰手机麦 — `useBle=true` →
// ASR 用 .external,全部 PCM 由 device_jieli 的 AssistantAudioEvent
// 经 `pushAudioData` 灌入。下行 TTS 直接 `AudioTrack` USAGE_MEDIA,
// 系统自己决定走 A2DP 还是手机扬声器。
//
// 所以 iOS 也按"纯播放"配置:.playback + .spokenAudio + .allowBluetoothA2DP,
// 不再申请录音权限通道,也不再强制 builtInMic / defaultToSpeaker。
// 这样耳机以经典蓝牙配对过的话 TTS 走 A2DP;没配过的话回退手机扬声器
// —— 跟 Android 行为对齐。
if audioSourceType == .microphone {
// --- 麦克风输入逻辑 (包括混合模式) ---
if currentRecognitionMode == "phone_call" {
if isHeadphones {
@ -217,7 +240,10 @@ public class SimpleAudioReceiver: NSObject {
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
// --- 路由设置 ---
if audioSourceType == .microphone || isHybridExternalMode {
// 见上方注释:iOS 已对齐 Android `BleAgent` —— 设备录音模式下
// audioSourceType 永远是 .external,根本不进 .microphone 分支,
// 所以路由设置只在"真的用手机麦"时才执行。
if audioSourceType == .microphone {
// 为需要麦克风的场景设置输入输出
if desiredCategory == .playAndRecord {
if isHeadphones {
@ -235,12 +261,6 @@ public class SimpleAudioReceiver: NSObject {
try audioSession.setPreferredInput(builtInMic)
print("耳机模式下,为 \(currentRecognitionMode) 模式强制设置内置麦克风")
}
} else if isHybridExternalMode {
// 混合模式: 强制使用内置麦克风
if let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) {
try audioSession.setPreferredInput(builtInMic)
print("混合外部模式: 首选输入已设置为内置麦克风")
}
} else if let btInput = inputs.first(where: { $0.portType == .bluetoothHFP }), isHeadphones {
// 其他耳机模式: 优先使用蓝牙耳机麦克风
try audioSession.setPreferredInput(btInput)

11
local_plugins/ble_service/ios/ble_service/Package.swift

@ -13,8 +13,11 @@ let package = Package(
.target(
name: "ble_service",
dependencies: [
"opus",
"JLAudioUnitKit"
"opus"
// 注:JLAudioUnitKit 已移除 —— ble_service 整体在向 device_jieli
// 迁移,PCM→OPUS 编码路径砍掉以解开 SPM "Multiple commands produce
// JLAudioUnitKit.framework" 冲突(device_jieli 已经 vendor 同名 framework)。
// 详见 Sources/ble_service/SwiftOpusAudioProcessor.swift 顶部注释。
],
path: "Sources/ble_service",
linkerSettings: [
@ -27,10 +30,6 @@ let package = Package(
.binaryTarget(
name: "opus", // 改为与 xcframework 内部模块名称一致
path: "opus.xcframework"
),
.binaryTarget(
name: "JLAudioUnitKit",
path: "JLAudioUnitKit.xcframework"
)
]
)

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

@ -1,7 +1,24 @@
import Foundation
import AVFoundation
import opus
import JLAudioUnitKit
// MARK: - ble_service 弃用迁移期说明
// ----------------------------------------------------------------------------
// 整个 ble_service plugin 在迁移到 device_jieli。
//
// 编码路径(PCM → OPUS → BLE 下发到耳机外放)原本由 JLAudioUnitKit 提供,
// 现已被砍掉以解开 "Multiple commands produce JLAudioUnitKit.framework" 编译
// 冲突(两个 SPM plugin 都 vendor 同名 binaryTarget)。
//
// 砍掉后受影响的 native 调用(暂时变成 noop):
// - AzureSpeechPlugin.writeExternalAudioData → BleService.shared.writeExternalAudioData
// (通话翻译 A 路 / TTS 把 PCM 编码发到耳机外放给对方)
// - AgentServiceImpl 在 ble_wakeup 播报结束后 BleService.shared.openB1Encoder()
// (重启耳机端编码器)
//
// 迁移后这些路径应改走 device_jieli 的 RcspTranslationRuntime / AssistantBridge 下行。
// 短期内保留方法签名让所有调用方编译通过;废弃时机由 plugin 整体下线决定。
// ----------------------------------------------------------------------------
@available(iOS 13.0, *)
class SwiftOpusAudioProcessor: NSObject {
@ -25,20 +42,12 @@ 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()
/// 每次处理的数据块大小(字节)
private let chunkSize = 640
/// 异步处理队列
private var processingQueue: DispatchQueue?
// MARK: - 编码器(已废弃)
// 原本通过 JLAudioUnitKit 提供 OPUS encoding,迁移到 device_jieli 后这里全部
// 不再做实际工作。保留 isEncodingActive 仅用于让外部状态查询不变。
// 上次 noop 调用日志的时间(节流,避免 encodePCMData 高频调用时日志刷屏)
private var lastNoopEncodeLogMs: Int64 = 0
// MARK: - 初始化和清理
override init() {
@ -367,145 +376,38 @@ class SwiftOpusAudioProcessor: NSObject {
}
/// 启动JLOpusEncoder编码(完善版本)
/// - Parameters:
/// - sampleRate: 采样率(默认16000Hz)
/// - channels: 声道数(默认1)
/// - frameRate: 帧长度(默认20ms)
/// - isHeader: 是否包含头部信息
/// - Returns: 是否启动成功
func startEncoding(sampleRate: Int32 = 16000,
channels: Int32 = 1,
frameRate: Int32 = 20,
isHeader: Bool = false) -> Bool {
if isEncodingActive {
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
}
// 初始化JLOpusEncoder
jlOpusEncoder = JLOpusEncoder(format: format, delegate: self)
guard jlOpusEncoder != nil else {
print("JLOpusEncoder初始化失败")
return false
}
/// 启动 OPUS 编码(已废弃 / noop)
/// 迁移到 device_jieli 后保留方法签名让 native 调用方编译通过。
/// 调用方应改走 Jielihome / device_jieli 的下行链路。
@discardableResult
func startEncoding(sampleRate: Int32 = 16000,
channels: Int32 = 1,
frameRate: Int32 = 20,
isHeader: Bool = false) -> Bool {
if isEncodingActive { return true }
isEncodingActive = true
print("JLOpusEncoder启动成功")
print("[ble_service:deprecated] startEncoding noop (sampleRate=\(sampleRate) ch=\(channels) frameRate=\(frameRate)) — migrate to device_jieli")
return true
}
/// 停止JLOpusEncoder编码(完善版本)
/// - Returns: 是否停止成功
/// 停止 OPUS 编码(已废弃 / noop)
@discardableResult
func stopEncoding() -> Bool {
if !isEncodingActive {
return false
}
guard isEncodingActive else { return false }
isEncodingActive = false
// 清空PCM数据队列
clearPCMQueue()
// 释放JLOpusEncoder资源
jlOpusEncoder?.opusOnRelease()
jlOpusEncoder = nil
encoderFormat = nil
processingQueue = nil
print("JLOpusEncoder已停止")
print("[ble_service:deprecated] stopEncoding noop — migrate to device_jieli")
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()
}
/// 处理队列中的PCM数据
private func processQueuedData() {
guard isEncodingActive, let encoder = jlOpusEncoder else {
return
}
// 使用专用队列进行异步处理
if processingQueue == nil {
processingQueue = DispatchQueue(label: "com.opus.processing", qos: .userInitiated)
}
processingQueue?.async { [weak self, weak encoder] in
guard let self = self, let encoder = encoder else { return }
while self.isEncodingActive {
var chunkData: Data?
self.queueLock.lock()
if self.pcmDataQueue.count >= self.chunkSize {
chunkData = self.pcmDataQueue.subdata(in: 0..<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 {
guard self.isEncodingActive else { break }
encoder.opusEncode(dataToEncode)
} else {
break
}
}
/// 编码 PCM 音频数据(已废弃 / noop,日志节流到 1s 一条)
func encodePCMData(_ pcmData: Data) {
let now = Int64(Date().timeIntervalSince1970 * 1000)
if now - lastNoopEncodeLogMs >= 1000 {
lastNoopEncodeLogMs = now
print("[ble_service:deprecated] encodePCMData dropped \(pcmData.count) B (encoder removed; migrate to device_jieli)")
}
}
/// 清空PCM数据队列
private func clearPCMQueue() {
queueLock.lock()
pcmDataQueue.removeAll()
queueLock.unlock()
print("PCM数据队列已清空")
}
/// 清理所有资源
func cleanup() {
stopDecoding()
@ -513,29 +415,5 @@ class SwiftOpusAudioProcessor: NSObject {
}
}
// MARK: - JLOpusEncoderDelegate
@available(iOS 13.0, *)
extension SwiftOpusAudioProcessor: JLOpusEncoderDelegate {
/// JLOpusEncoder编码回调
/// - Parameters:
/// - encoder: 编码器实例
/// - data: 编码后的Opus数据
/// - error: 错误信息
func opusEncoder(_ encoder: JLOpusEncoder, data: Data?, error: (any Error)?) {
if let error = error {
print("Opus编码错误: \(error.localizedDescription)")
return
}
guard let opusData = data, !opusData.isEmpty else { return }
// 回调编码后的数据到主线程
DispatchQueue.main.async { [weak self] in
self?.callback?.onEncodedDataReceived(data: opusData)
}
}
}

84
local_plugins/device_jieli/CHANGELOG.md

@ -1,3 +1,87 @@
## 0.0.2
### iOS 端补全 / 与 Android 对齐(参考 `JIELI-IOS-Release`)
#### 框架
- 拷贝 `JIELI-IOS-Release/Libs/` 下全部 `.xcframework` + sample 项目里的 `JLAudioUnitKit.xcframework`(OPUS 编解码所需)到 `ios/Frameworks/`,通过 `vendored_frameworks` 引入。
- `pubspec.yaml` 新增 `ios` 平台条目;`ios/device_jieli.podspec` 配置完成。
#### 与 Android 同名 / 同形态的核心通路(按 user 要求全量对齐)
| 通路 | Android | iOS |
|---|---|---|
| 设备连接 | `ConnectFeature` → `JL_BluetoothManager.connect` + `setUseDeviceAuth(true)` | `ConnectFeature` → `JL_BLEMultiple.connectEntity` + `authEnable=true`;paired 后**自动调 `cmdTargetFeatureResult`** + `cmdGetSystemInfo(.COMMON)` 把 model 拉满,再外抛 `rcspInit` |
| 电量推送 | `OnRcspEventListener.onBatteryChange` → `battery` 事件 | `DeviceInfoEventForwarder` 订阅 `kJL_MANAGER_HEADSET_ADV` / `kJL_MANAGER_SYSTEM_INFO`,合成 `battery` 事件(带 `level/left/right/case` 字段,去重) |
| 通话状态 | `onPhoneCallStatusChange` → `phoneCallStatus` | `DeviceInfoEventForwarder` 订阅 `kJL_MANAGER_CALL_STATUS` |
| 通话翻译 mode=3 | `CallTranslationModeHandler` (uplink/downlink × OPUS 编解码) | `CallTranslationModeHandler` 完整复刻:`OpusStreamDecoder(channels=1, dataSize=40)` 分流 escoUp/escoDown,下行 PCM `OpusStreamEncoder` 编码后 `trWriteAudio(.escoUp/.escoDown)` |
| 通话翻译 mode=6(立体声)| `StereoCallTranslationModeHandler` + `PcmKit.splitStereo16` | `StereoCallTranslationModeHandler` 完整复刻:`OpusStreamDecoder(channels=2, dataSize=80)` → `PcmKit.splitStereo16` → uplink/downlink 两路 |
| MODE_CALL_TRANSLATION + OPUS 自动升级到 stereo | ✅ | ✅(`bypassStereoUpgrade=true` 可禁用,行为与 Android 一致) |
| AI 助理 | `JieliAssistantPort` (MODE_RECORD + STRATEGY_DEVICE_ALWAYS_RECORDING + OPUS) | `AssistantBridge` 复刻:通过 `TranslationSession.acquire` 拿独占所有权,`trStartTranslateMode(.onlyRecord, OPUS, ch=1)` + `recordtype=.byDevice`,`OpusStreamDecoder(channels=1, dataSize=40)` 解出 16k mono PCM → `assistantAudio` |
| 设备录音 | `JieliDeviceRecordPort` (mode=6 stereo + DEVICE_ALWAYS_RECORDING) | `DeviceRecordFeature` 复刻:`trStartTranslateMode(.callTranslateStereo, OPUS, ch=2)` + `recordtype=.byDevice`,`OpusStreamDecoder(channels=2, dataSize=80)` 解出 stereo PCM → `deviceRecordAudio` |
| OTA | 自实现 cmd-level 状态机 | 直接走 `JL_BLEMultiple.otaFunc(...)` 高层 API,结果回调映射到 `otaState` / `otaError` |
#### TranslationSession 互斥模型
iOS SDK 一台设备只有一个 `JLTranslationManager`,与 Android 同样要求"翻译 / 助理 / 设备录音"三选一互斥。新增 [TranslationSession](ios/Classes/core/TranslationSession.swift):
- 内部持有 `JLTranslationManager` 实例,自身作为 `JLTranslationManagerDelegate`
- 通过 `acquire(owner:)` / `release(owner:)` 协调当前会话所有者
- 所有 SDK 回调(`onReceiveAudioData` / `onModeChange` / `onError` / `onSendAudioQueueOver` / `isOnCalling`)fan-out 到当前 owner
#### 文件清单(`ios/Classes/`)
```
JielihomePlugin.swift # Flutter 入口
MethodRouter.swift # MethodChannel 路由
EventDispatcher.swift # EventChannel 总线
JieliHomeServer.swift # 单例 + per-device session
core/TranslationSession.swift # 设备级翻译会话所有者协调
audio/OpusStreamCodec.swift # JLOpusDecoder/Encoder 包装
audio/PcmKit.swift # 16-bit stereo split / interleave
event/BluetoothEventForwarder.swift # BLE 状态 / 连接通知
event/CustomEventForwarder.swift # 厂商扩展指令
event/DeviceInfoEventForwarder.swift # 电量 / 通话 / 系统信息
feature/ScanFeature.swift
feature/ConnectFeature.swift
feature/DeviceInfoFeature.swift
feature/CustomCmdFeature.swift
feature/OtaFeature.swift
feature/TranslationFeature.swift # 6-mode dispatcher
feature/translation/TranslationConstants.swift
feature/translation/TranslationModeHandler.swift
feature/translation/mode/RecordModeHandler.swift (mode=1 + RecordingTranslation mode=2)
feature/translation/mode/CallTranslationModeHandler.swift (mode=3)
feature/translation/mode/StereoCallTranslationModeHandler.swift (mode=6)
feature/translation/mode/AudioAndFaceModeHandlers.swift (mode=4 + mode=5)
feature/AssistantBridge.swift
feature/DeviceRecordFeature.swift
feature/SpeechFeature.swift # 状态机骨架(详见下方)
```
### 与 Android 实际差异
| 项 | Android | iOS | 影响 |
|---|---|---|---|
| `address` 含义 | BLE MAC | CoreBluetooth identifier (UUID) | iOS 系统对 BLE MAC 永久匿名;上层若用 address 做主键需要做厂商适配 |
| 录音策略 | 三选一:CUSTOM/ALWAYS/AUTO | 二选一:`.byPhone`(=CUSTOM) / `.byDevice`(=ALWAYS) | iOS SDK 不暴露 AUTO(VAD 自动启停),调用 `strategy="auto"` 时回退到 default |
| `versionCode` 字段 | SDK 有 numeric versionCode | SDK 只有 versionFirmware(字符串) | `deviceSnapshot` 里 versionCode 为 null |
| `sendCustomCmd` opCode | SDK 有专门字段 | iOS `JL_CustomManager.cmdCustomData:` 无 opCode 字段 | iOS 实现把 Dart 传入的 opCode 拼到 payload 头部(业务双方约定) |
| `OTA` 状态机 | 插件自实现 cmd 级状态机 | 直接走 SDK 高层 API | Dart `otaStart` 的 `blockSize` / `fileFlag` 参数 iOS 端忽略 |
| `speechStart/Stop`(cmd=4/5 通路) | 完整实现 | 仅状态机外抛 `speechStart/End`,**不拉真实音频** | iOS SDK 4.2 beta 没把 cmd=4/5 通路暴露成公开 Swift API;耳机硬件唤醒的上行帧通过翻译通路接管 |
### 真机验证状态
| 子系统 | 单元自测 | 真机验证 |
|---|---|---|
| 扫描 / 连接 / 断开 | ✅ 编译通过 | ⬜ |
| `battery` / `phoneCallStatus` 推送 | ✅ | ⬜ |
| `deviceSnapshot` / `queryTargetInfo` / `sendCustomCmd` | ✅ | ⬜ |
| OTA | ✅ | ⬜ |
| 通话翻译 mode=3 + OPUS 上下行 | ✅ | ⬜ |
| 通话翻译 mode=6 立体声 | ✅ | ⬜ |
| AI 助理 (MODE_RECORD + byDevice + OPUS) | ✅ | ⬜ |
| 设备录音 (mode=6 + stereo + byDevice + OPUS) | ✅ | ⬜ |
## 0.0.1
* TODO: Describe initial release.

47
local_plugins/device_jieli/README.md

@ -1,15 +1,42 @@
# jielihome
# device_jieli
JieLi Home SDK Flutter plugin
Flutter plugin around the JieLi (杰理) RCSP Bluetooth SDK. Provides a vendor-agnostic
MethodChannel / EventChannel surface (`device_jieli/method`, `device_jieli/event`)
so Dart-side code can scan / connect / query / OTA / translate against JieLi earpieces
on both Android and iOS.
## Getting Started
## Platforms
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/to/develop-plugins),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
| Platform | Native SDK | Status |
|---|---|---|
| Android | `android/libs/jl_bluetooth_rcsp_*.aar` etc. | Functional (used in production demo) |
| iOS | `ios/Frameworks/JL_BLEKit.xcframework` etc. (from `JIELI-IOS-Release/Libs`) | **Compiles,真机验证未完成** — see [CHANGELOG.md](CHANGELOG.md) for the per-feature status matrix. |
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## iOS setup
The plugin ships every required `.xcframework` under `ios/Frameworks/`. When the upstream
SDK in `JIELI-IOS-Release/Libs/` updates, mirror the new `.xcframework` directories on
top of `ios/Frameworks/` (overwrite is safe) and `pod install` again in the host app.
Minimum target: iOS 12.
The host app must declare in `Info.plist`:
```xml
<key>NSBluetoothAlwaysUsageDescription</key>
<string>用于连接杰理蓝牙耳机</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>用于连接杰理蓝牙耳机</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于翻译 / 语音助手录音</string>
```
## Channel surface
Dart API lives in [`lib/device_jieli.dart`](lib/device_jieli.dart). Native code on either
platform implements the same set of method calls and emits the same event payload schema —
the iOS implementation lives in [`ios/Classes/`](ios/Classes/), the Android
implementation in [`android/src/main/kotlin/com/jielihome/jielihome/`](android/src/main/kotlin/com/jielihome/jielihome/).
For background on the RCSP commands and translation pipeline see
[`JIELI_SDK_COMMANDS.md`](JIELI_SDK_COMMANDS.md).

BIN
local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar

Binary file not shown.

2
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt

@ -287,7 +287,7 @@ class JieliDeviceRecordPort(
val addr = device?.address
if (impl != null) {
runCatching {
Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO}")
Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_CALL_RECORD}")
impl.exitMode(object : OnRcspActionCallback<Int> {
override fun onSuccess(d: BluetoothDevice?, t: Int?) {
Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t")

281
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt

@ -0,0 +1,281 @@
package com.jielihome.jielihome.feature.translation.runtime
import android.util.Log
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.bean.translation.TranslationResult
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi
import com.jieli.jl_audio_decode.callback.OnStateCallback
import com.jieli.jl_audio_decode.opus.OpusManager
import com.jielihome.jielihome.audio.OpusStreamDecoder
import com.jielihome.jielihome.feature.translation.TranslationStreams
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.atomic.AtomicLong
/**
* 杰理 SDK 的 [IAITranslationApi] 实现 —— call translation 路径专用的「上下行接力桥」。
*
* # 为什么要有这个 bridge
* 早先的实现(旧 [RcspTranslationRuntime])走的是"自己手写 writeAudioData 队列"的方案:
* 1. 用 [com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback.onReceiveAudioData]
* 拿上行 OPUS,自己解码;
* 2. 把外部翻译服务回送的 PCM 周期切片 → OPUS 编码 → 用自己的 WriteScheduler 串行调
* [com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl.writeAudioData] 推回耳机。
*
* 实测踩到的两个坑:
* - 周期切片(每 1s)→ 多段独立 OPUS → 耳机端解码器频繁 reset → 杂音 / 断续;
* - 自己维护 in-flight=1 的写队列,溢出丢最老 → utterance 中段被截掉。
*
* 官方 demo 的真正用法([com.jieli.bt.sdk.tool.translation.AITranslationImpl]):实现
* [IAITranslationApi],把整段 utterance 编一次 OPUS,包成 [AudioData] 塞进
* [TranslationResult.translationTTSData] 然后调 [AITranslationCallback.onTranslateResult],
* **SDK 内部** 接管 cmd=52 切包 / 发送时序 / 缓冲水位。
*
* # 上下行职责
* - **上行**(headset → app):SDK 主动调 [writeAudio],audioData.type = OPUS / PCM;
* 按 [AudioData.source] 分流到 up/down/stereo decoder,解码后通过 [onPcm] 抛出去。
* - **下行**(app → headset):[feedTtsPcm] 累积外部翻译服务回送的 PCM,**纯 isFinal 驱动**:
* `isFinal=true` 时把累积 PCM 编成一段 OPUS,组装 [TranslationResult] 丢给 SDK。
* 不再有周期 flush。如果上层一直不发 isFinal,缓冲到 [PCM_BUFFER_HARD_LIMIT_BYTES]
* 兜底强制 flush,避免内存爆。
*
* # 与 [NoOpAITranslationApi] 的区别
* - [NoOpAITranslationApi]:纯录音通路(JieliAssistantPort / JieliDeviceRecordPort 等)
* 只想拿原始 PCM,不希望 SDK 触发 AI 流程。
* - 本类:通话翻译需要"上行解码 + 下行 SDK 接管 TTS 注入",要走 SDK 的标准 AI hook。
*
* # 线程
* - SDK 回调线程不固定;解码器 / 编码器调用都不假设线程。
* - [feedTtsPcm] 可被任意线程调用,per-leg 缓冲用 [bufferLock] 互斥。
* - OPUS 文件编码异步([OpusManager.encodeFile] 内部线程),完成后回到 SDK callback。
*/
class JieliAITranslationBridge(
private val mode: TranslationMode,
private val tempDir: File,
/** 解码后的上行 PCM 出口;source 为 SDK 原值(SOURCE_E_SCO_UP_LINK / DOWN_LINK / MIX)。 */
private val onPcm: (source: Int, pcm: ByteArray) -> Unit,
/** 解码 / 编码失败的统一出口。 */
private val onError: (code: Int, msg: String?) -> Unit,
private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200,
) : IAITranslationApi {
companion object {
private const val TAG = "JieliAITranslationBridge"
/** 单段 utterance PCM 上限:≈ 60s 16k mono 16bit = 1.92MB。超限强制 flush 兜底。 */
private const val PCM_BUFFER_HARD_LIMIT_BYTES = 2 * 1024 * 1024
}
private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM
private val sampleRateHz = mode.sampleRate.takeIf { it > 0 } ?: 16000
@Volatile private var sdkCallback: AITranslationCallback? = null
@Volatile private var working = false
/** 上行 OPUS 解码器:通话翻译有 up/down 两路单声道,stereo 模式只一路双声道。 */
private val upDecoder: OpusStreamDecoder? = if (isPcmMode) null else OpusStreamDecoder(
channel = 1,
packetSize = opusPacketSize,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_UP_LINK, pcm) },
onError = { c, m -> onError(c, "upDecoder: $m") },
)
private val downDecoder: OpusStreamDecoder? =
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION) OpusStreamDecoder(
channel = 1,
packetSize = opusPacketSize,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_DOWN_LINK, pcm) },
onError = { c, m -> onError(c, "downDecoder: $m") },
) else null
private val stereoDecoder: OpusStreamDecoder? =
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO) OpusStreamDecoder(
channel = 2, packetSize = 80,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_MIX, pcm) },
onError = { c, m -> onError(c, "stereoDecoder: $m") },
) else null
/** Per-leg PCM 缓冲:key = outputStreamId(OUT_UPLINK / OUT_DOWNLINK / OUT_SPEAKER)。 */
private val pcmBuffers = mutableMapOf<String, ByteArrayOutputStream>()
private val bufferLock = Any()
private val encodeSeq = AtomicLong(0)
/** 调试统计 */
@Volatile private var rxFirstLogged = false
private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap<Int, Boolean>()
// ─── IAITranslationApi 实现 ───────────────────────────────────────────
override fun isWorking(): Boolean = working
override fun startTranslating(mode: TranslationMode, callback: AITranslationCallback) {
Log.i(TAG, "[SDK->APP] startTranslating mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel}")
sdkCallback = callback
if (!tempDir.exists()) tempDir.mkdirs()
upDecoder?.start()
downDecoder?.start()
stereoDecoder?.start()
working = true
callback.onStart()
}
override fun stopTranslating() {
Log.i(TAG, "[SDK->APP] stopTranslating")
working = false
runCatching { upDecoder?.stop() }
runCatching { downDecoder?.stop() }
runCatching { stereoDecoder?.stop() }
// 兜底:剩余 buffer 直接丢,不补发 onTranslateResult(utterance 已被打断)。
synchronized(bufferLock) {
pcmBuffers.values.forEach { it.reset() }
pcmBuffers.clear()
}
sdkCallback?.onStop(0, "stopTranslating")
sdkCallback = null
}
override fun writeAudio(data: AudioData) {
if (!working) return
if (data.type != mode.audioType) {
// SDK 极少数情况下会推与 mode.audioType 不一致的帧(例如握手期残留);忽略不报错。
return
}
val payload = data.audioData ?: return
if (payload.isEmpty()) return
// 首帧(总)
if (!rxFirstLogged) {
rxFirstLogged = true
Log.i(TAG, "[SDK->APP] writeAudio FIRST source=${data.source} type=${data.type} size=${payload.size}")
}
// 首帧(按 source)
if (rxFirstPerSource.putIfAbsent(data.source, true) == null) {
Log.i(TAG, "[SDK->APP] writeAudio FIRST source=${data.source} size=${payload.size}")
}
if (isPcmMode) {
onPcm(data.source, payload)
return
}
when (data.source) {
AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload)
else -> Log.w(TAG, "[SDK->APP] writeAudio unknown source=${data.source} size=${payload.size}")
}
}
// ─── 下行:外部翻译服务的 TTS PCM 接力 ─────────────────────────────────
/**
* 把外部翻译服务回送的 TTS PCM 接力给 SDK。
*
* 策略:
* - PCM 模式:每次调用作为一段 [AudioData] 直接交给 SDK。
* - OPUS 模式:累积 per-leg buffer;`isFinal=true` 或缓冲到 [PCM_BUFFER_HARD_LIMIT_BYTES]
* 时整段编码 → 通过 [AITranslationCallback.onTranslateResult] 交给 SDK,SDK 内部
* 完成 cmd=52 切包 / 发送 / 速率控制。
*
* @return 是否接受本帧(false 表示丢弃,例如未启动 / 缓冲爆 / SDK callback 缺失)
*/
fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean {
if (!working) return false
val cb = sdkCallback ?: return false
val source = sourceOf(outputStreamId)
if (isPcmMode) {
postTranslationResult(cb, source, Constants.AUDIO_TYPE_PCM, pcm)
return true
}
// OPUS 模式:累积 + isFinal/超限触发整段编码
val pendingFlush: ByteArray? = synchronized(bufferLock) {
val buf = pcmBuffers.getOrPut(outputStreamId) { ByteArrayOutputStream() }
if (pcm.isNotEmpty()) {
if (buf.size() + pcm.size > PCM_BUFFER_HARD_LIMIT_BYTES) {
Log.w(TAG, "feedTtsPcm leg=$outputStreamId buffer hit hard limit ${PCM_BUFFER_HARD_LIMIT_BYTES}, force flush")
val out = buf.toByteArray() + pcm
buf.reset()
return@synchronized out
}
buf.write(pcm)
}
if (isFinal) {
val out = buf.toByteArray()
buf.reset()
if (out.isEmpty()) null else out
} else null
}
if (pendingFlush != null) {
encodeAndDeliverAsync(cb, source, outputStreamId, pendingFlush)
}
return true
}
private fun postTranslationResult(
cb: AITranslationCallback,
source: Int,
audioType: Int,
bytes: ByteArray,
) {
val result = TranslationResult().also {
it.translationTTSData = AudioData(source, audioType, bytes)
}
runCatching { cb.onTranslateResult(result) }.onFailure {
Log.w(TAG, "onTranslateResult threw: ${it.message}")
}
}
private fun encodeAndDeliverAsync(
cb: AITranslationCallback,
source: Int,
leg: String,
pcmBytes: ByteArray,
) {
val seq = encodeSeq.incrementAndGet()
val pcmFile = File(tempDir, "tts_${source}_$seq.pcm")
val opusFile = File(tempDir, "tts_${source}_$seq.opus")
try {
pcmFile.writeBytes(pcmBytes)
} catch (e: Throwable) {
Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq write pcm failed: ${e.message}")
return
}
// OpusManager 默认 OpusOption(带 head),与 demo `MachineTranslation.tryToTTS` 一致;
// SDK 接收端按"完整带 head 的 OPUS 段"解析,所以一次 onTranslateResult = 一段 utterance。
val encoder = OpusManager()
encoder.encodeFile(pcmFile.absolutePath, opusFile.absolutePath, object : OnStateCallback {
override fun onStart() {}
override fun onComplete(path: String?) {
try {
val opusBytes = if (opusFile.exists()) opusFile.readBytes() else null
if (opusBytes == null || opusBytes.isEmpty()) {
Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq: empty opus output")
} else {
Log.d(TAG, "encodeAndDeliver leg=$leg seq=$seq pcm=${pcmBytes.size}B opus=${opusBytes.size}B → onTranslateResult")
postTranslationResult(cb, source, Constants.AUDIO_TYPE_OPUS, opusBytes)
}
} finally {
runCatching { encoder.release() }
runCatching { pcmFile.delete() }
runCatching { opusFile.delete() }
}
}
override fun onError(code: Int, message: String?) {
Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq encodeFile error code=$code msg=$message")
runCatching { encoder.release() }
runCatching { pcmFile.delete() }
runCatching { opusFile.delete() }
onError(code, "encodeFile $leg: $message")
}
})
}
private fun sourceOf(outputStreamId: String): Int = when (outputStreamId) {
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK
else -> AudioData.SOURCE_PHONE_MIC
}
}

17
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt

@ -6,8 +6,21 @@ import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi
/**
* SDK 的 [TranslationImpl] 构造函数要求一个 [IAITranslationApi]。
* 我们走的是「外部翻译服务」路线,SDK 自带的 AI 流程不启用,所以这里给个空实现。
* SDK 的 `TranslationImpl` 构造函数要求一个 [IAITranslationApi]。本类是给**纯录音通路**
* 占位用的空实现:上层只想拿原始 OPUS / PCM 流(自己挂 `TranslationCallback.onReceiveAudioData`),
* 不希望 SDK 触发任何 AI 翻译流程 / TTS 注入。
*
* # 谁在用
* - [com.jielihome.jielihome.feature.assistant.JieliAssistantPort](AI 助理上行通路)
* - [com.jielihome.jielihome.feature.record.JieliDeviceRecordPort](通话录音 MODE_CALL_RECORD)
* - [com.jielihome.jielihome.feature.translation.mode.RecordModeHandler](MODE_RECORD)
* - [com.jielihome.jielihome.feature.translation.mode.RecordingTranslationModeHandler](MODE_RECORDING_TRANSLATION)
* - [com.jielihome.jielihome.feature.translation.TranslationFeature](兜底 TranslationImpl 实例)
*
* # 谁**不在**用了
* Call translation(mode 3 / 6)由 [JieliAITranslationBridge] 接管——那条路要的是
* "SDK 接管 TTS 切包注入",必须给真正的 IAITranslationApi 实现,避免自己写 writeAudioData
* 队列触发的"耳机端解码器频繁 reset → 杂音 / 断续"。
*/
internal class NoOpAITranslationApi : IAITranslationApi {
override fun isWorking(): Boolean = false

466
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt

@ -1,6 +1,7 @@
package com.jielihome.jielihome.feature.translation.runtime
import android.bluetooth.BluetoothDevice
import android.util.Log
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
@ -8,42 +9,28 @@ import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback
import android.util.Log
import com.jielihome.jielihome.audio.OpusStreamDecoder
import com.jielihome.jielihome.feature.translation.TranslationStreams
import com.jieli.jl_audio_decode.callback.OnStateCallback
import com.jieli.jl_audio_decode.opus.OpusManager
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.ArrayDeque
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* RCSP 翻译模式运行时。
*
* audioType:
* - [Constants.AUDIO_TYPE_OPUS](默认):上行 OPUS → 解码 PCM;下行 PCM → **整段** 编码 OPUS 写回
* - [Constants.AUDIO_TYPE_PCM]:上下行直接走 PCM,不经编解码
* RCSP 翻译模式运行时(call translation 路径专用)。
*
* # TTS 回灌策略(与官方 demo `MachineTranslation.tryToTTS` / `OpusHelper.encodeFile` 对齐)
* # 角色
* 把"进入/退出 SDK 翻译模式"的 RCSP 状态机生命周期,跟「上下行音频接力」绑在一起。
* 真正的音频路由由 [JieliAITranslationBridge] 这个 [com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi]
* 实现负责(见它头注释)。
*
* 之前是流式 [com.jielihome.jielihome.audio.OpusStreamEncoder]:每出一帧 OPUS 立即包成
* AudioData 调 [TranslationImpl.writeAudioData],导致耳机端 RCSP 把每帧当成"独立的一段
* TTS 起点",解码器频繁 reset → 杂音 / 断续。
* # 与旧实现的区别
* 之前的 runtime 大约 500 行:自己用 [TranslationCallback.onReceiveAudioData] 拿上行 OPUS、
* 自己 OPUS 编码 TTS、自己维护 `WriteScheduler` 串行队列调 [TranslationImpl.writeAudioData]。
* 实测耳机端解码器频繁 reset 导致杂音 / 断续。
*
* 现在按 demo:[feedTtsPcm] 把 PCM 累积到 per-leg 缓冲;上层在每段 utterance 末尾把
* `isFinal=true` 透传过来;runtime 这一刻:
* 1. 把累积 PCM 写到临时 .pcm 文件;
* 2. `OpusManager.encodeFile(pcmFile, opusFile, callback)` 离线整段编码(带 head 的默认 OpusOption);
* 3. 整段读出 → **一个** [AudioData] → [WriteScheduler] 单次入队下发。
* 现在按官方 demo `AITranslationImpl` 的标准做法:
* 1. 构造 [TranslationImpl] 时传入真正的 [JieliAITranslationBridge],SDK 通过它把上行
* OPUS 推给我们解码;
* 2. 下行 TTS PCM 累积成段后通过 [AITranslationCallback.onTranslateResult] 交给 SDK,
* 由 SDK 内部完成 cmd=52 切包 / 写时序 / 缓冲水位([TranslationImpl.PushDataWrapper])。
*
* # 文件命名
* 临时文件位于 [tempDir],按 `tts_<source>_<seq>.<pcm|opus>` 命名;编码完成异步删除。
*
* # source 字段写回方向
* # 上下行 source 字段语义
* - 通话翻译给「对端听」 → AudioData.source = SOURCE_E_SCO_UP_LINK
* - 通话翻译给「本机听」 → AudioData.source = SOURCE_E_SCO_DOWN_LINK
* - 录音/音视频/面对面 → AudioData.source = SOURCE_PHONE_MIC(SDK 按 mode 自分发)
@ -56,90 +43,44 @@ class RcspTranslationRuntime(
/** 解码(或直传 PCM)后的音频上行;source 为 SDK 原值 */
private val onPcm: (source: Int, pcm: ByteArray) -> Unit,
private val onError: (code: Int, msg: String?) -> Unit,
/** OPUS 解码 packetSize;mono call=200,stereo=80 */
private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200,
) {
private val translationImpl = TranslationImpl(btManager, NoOpAITranslationApi(), device)
private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM
private val sampleRateHz = mode.sampleRate.takeIf { it > 0 } ?: 16000
companion object {
private const val TAG = "RcspTranslationRuntime"
}
/** 入栈解码器:仅 OPUS 模式下创建 */
private val upDecoder: OpusStreamDecoder? = if (isPcmMode) null else OpusStreamDecoder(
channel = 1,
packetSize = opusPacketSize,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_UP_LINK, pcm) },
onError = { c, m -> onError(c, "upDecoder: $m") },
/** SDK 的 AI hook 实现:上行解码、下行 TTS 接力都在这里。 */
private val bridge = JieliAITranslationBridge(
mode = mode,
tempDir = tempDir,
onPcm = onPcm,
onError = onError,
opusPacketSize = opusPacketSize,
)
private val downDecoder: OpusStreamDecoder? =
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION) OpusStreamDecoder(
channel = 1,
packetSize = opusPacketSize,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_DOWN_LINK, pcm) },
onError = { c, m -> onError(c, "downDecoder: $m") },
) else null
private val stereoDecoder: OpusStreamDecoder? =
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO) OpusStreamDecoder(
channel = 2, packetSize = 80,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_MIX, pcm) },
onError = { c, m -> onError(c, "stereoDecoder: $m") },
) else null
/** 调试统计:SDK onReceiveAudioData 被回调多少次、按 (source,type) 分桶 */
@Volatile private var rxCount = 0L
@Volatile private var rxTypeMismatch = 0L
@Volatile private var rxNullPayload = 0L
@Volatile private var rxLastReportMs = 0L
/** 首帧 flag:首次收到任意 AudioData 时立即打一条 INFO 级别日志(不受 1s 节流影响)。 */
@Volatile private var rxFirstLogged = false
/** 首帧(按 source 维度):uplink / downlink / mix 分别各打一条,确认链路通畅。 */
private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap<Int, Boolean>()
private val translationImpl = TranslationImpl(btManager, bridge, device)
private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM
/**
* 仅订阅 mode 变化事件用于日志 / 异常上报;不在这里消费音频
* (音频走 [bridge] 的 `IAITranslationApi.writeAudio`,避免双路重复消费)。
*/
private val translationCallback = object : TranslationCallback {
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) {
Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel} strategy=${m.recordingStrategy}")
Log.i(
TAG,
"[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} " +
"sr=${m.sampleRate} ch=${m.channel} strategy=${m.recordingStrategy}"
)
if (m.mode == TranslationMode.MODE_IDLE && mode.mode != TranslationMode.MODE_IDLE) {
onError(-1, "headset exited mode=${mode.mode} → MODE_IDLE")
}
}
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) {
rxCount++
val payloadSize = data.audioData?.size ?: 0
// 首帧(总):立即打点,避免节流导致"完全静默"误判
if (!rxFirstLogged) {
rxFirstLogged = true
Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=$payloadSize expect.type=${mode.audioType}")
}
// 首帧(按 source):分别打点,快速判断上行/下行是否都在推
if (rxFirstPerSource.putIfAbsent(data.source, true) == null) {
Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST source=${data.source} type=${data.type} size=$payloadSize")
}
val now = System.currentTimeMillis()
if (now - rxLastReportMs >= 1000L) {
Log.d(TAG, "[SDK<-DEV] RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=$payloadSize")
rxCount = 0; rxTypeMismatch = 0; rxNullPayload = 0; rxLastReportMs = now
}
if (data.type != mode.audioType) {
rxTypeMismatch++
return
}
val payload = data.audioData
if (payload == null) {
rxNullPayload++
return
}
if (isPcmMode) {
onPcm(data.source, payload)
return
}
when (data.source) {
AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload)
else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown source=${data.source} dropped")
}
// 音频走 bridge.writeAudio;这里不消费,避免与 bridge 双路重复解码。
}
override fun onError(d: BluetoothDevice, code: Int, msg: String) {
@ -148,129 +89,7 @@ class RcspTranslationRuntime(
}
}
companion object {
private const val TAG = "RcspTranslationRuntime"
/** 每腿 writeAudioData 缓冲队列上限。整段下发后队列里几乎不会堆积,留 8 个槽足够。 */
private const val WRITE_QUEUE_LIMIT = 8
/** 单段 PCM 上限:≈ 60s 16k mono 16bit = 1.92MB,正常 utterance 远低于此值。 */
private const val PCM_BUFFER_HARD_LIMIT_BYTES = 2 * 1024 * 1024
/**
* 周期性 flush 间隔:每隔此毫秒数巡检一次 buffer,**只要非空就 flush**。
*
* 历史实测:200ms 时耳机端 RCSP 解码器会被频繁 reset 直至死机;1s 长期稳定。
* 当前实验:500ms —— 通话翻译"播报中间断一下"的优化尝试。需真机长时压测
* (≥10 分钟连续讲话)确认无解码器 reset 风暴,否则回退到 1_000L。
*/
private const val PERIODIC_FLUSH_MS = 1000L
/**
* 首段优先 flush 阈值(路线 B):每个 utterance 的**首段**达到此毫秒数对应字节量
* 就立即 flush,不等周期。让用户开口到对方听到的延迟尽可能小(< 200ms)。
*
* 触发后转入周期模式直到 isFinal=true 重置。火山不发 isFinal 的会话里,
* 只有第一句享受首段优先;后续句统一走 [PERIODIC_FLUSH_MS] 周期。这是为了
* 避免连续讲话被反复识别成"新 utterance"导致每 100ms 切一段、reset 频率失控。
*/
private const val FIRST_SEGMENT_FLUSH_MS = 100L
}
/**
* 串行化的 writeAudioData 调度器(每个 source 一个)。
*
* RCSP/SPP 写入要求:上一帧 [writeAudioData] 的回调返回(成功或失败)之前不要再发,
* 否则 SDK 会拒收新请求并报 "Operation in progress"。这里维护一个 in-flight ≤ 1
* 的 FIFO 队列,溢出时丢最老的帧(保实时性,不堆积延迟)。
*/
private class WriteScheduler(
private val tag: String,
private val send: (AudioData, OnRcspActionCallback<Boolean>) -> Unit,
) {
private val queue = ArrayDeque<AudioData>(WRITE_QUEUE_LIMIT)
private val inFlight = AtomicBoolean(false)
private val lock = Any()
@Volatile private var droppedSinceReport = 0L
@Volatile private var lastReportMs = 0L
fun enqueue(data: AudioData) {
val toSend: AudioData? = synchronized(lock) {
if (inFlight.compareAndSet(false, true)) {
data
} else {
if (queue.size >= WRITE_QUEUE_LIMIT) {
queue.pollFirst() // 丢最老
droppedSinceReport++
val now = System.currentTimeMillis()
if (now - lastReportMs >= 1000L) {
Log.w(TAG, "writeAudioData[$tag] queue full, dropped=$droppedSinceReport (last 1s)")
droppedSinceReport = 0
lastReportMs = now
}
}
queue.offerLast(data)
null
}
}
if (toSend != null) dispatch(toSend)
}
private fun dispatch(data: AudioData) {
send(data, object : OnRcspActionCallback<Boolean> {
override fun onSuccess(d: BluetoothDevice?, ok: Boolean?) = onComplete()
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {
val msg = err?.message.orEmpty()
if (!msg.contains("Operation in progress", ignoreCase = true)) {
Log.w(TAG, "writeAudioData[$tag] error: code=${err?.code} msg=$msg")
}
onComplete()
}
})
}
private fun onComplete() {
val next: AudioData? = synchronized(lock) {
val n = queue.pollFirst()
if (n == null) inFlight.set(false)
n
}
if (next != null) dispatch(next)
}
fun clear() {
synchronized(lock) {
queue.clear()
inFlight.set(false)
}
}
}
private val uplinkWriter = WriteScheduler("uplink") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
private val downlinkWriter = WriteScheduler("downlink") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
private val phoneMicWriter = WriteScheduler("phoneMic") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
/** 兜底:PCM 直传 / 未识别 source 走这条;和 OPUS 编码器互不抢 in-flight 槽位。 */
private val rawWriter = WriteScheduler("raw") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
/**
* Per-leg PCM 缓冲。`feedTtsPcm` 累积写入,`isFinal=true` 时整段编码下发后清空。
*
* key = outputStreamId(OUT_UPLINK / OUT_DOWNLINK / OUT_SPEAKER 等)
*/
private val pcmBuffers = mutableMapOf<String, ByteArrayOutputStream>()
/** 每条 leg 是否仍处于"等待首段触发"状态(路线 B 首段优先 flush 用)。 */
private val firstSegmentPending = mutableMapOf<String, Boolean>()
private val bufferLock = Any()
private var encodeSeq = 0L
/** 周期性 flush 巡检线程;start() 启动,stop() 关闭。 */
private var flushWatcher: ScheduledExecutorService? = null
/** 启动前置校验 + 进入翻译模式 */
/** 启动前置校验 + 进入翻译模式。 */
fun start(): Result<Unit> {
if (!translationImpl.isInit) {
return Result.failure(IllegalStateException("RCSP not init for ${device.address}"))
@ -285,172 +104,31 @@ class RcspTranslationRuntime(
}
if (!tempDir.exists()) tempDir.mkdirs()
upDecoder?.start()
downDecoder?.start()
stereoDecoder?.start()
translationImpl.addTranslationCallback(translationCallback)
Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} (waiting for onModeChange/onReceiveAudioData)")
Log.i(
TAG,
"[APP->SDK] enterMode addr=${device.address} mode=${mode.mode} type=${mode.audioType} " +
"sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} " +
"(SDK will drive bridge.startTranslating)"
)
translationImpl.enterMode(mode, translationCallback)
// OPUS 模式启动周期性 flush 巡检:每 [PERIODIC_FLUSH_MS] 切一次缓冲。
if (!isPcmMode) startPeriodicFlushWatcher()
return Result.success(Unit)
}
private fun startPeriodicFlushWatcher() {
flushWatcher?.shutdownNow()
val exec = Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "rcsp-tts-flush").apply { isDaemon = true }
}
flushWatcher = exec
exec.scheduleAtFixedRate(
{ runCatching { periodicFlush() } },
PERIODIC_FLUSH_MS, PERIODIC_FLUSH_MS, TimeUnit.MILLISECONDS,
)
}
/** 每秒巡检:每条 leg 的 buffer 只要非空就立即切走 + 编码 + 下发(不依赖任何外部信号)。 */
private fun periodicFlush() {
val toEncode = mutableListOf<Triple<Int, String, ByteArray>>()
synchronized(bufferLock) {
for ((leg, buf) in pcmBuffers) {
if (buf.size() == 0) continue
val source = when (leg) {
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK
else -> AudioData.SOURCE_PHONE_MIC
}
val out = buf.toByteArray()
buf.reset()
Log.d(TAG, "periodicFlush: leg=$leg bytes=${out.size}")
toEncode.add(Triple(source, leg, out))
}
}
// encode 走 async,必须释放锁后做。
for ((source, leg, pcm) in toEncode) {
encodeAndDispatchAsync(source, leg, pcm)
}
}
/**
* 把外部翻译服务回送的 PCM 注入回耳机。
* 把外部翻译服务回送的 PCM 接力给 SDK。
*
* 策略:
* - PCM 模式:每次调用直接当作一段 AudioData 下发(SDK 内部按 blockMtu 分片)。
* - OPUS 模式:纯时间驱动 flush —— 火山等端到端服务的段尾事件不可靠,
* 不能依赖 isFinal 或字节量阈值。改为:
* a) feedTtsPcm 仅追加 buffer,不主动 flush(除非 isFinal=true);
* b) [PERIODIC_FLUSH_MS] (1s) 巡检线程每秒切走 buffer 并整段编码下发;
* c) `isFinal=true` 仍短路立即 flush(兼容主动信号,不再依赖)。
* 最大听感延迟 ≤ 1s,且短句 / 没有段尾事件的服务都不会卡死。
* 行为完全委托给 [JieliAITranslationBridge.feedTtsPcm]:
* - PCM 模式:每次调用作为一段 [AudioData] 立即交付给 SDK;
* - OPUS 模式:累积,`isFinal=true` 时整段编码 → `onTranslateResult` 交给 SDK。
*
* @param outputStreamId 决定 source:
* [TranslationStreams.OUT_UPLINK] / [TranslationStreams.OUT_DOWNLINK] 用于通话翻译;
* 其它(speaker/localPlayback)由 ModeHandler 自己处理,不会落到这里。
* @param isFinal 本帧是否为当前 utterance 的最后一帧;触发 buffer 残余立即 flush。
* 调用方契约:**必须**在 utterance 末尾调一次 `isFinal=true`,否则音频会一直累积
* 到 2MB 兜底上限才出。
*/
fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean {
val source = when (outputStreamId) {
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK
else -> AudioData.SOURCE_PHONE_MIC
}
if (isPcmMode) {
// PCM 直传:每次调用一段 AudioData。`isFinal` 在该模式下不影响下发节奏。
writeBack(AudioData(source, Constants.AUDIO_TYPE_PCM, pcm))
return true
}
// OPUS 模式:累积到 buffer,三条 flush 触发链(按优先级):
// 1. isFinal=true → 立即 flush,并把首段优先重置回 true(下一句新算)
// 2. 首段优先(路线 B) → 当前 leg 处于 firstSegmentPending=true 且 buffer
// 累积到 FIRST_SEGMENT_FLUSH_MS 字节量时立即 flush,
// 让"开口到对方听见"的延迟最小化(< 200ms)
// 3. 周期 flush(路线 A) → 由 [PERIODIC_FLUSH_MS] 巡检线程接管
val pendingFlush: ByteArray? = synchronized(bufferLock) {
val buf = pcmBuffers.getOrPut(outputStreamId) { ByteArrayOutputStream() }
if (pcm.isNotEmpty()) {
if (buf.size() + pcm.size > PCM_BUFFER_HARD_LIMIT_BYTES) {
Log.w(TAG, "feedTtsPcm: leg=$outputStreamId pcm buffer hit hard limit ${PCM_BUFFER_HARD_LIMIT_BYTES}, dropping segment")
buf.reset()
return false
}
buf.write(pcm)
}
// 1) 段尾信号:立即下发尾段并把首段标志重置回 true。
if (isFinal) {
firstSegmentPending[outputStreamId] = true
val out = buf.toByteArray()
buf.reset()
return@synchronized if (out.isEmpty()) null else out
}
// 2) 首段优先(路线 B):当前**实验中临时屏蔽**——为观察 500ms 周期 flush 的纯效果。
// 屏蔽后所有 flush 走两条路:a) isFinal=true 短路;b) PERIODIC_FLUSH_MS 周期。
// 实验完成后若需恢复,把下面 if (false) 改回 if (pending) 即可。
@Suppress("ConstantConditionIf")
if (false) {
val pending = firstSegmentPending.getOrDefault(outputStreamId, true)
if (pending) {
val firstFlushBytes = (sampleRateHz * 2 * FIRST_SEGMENT_FLUSH_MS / 1000L).toInt()
if (buf.size() >= firstFlushBytes) {
firstSegmentPending[outputStreamId] = false
val out = buf.toByteArray()
buf.reset()
Log.d(TAG, "firstSegmentFlush: leg=$outputStreamId bytes=${out.size}")
return@synchronized if (out.isEmpty()) null else out
}
}
}
null
}
if (pendingFlush != null) {
encodeAndDispatchAsync(source, outputStreamId, pendingFlush)
}
return true
}
private fun encodeAndDispatchAsync(source: Int, leg: String, pcmBytes: ByteArray) {
val seq = synchronized(bufferLock) { ++encodeSeq }
val pcmFile = File(tempDir, "tts_${source}_$seq.pcm")
val opusFile = File(tempDir, "tts_${source}_$seq.opus")
try {
pcmFile.writeBytes(pcmBytes)
} catch (e: Throwable) {
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq write pcm file failed: ${e.message}")
return
}
// OpusManager.encodeFile(in, out, callback) 默认 OpusOption(带 head),与 demo 一致。
val encoder = OpusManager()
encoder.encodeFile(pcmFile.absolutePath, opusFile.absolutePath, object : OnStateCallback {
override fun onStart() {}
override fun onComplete(path: String?) {
try {
val opusBytes = if (opusFile.exists()) opusFile.readBytes() else null
if (opusBytes == null || opusBytes.isEmpty()) {
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq: empty opus output")
} else {
Log.d(TAG, "encodeAndDispatch leg=$leg seq=$seq pcm=${pcmBytes.size}B opus=${opusBytes.size}B")
writeBack(AudioData(source, Constants.AUDIO_TYPE_OPUS, opusBytes))
}
} finally {
runCatching { encoder.release() }
runCatching { pcmFile.delete() }
runCatching { opusFile.delete() }
}
}
override fun onError(code: Int, message: String?) {
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq encodeFile error code=$code msg=$message")
runCatching { encoder.release() }
runCatching { pcmFile.delete() }
runCatching { opusFile.delete() }
onError(code, "encodeFile $leg: $message")
}
})
}
fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean =
bridge.feedTtsPcm(outputStreamId, pcm, isFinal)
fun stop() {
runCatching { flushWatcher?.shutdownNow() }
flushWatcher = null
runCatching {
Log.i(TAG, "[APP->SDK] exitMode addr=${device.address} mode=${mode.mode}")
translationImpl.exitMode(object : OnRcspActionCallback<Int> {
@ -463,34 +141,8 @@ class RcspTranslationRuntime(
})
}
runCatching { translationImpl.removeTranslationCallback(translationCallback) }
// bridge 由 SDK 在 exitMode 后通过 stopTranslating 自动 release decoders;
// 这里不重复调,避免双重 stop。
runCatching { translationImpl.destroy() }
runCatching { upDecoder?.stop() }
runCatching { downDecoder?.stop() }
runCatching { stereoDecoder?.stop() }
runCatching {
uplinkWriter.clear()
downlinkWriter.clear()
phoneMicWriter.clear()
rawWriter.clear()
}
synchronized(bufferLock) {
pcmBuffers.values.forEach { it.reset() }
pcmBuffers.clear()
firstSegmentPending.clear()
}
}
/**
* 把 OPUS / PCM 帧入队到对应 source 的 [WriteScheduler],由调度器串行发起 RCSP
* writeAudioData,避免并发写入触发 "Operation in progress"。
*/
private fun writeBack(data: AudioData) {
val scheduler = when (data.source) {
AudioData.SOURCE_E_SCO_UP_LINK -> uplinkWriter
AudioData.SOURCE_E_SCO_DOWN_LINK -> downlinkWriter
AudioData.SOURCE_PHONE_MIC -> phoneMicWriter
else -> rawWriter
}
scheduler.enqueue(data)
}
}

BIN
local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta1_40255_20260324.aar

Binary file not shown.

BIN
local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar

Binary file not shown.

181
local_plugins/device_jieli/ios/Classes/Audio/OpusStreamCodec.swift

@ -1,181 +0,0 @@
import Foundation
#if canImport(JLAudioUnitKit)
import JLAudioUnitKit
#endif
/// Streaming OPUS decoder + encoder built on top of `JLAudioUnitKit`.
///
/// Mirrors the Android `audio/OpusStreamCodec.kt` (decoder side). The codec
/// pushes 16 kHz / 16-bit / mono / 20 ms PCM frames through the supplied
/// callback, matching the Android contract used by `RcspTranslationRuntime`.
///
/// Important: `JLOpusDecoder.opusDecoderInputData(_:)` accepts **one OPUS
/// packet at a time**. The headset advertises packets at 40 bytes for 16k/20ms;
/// pass the byte stream directly — the decoder does not buffer. If the
/// upstream feed combines multiple packets in one chunk, we slice along
/// `packetSize` to keep the decoder happy.
final class OpusStreamDecoder: NSObject {
let sampleRate: Int
let channels: Int
let packetSize: Int
private let onPcm: (Data) -> Void
private let onError: (Int, String?) -> Void
#if canImport(JLAudioUnitKit)
private var decoder: JLOpusDecoder?
#endif
private var started = false
private let lock = NSLock()
init(
channels: Int = 1,
packetSize: Int = 40,
sampleRate: Int = 16000,
onPcm: @escaping (Data) -> Void,
onError: @escaping (Int, String?) -> Void = { _, _ in }
) {
self.channels = channels
self.packetSize = packetSize
self.sampleRate = sampleRate
self.onPcm = onPcm
self.onError = onError
}
func start() {
lock.lock(); defer { lock.unlock() }
if started { return }
#if canImport(JLAudioUnitKit)
let format = JLOpusFormat.defaultFormats()
format.sampleRate = Int32(sampleRate)
format.channels = Int32(channels)
format.frameDuration = 20
// The headset's stream is the JieLi-flavored OPUS (no header). The
// demo's `defaultFormats` already disables the header for the headset
// direction, so we keep that default.
format.dataSize = Int32(packetSize)
decoder = JLOpusDecoder(decoder: format, delegate: self)
#endif
started = true
}
func stop() {
lock.lock(); defer { lock.unlock() }
if !started { return }
started = false
#if canImport(JLAudioUnitKit)
decoder?.opusOnRelease()
decoder = nil
#endif
}
/// Feed one or more OPUS packets concatenated. The decoder is fed one
/// `packetSize`-sized chunk at a time when the input length is a multiple
/// of `packetSize`; otherwise the entire buffer is forwarded as-is and
/// the decoder does its own resync.
func feedEncoded(_ data: Data) {
#if canImport(JLAudioUnitKit)
guard started, let dec = decoder else { return }
if packetSize > 0 && data.count > packetSize && data.count % packetSize == 0 {
var offset = 0
while offset < data.count {
let end = min(offset + packetSize, data.count)
dec.opusDecoderInputData(data.subdata(in: offset..<end))
offset = end
}
} else {
dec.opusDecoderInputData(data)
}
#endif
}
}
#if canImport(JLAudioUnitKit)
extension OpusStreamDecoder: JLOpusDecoderDelegate {
func opusDecoder(_ decoder: JLOpusDecoder, data: Data?, error: Error?) {
if let err = error {
onError((err as NSError).code, err.localizedDescription)
return
}
guard let pcm = data, !pcm.isEmpty else { return }
onPcm(pcm)
}
}
#endif
/// Streaming OPUS encoder used to repackage TTS PCM for the headset's
/// downlink path. Buffers PCM chunks and emits encoded packets through the
/// callback.
final class OpusStreamEncoder: NSObject {
let sampleRate: Int
let channels: Int
private let onEncoded: (Data) -> Void
private let onError: (Int, String?) -> Void
#if canImport(JLAudioUnitKit)
private var encoder: JLOpusEncoder?
#endif
private var started = false
private let lock = NSLock()
init(
channels: Int = 1,
sampleRate: Int = 16000,
onEncoded: @escaping (Data) -> Void,
onError: @escaping (Int, String?) -> Void = { _, _ in }
) {
self.sampleRate = sampleRate
self.channels = channels
self.onEncoded = onEncoded
self.onError = onError
}
func start() {
lock.lock(); defer { lock.unlock() }
if started { return }
#if canImport(JLAudioUnitKit)
// Use the JieLi default config (no OGG header — same as the headset
// expects, matches the demo's `JLOpusEncodeConfig.default()`).
let cfg = JLOpusEncodeConfig.default()
cfg.sampleRate = Int32(sampleRate)
cfg.channels = Int32(channels)
cfg.frameDuration = 20
encoder = JLOpusEncoder(format: cfg, delegate: self)
#endif
started = true
}
func stop() {
lock.lock(); defer { lock.unlock() }
if !started { return }
started = false
#if canImport(JLAudioUnitKit)
encoder?.opusOnRelease()
encoder = nil
#endif
}
/// Feed one or more raw PCM bytes (16-bit LE, `channels` interleaved when >1).
func feedPcm(_ pcm: Data) {
#if canImport(JLAudioUnitKit)
guard started, let enc = encoder else { return }
enc.opusEncode(pcm)
#endif
}
}
#if canImport(JLAudioUnitKit)
extension OpusStreamEncoder: JLOpusEncoderDelegate {
func opusEncoder(_ encoder: JLOpusEncoder, data: Data?, error: Error?) {
if let err = error {
onError((err as NSError).code, err.localizedDescription)
return
}
guard let opus = data, !opus.isEmpty else { return }
onEncoded(opus)
}
}
#endif

27
local_plugins/device_jieli/ios/Classes/Audio/PcmKit.swift

@ -1,27 +0,0 @@
import Foundation
/// PCM helpers (16-bit signed little-endian).
///
/// Mirrors Android `audio/PcmKit.kt` — stereo split / mono mix utilities used
/// by the stereo call-translation mode handler.
enum PcmKit {
/// Split an interleaved 16-bit LE stereo buffer into two mono buffers
/// (left, right). Each output has half the input byte length.
static func splitStereo16(_ stereo: Data) -> (Data, Data) {
var left = Data(); left.reserveCapacity(stereo.count / 2)
var right = Data(); right.reserveCapacity(stereo.count / 2)
let count = stereo.count - (stereo.count % 4)
var i = 0
stereo.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
let p = raw.bindMemory(to: UInt8.self).baseAddress!
while i < count {
// L = bytes[i..i+1], R = bytes[i+2..i+3]
left.append(p[i]); left.append(p[i + 1])
right.append(p[i + 2]); right.append(p[i + 3])
i += 4
}
}
return (left, right)
}
}

110
local_plugins/device_jieli/ios/Classes/Bridge/EventDispatcher.swift

@ -1,110 +0,0 @@
import Flutter
import Foundation
/// Native-side event subscriber. Mirrors Android `JieliEventListener`.
/// Implementers receive the same `payload` dictionary that gets pushed to
/// the Flutter EventChannel, so native and Dart subscribers stay symmetric.
///
/// Pure-Swift protocol (not `@objc`) because the payload's `[String: Any?]`
/// cannot be bridged to Obj-C (Obj-C dictionary values can't be nullable).
protocol JieliEventListener: AnyObject {
func onEvent(payload: [String: Any?])
}
/// Event bus. All forwarders / features call [send]; this fans out to:
/// - Flutter side: through the EventChannel sink (on main queue);
/// - Native side: through registered [JieliEventListener]s (sync, on the
/// originating queue — fits high-frequency audio frames).
///
/// Mirrors Android `bridge/EventDispatcher.kt`.
class EventDispatcher: NSObject, FlutterStreamHandler {
private let lock = NSLock()
private var sink: FlutterEventSink?
private var listeners: [JieliEventListener] = []
// MARK: - FlutterStreamHandler
func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink
) -> FlutterError? {
lock.lock(); defer { lock.unlock() }
sink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
lock.lock(); defer { lock.unlock() }
sink = nil
return nil
}
// MARK: - Native listeners
func addNativeListener(_ listener: JieliEventListener) {
lock.lock(); defer { lock.unlock() }
if !listeners.contains(where: { $0 === listener }) {
listeners.append(listener)
}
}
func removeNativeListener(_ listener: JieliEventListener) {
lock.lock(); defer { lock.unlock() }
listeners.removeAll { $0 === listener }
}
// MARK: - Send
/// Broadcast a single event payload.
///
/// Payload schema: identical to Android. `type` is the discriminator key
/// that the Dart `Jielihome._parseEvent(...)` switches on.
func send(_ payload: [String: Any?]) {
let snapshot: [JieliEventListener]
let sinkRef: FlutterEventSink?
lock.lock()
snapshot = listeners
sinkRef = sink
lock.unlock()
for l in snapshot {
l.onEvent(payload: payload)
}
guard let s = sinkRef else { return }
let sanitized = sanitize(payload)
if Thread.isMainThread {
s(sanitized)
} else {
DispatchQueue.main.async { s(sanitized) }
}
}
/// Replace `nil` values with `NSNull()` and re-emit `Data` payloads as
/// `FlutterStandardTypedData.bytes(...)` so Dart receives `Uint8List`
/// rather than an opaque blob (matches Android's ByteArray → Uint8List
/// auto-conversion).
private func sanitize(_ raw: [String: Any?]) -> [String: Any] {
var out = [String: Any]()
out.reserveCapacity(raw.count)
for (k, v) in raw {
out[k] = sanitize(value: v)
}
return out
}
private func sanitize(value: Any?) -> Any {
guard let v = value else { return NSNull() }
if let data = v as? Data {
return FlutterStandardTypedData(bytes: data)
}
if let arr = v as? [Any?] {
return arr.map { sanitize(value: $0) }
}
if let map = v as? [String: Any?] {
return sanitize(map)
}
return v
}
}

327
local_plugins/device_jieli/ios/Classes/Bridge/MethodRouter.swift

@ -1,327 +0,0 @@
import Flutter
import Foundation
import UIKit
/// Method-channel router. Maps every Dart call to the matching feature module.
///
/// Symmetric with Android `bridge/MethodRouter.kt` — same method names, same
/// argument keys. Where the iOS JieLi SDK does not yet have a wired
/// implementation, the handler returns a `NOT_IMPLEMENTED_IOS` error so the
/// Dart layer can downgrade gracefully (instead of crashing).
class MethodRouter {
private let server: JieliHomeServer
init(server: JieliHomeServer) {
self.server = server
}
func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
let args = call.arguments as? [String: Any] ?? [:]
do {
switch call.method {
// MARK: - lifecycle / platform
case "getPlatformVersion":
result("iOS \(UIDevice.current.systemVersion)")
case "initialize":
try server.initialize(
multiDevice: (args["multiDevice"] as? Bool) ?? true,
skipNoNameDev: (args["skipNoNameDev"] as? Bool) ?? false,
enableLog: (args["enableLog"] as? Bool) ?? false
)
result(true)
// MARK: - scan
case "startScan":
let timeoutMs = (args["timeoutMs"] as? Int) ?? 30000
let nameList = (args["nameList"] as? [String]) ?? []
let uuidList = (args["uuidList"] as? [String]) ?? []
let skipUnnamed = (args["skipUnnamed"] as? Bool) ?? true
try server.scanFeature.startScan(
timeoutMs: timeoutMs,
nameList: nameList,
uuidList: uuidList,
skipUnnamed: skipUnnamed
)
result(true)
case "stopScan":
server.scanFeature.stopScan()
result(true)
case "isScanning":
result(server.scanFeature.isScanning())
// MARK: - connect
case "connect":
guard let address = args["address"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "address required", details: nil)); return
}
try server.connectFeature.connect(
bleAddress: address,
edrAddress: args["edrAddr"] as? String,
deviceType: (args["deviceType"] as? Int) ?? -1,
connectWay: (args["connectWay"] as? Int) ?? 0
)
result(true)
case "disconnect":
guard let address = args["address"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "address required", details: nil)); return
}
try server.connectFeature.disconnect(address: address)
result(true)
case "isConnected":
guard let address = args["address"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "address required", details: nil)); return
}
result(server.connectFeature.isConnected(address: address))
case "connectedDevice":
if let info = server.connectFeature.connectedDeviceInfo() {
result(info)
} else {
result(nil)
}
// MARK: - device info
case "deviceSnapshot":
guard let address = args["address"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "address required", details: nil)); return
}
result(server.deviceInfoFeature.snapshot(address: address))
case "queryTargetInfo":
guard let address = args["address"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "address required", details: nil)); return
}
let mask = (args["mask"] as? Int) ?? 0x0F
server.deviceInfoFeature.queryTargetInfo(address: address, mask: mask) { snapshot, error in
if let snapshot = snapshot {
result(snapshot)
} else {
result(FlutterError(
code: "TARGET_INFO_ERR",
message: error?.localizedDescription ?? "queryTargetInfo failed",
details: nil
))
}
}
// MARK: - custom RCSP
case "sendCustomCmd":
guard let address = args["address"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "address required", details: nil)); return
}
guard let opCode = args["opCode"] as? Int else {
result(FlutterError(code: "BAD_ARG", message: "opCode required", details: nil)); return
}
let payloadBytes: [UInt8]
if let typed = args["payload"] as? FlutterStandardTypedData {
payloadBytes = [UInt8](typed.data)
} else if let arr = args["payload"] as? [Int] {
payloadBytes = arr.map { UInt8(truncatingIfNeeded: $0) }
} else {
payloadBytes = []
}
server.customCmdFeature.send(
address: address,
opCode: UInt8(truncatingIfNeeded: opCode),
payload: Data(payloadBytes)
) { response, error in
if let resp = response {
result([UInt8](resp))
} else {
result(FlutterError(
code: "CUSTOM_CMD_ERR",
message: error?.localizedDescription ?? "custom cmd failed",
details: nil
))
}
}
// MARK: - translation
case "startTranslation":
guard let modeId = args["modeId"] as? Int else {
result(FlutterError(code: "BAD_ARG", message: "modeId required", details: nil)); return
}
let extra = (args["args"] as? [String: Any]) ?? [:]
if server.deviceRecordFeature.isRecording() { server.deviceRecordFeature.stop() }
try server.translationFeature.start(modeId: modeId, args: extra)
result(true)
case "stopTranslation":
server.translationFeature.stop()
result(true)
case "translationStatus":
result([
"working": server.translationFeature.isWorking(),
"modeId": server.translationFeature.currentModeId() as Any,
"inputStreams": server.translationFeature.currentInputStreams(),
"outputStreams": server.translationFeature.currentOutputStreams(),
] as [String: Any])
case "feedTranslatedAudio":
guard let streamId = args["streamId"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "streamId required", details: nil)); return
}
let pcm: Data
if let typed = args["pcm"] as? FlutterStandardTypedData {
pcm = typed.data
} else if let arr = args["pcm"] as? [Int] {
pcm = Data(arr.map { UInt8(truncatingIfNeeded: $0) })
} else {
result(FlutterError(code: "BAD_ARG", message: "pcm required", details: nil)); return
}
let ok = server.translationFeature.feedTranslatedAudio(
streamId: streamId,
pcm: pcm,
sampleRate: (args["sampleRate"] as? Int) ?? 16000,
channels: (args["channels"] as? Int) ?? 1,
bitsPerSample: (args["bitsPerSample"] as? Int) ?? 16,
isFinal: (args["final"] as? Bool) ?? false
)
result(ok)
case "feedTranslationResult":
server.translationFeature.feedTranslationResult(
srcLang: args["srcLang"] as? String,
srcText: args["srcText"] as? String,
destLang: args["destLang"] as? String,
destText: args["destText"] as? String,
requestId: args["requestId"] as? String
)
result(true)
case "isSupportCallTranslationWithStereo":
let address = args["address"] as? String
result(server.translationFeature.isSupportCallTranslationWithStereo(address: address))
case "feedAudioFilePcm":
let pcm: Data
if let typed = args["pcm"] as? FlutterStandardTypedData {
pcm = typed.data
} else if let arr = args["pcm"] as? [Int] {
pcm = Data(arr.map { UInt8(truncatingIfNeeded: $0) })
} else {
result(FlutterError(code: "BAD_ARG", message: "pcm required", details: nil)); return
}
let sampleRate = (args["sampleRate"] as? Int) ?? 16000
result(server.translationFeature.feedAudioFilePcm(pcm: pcm, sampleRate: sampleRate))
// MARK: - speech (PTT / wake-word)
case "speechIsRecording":
result(server.speechFeature.isRecording(address: args["address"] as? String))
case "speechStart":
server.speechFeature.start(
address: args["address"] as? String,
voiceType: (args["voiceType"] as? Int) ?? SpeechFeature.voiceTypeOpus,
sampleRate: (args["sampleRate"] as? Int) ?? SpeechFeature.sampleRate16k,
vadWay: (args["vadWay"] as? Int) ?? SpeechFeature.vadWayDevice
) { ok, message in
if ok { result(true) }
else {
result(FlutterError(code: "SPEECH_START_ERR", message: message, details: nil))
}
}
case "speechStop":
server.speechFeature.stop(
address: args["address"] as? String,
reason: (args["reason"] as? Int) ?? SpeechFeature.reasonNormal
) { ok, message in
if ok { result(true) }
else {
result(FlutterError(code: "SPEECH_STOP_ERR", message: message, details: nil))
}
}
// MARK: - assistant always-recording
case "assistantStart":
if server.translationFeature.isWorking() { server.translationFeature.stop() }
if server.deviceRecordFeature.isRecording() { server.deviceRecordFeature.stop() }
let ok = server.assistantBridge.start()
if ok { result(true) }
else {
result(FlutterError(code: "ASSISTANT_START_ERR",
message: "assistant bridge start failed", details: nil))
}
case "assistantStop":
server.assistantBridge.stop()
result(true)
case "assistantIsRunning":
result(server.assistantBridge.isRunning())
// MARK: - device record
case "deviceRecordStart":
let nested = (args["args"] as? [String: Any]) ?? [:]
if server.translationFeature.isWorking() { server.translationFeature.stop() }
try server.deviceRecordFeature.start(args: nested)
result(true)
case "deviceRecordStop":
server.deviceRecordFeature.stop()
result(true)
case "deviceRecordStatus":
result(["recording": server.deviceRecordFeature.isRecording()])
// MARK: - OTA
case "otaStart":
guard let path = args["firmwareFilePath"] as? String else {
result(FlutterError(code: "BAD_ARG", message: "firmwareFilePath required", details: nil)); return
}
let address = args["address"] as? String
let blockSize = (args["blockSize"] as? Int) ?? 512
let fileFlag: Data?
if let typed = args["fileFlag"] as? FlutterStandardTypedData {
fileFlag = typed.data
} else if let arr = args["fileFlag"] as? [Int] {
fileFlag = Data(arr.map { UInt8(truncatingIfNeeded: $0) })
} else {
fileFlag = nil
}
server.otaFeature.start(
address: address,
firmwareFilePath: path,
blockSize: blockSize,
fileFlag: fileFlag
)
result(true)
case "otaCancel":
server.otaFeature.cancel()
result(true)
case "otaIsRunning":
result(server.otaFeature.isRunning())
default:
result(FlutterMethodNotImplemented)
}
} catch let err as PluginError {
result(FlutterError(code: err.code, message: err.message, details: nil))
} catch {
result(FlutterError(code: "PLUGIN_ERR", message: error.localizedDescription, details: nil))
}
}
}
/// Local plugin error wrapping that maps cleanly onto FlutterError. Used by
/// feature classes when they need to surface a typed error code without
/// throwing an opaque NSError.
struct PluginError: Error {
let code: String
let message: String
init(_ code: String, _ message: String) {
self.code = code
self.message = message
}
}

177
local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift

@ -1,177 +0,0 @@
import Foundation
import CoreBluetooth
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// Process-wide singleton mirroring Android `core/JieliHomeServer.kt`.
///
/// All features (scan, connect, info, custom-cmd, translation, speech,
/// device-record, OTA, assistant) hang off this server. Both the Flutter side
/// (via `JielihomePlugin`) and any pure-iOS host code share the same instance.
///
/// Initialization is idempotent — calling [initialize] twice returns
/// immediately on the second call.
class JieliHomeServer {
static let shared = JieliHomeServer()
private(set) public var initialized: Bool = false
/// Event bus shared by Flutter EventChannel + native listeners.
let dispatcher: EventDispatcher = EventDispatcher()
// MARK: - Underlying SDK references
// The JieLi iOS SDK exposes BLE plumbing via `JL_BLEMultiple` and per-device
// command management via `JL_ManagerM` (held inside each `JL_EntityM`).
// We keep one shared `JL_BLEMultiple` for scan/connect across features.
#if canImport(JL_BLEKit)
internal let bleMultiple: JL_BLEMultiple = JL_BLEMultiple()
#endif
/// Shared coordinator owning the per-device-session
/// `JLTranslationManager`. Holds the active translation sink (call
/// translation / assistant / device record); modules switch sinks as they
/// enter/exit modes — only one mode at a time.
private(set) lazy var translationCoordinator: TranslationCoordinator = {
TranslationCoordinator(server: self)
}()
// MARK: - Features (lazy-attached after initialize)
private(set) lazy var scanFeature: ScanFeature = {
ScanFeature(dispatcher: dispatcher)
}()
private(set) lazy var connectFeature: ConnectFeature = {
ConnectFeature(server: self)
}()
private(set) lazy var deviceInfoFeature: DeviceInfoFeature = {
DeviceInfoFeature(server: self)
}()
private(set) lazy var customCmdFeature: CustomCmdFeature = {
CustomCmdFeature(server: self)
}()
private(set) lazy var translationFeature: TranslationFeature = {
TranslationFeature(server: self)
}()
private(set) lazy var speechFeature: SpeechFeature = {
SpeechFeature(server: self)
}()
private(set) lazy var otaFeature: OtaFeature = {
OtaFeature(server: self)
}()
private(set) lazy var deviceRecordFeature: DeviceRecordFeature = {
DeviceRecordFeature(server: self)
}()
private(set) lazy var assistantBridge: AssistantBridge = {
AssistantBridge(server: self)
}()
/// 设备主动推送事件转发器(battery / phoneCallStatus / expandFunction),
/// 对齐 Android 的 `DeviceInfoEventForwarder` + `CustomEventForwarder`。
private(set) lazy var eventForwarders: JieliEventForwarders = {
JieliEventForwarders(server: self)
}()
private init() {}
// MARK: - Lifecycle
func initialize(
multiDevice: Bool,
skipNoNameDev: Bool,
enableLog: Bool
) throws {
if initialized { return }
#if canImport(JL_BLEKit)
// SDK log + auth toggles. Mirrors Android `BluetoothOption` setup.
bleMultiple.ble_FILTER_ENABLE = true
// 7s default per BleManager.swift demo.
bleMultiple.ble_TIMEOUT = 7
// Device authentication on by default — match Android `setUseDeviceAuth(true)`.
bleMultiple.authEnable = true
#endif
// Forward connect/disconnect → scan-feature reset, identical to the
// Android internalCleanupListener in JieliHomeServer.kt.
dispatcher.addNativeListener(internalCleanup)
// Hook scan into shared BLE central; scan publishes its own events.
scanFeature.attach(server: self)
connectFeature.attach()
// 把 SDK 的 NSNotification(电量 / 通话状态 / 自定义协议)转发到 Dart。
eventForwarders.attach()
initialized = true
dispatcher.send([
"type": "adapterStatus",
"enabled": true,
"hasBle": true,
])
}
func shutdown() {
if !initialized { return }
otaFeature.cancel()
deviceRecordFeature.stop()
translationFeature.stop()
speechFeature.detach()
assistantBridge.shutdown()
eventForwarders.detach()
scanFeature.detach()
initialized = false
}
// MARK: - Native event subscription helpers
func addEventListener(_ listener: JieliEventListener) {
dispatcher.addNativeListener(listener)
}
func removeEventListener(_ listener: JieliEventListener) {
dispatcher.removeNativeListener(listener)
}
// MARK: - Internal cleanup listener
// Same role as Android `internalCleanupListener`: when the device
// disconnects or call status flips while in non-call translation mode,
// proactively stop translation/recording to avoid stale state.
private lazy var internalCleanup: InternalCleanupListener = {
InternalCleanupListener(server: self)
}()
}
private final class InternalCleanupListener: NSObject, JieliEventListener {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
func onEvent(payload: [String: Any?]) {
guard let server = server else { return }
let type = payload["type"] as? String
switch type {
case "connectionState":
let state = payload["state"] as? Int ?? -1
// 0 = CONNECTION_DISCONNECT (matches Android constant)
if state == 0 {
if server.translationFeature.isWorking() { server.translationFeature.stop() }
if server.deviceRecordFeature.isRecording() { server.deviceRecordFeature.stop() }
if server.assistantBridge.isRunning() { server.assistantBridge.stop() }
// Tear down the translation coordinator so the next session builds
// a fresh JLTranslationManager bound to the new device's manager.
server.translationCoordinator.tearDown()
}
case "phoneCallStatus":
let status = payload["status"] as? Int ?? 0
if status == 0 { return }
if let mid = server.translationFeature.currentModeId() {
// 3 = MODE_CALL_TRANSLATION, 6 = MODE_CALL_TRANSLATION_WITH_STEREO
let isCallMode = mid == 3 || mid == 6
if !isCallMode && server.translationFeature.isWorking() {
server.translationFeature.stop()
}
}
default:
break
}
}
}

208
local_plugins/device_jieli/ios/Classes/Feature/AssistantBridge.swift

@ -1,208 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// AI-assistant bridge — mirrors Android `feature/assistant/AssistantBridge.kt`
/// + `JieliAssistantPort.kt`.
///
/// Pipeline:
/// 1. Enter RCSP `MODE_RECORD` (=1) with `recordtype = .byDevice`
/// (equivalent to Android `STRATEGY_DEVICE_ALWAYS_RECORDING`). The
/// headset starts pushing OPUS audio frames continuously.
/// 2. `OpusStreamDecoder` (packet size 40 — critical, otherwise 80% of
/// frames get dropped, same gotcha the Android comment calls out) emits
/// 16-bit / 16 kHz / mono / 20 ms PCM.
/// 3. Each PCM frame is published on the EventDispatcher as `assistantAudio`.
/// 4. Errors / lifecycle events as `assistantError` / `assistantStart` /
/// `assistantEnd`.
///
/// TTS playback (stop direction) — for now we accept playback frames and
/// queue them but do not yet write back through A2DP locally. The Android
/// path writes via a `LocalPlayer` (AudioTrack USAGE_MEDIA) which the OS
/// re-routes through the connected A2DP earphone. iOS equivalent is
/// `AVAudioEngine`/`AudioQueue`; the orchestrator can drive this directly
/// or call back through `assistantPlayback` (TODO once needed).
class AssistantBridge {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
private var running: Bool = false
private var decoder: OpusStreamDecoder?
private var sequence: Int64 = 0
#if canImport(JL_BLEKit)
private var sinkWrapper: AssistantSinkWrapper?
#endif
func isRunning() -> Bool { running }
func start() -> Bool {
if running { return true }
#if canImport(JL_BLEKit)
guard let server = server else { return false }
guard let manager = server.translationCoordinator.ensureManager() else {
server.dispatcher.send([
"type": "assistantError",
"code": "device.assistant.no_device",
"message": "no connected device",
])
return false
}
if !manager.trIsSupportTranslate() {
server.dispatcher.send([
"type": "assistantError",
"code": "device.assistant.not_supported",
"message": "device does not support translation",
])
return false
}
// Build the OPUS decoder that turns 40-byte JieLi OPUS packets into
// 16k / 16-bit / mono / 20 ms PCM and republish each frame.
let dec = OpusStreamDecoder(
channels: 1,
packetSize: 40,
sampleRate: 16000,
onPcm: { [weak self] pcm in self?.publishPcm(pcm) },
onError: { [weak self] code, msg in
self?.server?.dispatcher.send([
"type": "assistantError",
"code": "device.assistant.decoder_failed",
"message": "opus: code=\(code) msg=\(msg ?? "")",
])
}
)
dec.start()
decoder = dec
// Install ourselves as the active translation sink before triggering
// `trStartTranslate`. Reusing the call-translation runtime is overkill
// for this single-leg path; we drive the manager directly.
let wrapper = AssistantSinkWrapper(owner: self)
server.translationCoordinator.activeSink = wrapper
sinkWrapper = wrapper
let mode = JLTranslateSetMode()
mode.modeType = .onlyRecord
mode.dataType = .OPUS
mode.channel = 1
mode.sampleRate = 16000
manager.recordtype = .byDevice
// Fire-and-forget — failure surfaces through the delegate as
// `onError`, which we map to `assistantError` events.
manager.trStartTranslate(mode) { [weak self] status, err in
guard let self = self else { return }
if status != .success {
self.server?.dispatcher.send([
"type": "assistantError",
"code": "device.assistant.enter_failed",
"message": "trStartTranslate status=\(status.rawValue) err=\(err?.localizedDescription ?? "")",
])
self.cleanup()
self.running = false
}
}
running = true
server.dispatcher.send([
"type": "assistantStart",
"sampleRate": 16000,
"tsMs": Int(Date().timeIntervalSince1970 * 1000),
])
return true
#else
return false
#endif
}
func stop() {
if !running { return }
running = false
cleanup()
server?.dispatcher.send([
"type": "assistantEnd",
"tsMs": Int(Date().timeIntervalSince1970 * 1000),
])
}
func shutdown() { stop() }
// MARK: - Internal
private func publishPcm(_ pcm: Data) {
sequence &+= 1
server?.dispatcher.send([
"type": "assistantAudio",
"encoding": "pcm16",
"sampleRate": 16000,
"channels": 1,
"bitsPerSample": 16,
"sequence": sequence,
"tsMs": Int(Date().timeIntervalSince1970 * 1000),
"pcm": pcm,
])
}
private func cleanup() {
decoder?.stop(); decoder = nil
#if canImport(JL_BLEKit)
if let server = server, let wrapper = sinkWrapper,
server.translationCoordinator.activeSink === wrapper {
server.translationCoordinator.activeSink = nil
}
sinkWrapper = nil
server?.translationCoordinator.manager?.trExitMode { _, _ in }
#endif
}
// MARK: - Sink callbacks
#if canImport(JL_BLEKit)
fileprivate func handleAudio(_ audio: JLTranslateAudio) {
let payload = audio.data
if payload.isEmpty { return }
switch audio.audioType {
case .PCM:
publishPcm(payload)
case .OPUS:
decoder?.feedEncoded(payload)
default:
// Speex / MSBC / JLA_V2 — not on the assistant path. Drop quietly.
break
}
}
fileprivate func handleModeChange(_ mode: JLTranslateSetMode) {
if mode.modeType == .idle && running {
server?.dispatcher.send([
"type": "assistantError",
"code": "device.assistant.mode_exited",
"message": "headset exited record mode",
])
}
}
fileprivate func handleError(_ error: Error) {
server?.dispatcher.send([
"type": "assistantError",
"code": "device.assistant.translation_error",
"message": error.localizedDescription,
])
}
#endif
}
#if canImport(JL_BLEKit)
private final class AssistantSinkWrapper: TranslationManagerSink {
weak var owner: AssistantBridge?
init(owner: AssistantBridge) { self.owner = owner }
func onModeChange(uuid: String, mode: JLTranslateSetMode) { owner?.handleModeChange(mode) }
func onReceiveAudioData(uuid: String, audio: JLTranslateAudio) { owner?.handleAudio(audio) }
func onError(uuid: String, error: Error) { owner?.handleError(error) }
func onCallingStateChanged(uuid: String, isCalling: Bool) {}
func onSendAudioQueueOver(uuid: String) {}
}
#endif

193
local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift

@ -1,193 +0,0 @@
import Foundation
import CoreBluetooth
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// Connect / disconnect via JL_BLEMultiple. Mirrors `feature/ConnectFeature.kt`.
///
/// Connection state events are published as `connectionState` payloads with
/// the same numeric coding as the Dart `ConnectionStateEvent` constants
/// (0=disconnect, 1=ok, 2=connecting). RCSP-init success surfaces as a
/// separate `rcspInit` event when the JieLi SDK confirms `cmdTargetFeatureResult`
/// returns successfully — same flow Android publishes.
class ConnectFeature: NSObject {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
/// Cached id → entity for lookup by Dart-side `address` (peripheral UUID).
private var entityCache: [String: AnyObject] = [:]
private var connectedId: String?
private var connectedName: String?
func attach() {
#if canImport(JL_BLEKit)
// Hook into the JieLi notifications. The SDK posts named NSNotifications
// (kJL_BLE_M_ENTITY_CONNECTED / kJL_BLE_M_ENTITY_DISCONNECTED).
let nc = NotificationCenter.default
nc.addObserver(self,
selector: #selector(onConnected(_:)),
name: NSNotification.Name(kJL_BLE_M_ENTITY_CONNECTED), object: nil)
nc.addObserver(self,
selector: #selector(onDisconnected(_:)),
name: NSNotification.Name(kJL_BLE_M_ENTITY_DISCONNECTED), object: nil)
nc.addObserver(self,
selector: #selector(onAdapterOff(_:)),
name: NSNotification.Name(kJL_BLE_M_OFF), object: nil)
nc.addObserver(self,
selector: #selector(onAdapterOn(_:)),
name: NSNotification.Name(kJL_BLE_M_ON), object: nil)
#endif
}
func connect(
bleAddress: String,
edrAddress: String?,
deviceType: Int,
connectWay: Int
) throws {
NSLog("%@", "[JieliConnect] connect() start ble=\(bleAddress) edr=\(edrAddress ?? "nil") type=\(deviceType) way=\(connectWay)")
server?.dispatcher.send([
"type": "connectionState",
"address": bleAddress,
"state": 2, // connecting
])
#if canImport(JL_BLEKit)
guard let server = server else { throw PluginError("CONNECT_FAILED", "server not initialized") }
let mult = server.bleMultiple
NSLog("%@", "[JieliConnect] authEnable=\(mult.authEnable) ble_TIMEOUT=\(mult.ble_TIMEOUT) filter=\(mult.ble_FILTER_ENABLE)")
// Build / lookup entity. JL_BLEMultiple keeps a `blePeripheralArr` with
// discovered entities — we resolve by UUID, matching the iOS demo.
let entity: JL_EntityM
if let cached = entityCache[bleAddress] as? JL_EntityM {
entity = cached
NSLog("%@", "[JieliConnect] entity cache hit for \(bleAddress)")
} else if let made = mult.makeEntity(withUUID: bleAddress) {
entity = made
entityCache[bleAddress] = entity
NSLog("%@", "[JieliConnect] entity makeEntity ok for \(bleAddress)")
} else {
NSLog("%@", "[JieliConnect] entity makeEntity FAILED — peripheral not in JL_BLEMultiple cache (\(bleAddress))")
throw PluginError("CONNECT_FAILED", "remote device not found (UUID=\(bleAddress))")
}
mult.connectEntity(entity) { [weak self] status in
guard let self = self else { return }
NSLog("%@", "[JieliConnect] connectEntity callback status=\(status.rawValue) ble=\(bleAddress)")
switch status {
case .paired:
NSLog("%@", "[JieliConnect] PAIRED — issuing cmdTargetFeatureResult (RCSP init handshake) for \(bleAddress)")
self.connectedId = bleAddress
self.connectedName = entity.mPeripheral.name
// RCSP target-feature query — same role as Android's connect-success
// path that issues `cmdTargetFeatureResult` after handshake.
entity.mCmdManager.cmdTargetFeatureResult { result, response, error in
NSLog("%@", "[JieliConnect] cmdTargetFeatureResult result=\(result) response=\(String(describing: response)) error=\(String(describing: error)) for \(bleAddress)")
self.server?.dispatcher.send([
"type": "connectionState",
"address": bleAddress,
"state": 1, // ok / link connected
])
self.server?.dispatcher.send([
"type": "rcspInit",
"address": bleAddress,
"code": 0,
])
}
case .connecting:
NSLog("%@", "[JieliConnect] CONNECTING \(bleAddress)")
case .disconnectOk:
NSLog("%@", "[JieliConnect] DISCONNECT_OK \(bleAddress)")
self.server?.dispatcher.send([
"type": "connectionState",
"address": bleAddress,
"state": 0,
])
default:
// Any other terminal failure → expose as 0/disconnect with a code.
// 注意:authEnable=true 时,未授权设备会在 RCSP 握手阶段被 SDK 主动断开,
// 通常会以 .disconnect 或 .timeout 等非 .paired 状态走到这里。
NSLog("%@", "[JieliConnect] TERMINAL FAILURE status=\(status.rawValue) (authEnable=\(self.server?.bleMultiple.authEnable ?? false)) — surfacing as disconnect for \(bleAddress)")
self.server?.dispatcher.send([
"type": "connectionState",
"address": bleAddress,
"state": 0,
])
}
}
#else
NSLog("%@", "[JieliConnect] JL_BLEKit not linked — cannot connect \(bleAddress)")
throw PluginError("CONNECT_FAILED", "JL_BLEKit framework not linked")
#endif
}
func disconnect(address: String) throws {
#if canImport(JL_BLEKit)
guard let server = server else { return }
let mult = server.bleMultiple
if let entity = entityCache[address] as? JL_EntityM {
mult.disconnectEntity(entity) { _ in }
}
server.dispatcher.send([
"type": "connectionState",
"address": address,
"state": 0,
])
if connectedId == address {
connectedId = nil
connectedName = nil
}
#endif
}
func isConnected(address: String) -> Bool {
return connectedId == address
}
func connectedDeviceInfo() -> [String: Any?]? {
guard let id = connectedId else { return nil }
return ["address": id, "name": connectedName ?? ""]
}
// MARK: - Internal helpers used by sibling features
func currentEntity() -> AnyObject? {
guard let id = connectedId else { return nil }
return entityCache[id]
}
// MARK: - Notifications
@objc private func onConnected(_ note: Notification) {
// The SDK posts the CBPeripheral as the object on Custom-BLE flow. The
// BleMultiple flow publishes via the `paired` callback above, so the only
// job here is to ensure consistency for native subscribers.
if let cbp = note.object as? CBPeripheral {
NSLog("%@", "[JieliConnect] notif kJL_BLE_M_ENTITY_CONNECTED id=\(cbp.identifier.uuidString) name=\(cbp.name ?? "<nil>")")
connectedId = cbp.identifier.uuidString
connectedName = cbp.name
}
}
@objc private func onDisconnected(_ note: Notification) {
if let cbp = note.object as? CBPeripheral {
let id = cbp.identifier.uuidString
NSLog("%@", "[JieliConnect] notif kJL_BLE_M_ENTITY_DISCONNECTED id=\(id) name=\(cbp.name ?? "<nil>")")
server?.dispatcher.send([
"type": "connectionState",
"address": id,
"state": 0,
])
if connectedId == id { connectedId = nil; connectedName = nil }
}
}
@objc private func onAdapterOff(_ note: Notification) {
NSLog("%@", "[JieliConnect] notif kJL_BLE_M_OFF (adapter off)")
server?.dispatcher.send(["type": "adapterStatus", "enabled": false, "hasBle": true])
}
@objc private func onAdapterOn(_ note: Notification) {
NSLog("%@", "[JieliConnect] notif kJL_BLE_M_ON (adapter on)")
server?.dispatcher.send(["type": "adapterStatus", "enabled": true, "hasBle": true])
}
}

50
local_plugins/device_jieli/ios/Classes/Feature/CustomCmdFeature.swift

@ -1,50 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// Custom RCSP command pass-through. Mirrors `feature/CustomCmdFeature.kt`.
///
/// Android's plugin takes (opCode: Int, payload: ByteArray) and the SDK
/// wraps them into a private RCSP frame. The iOS SDK exposes only
/// `cmdCustomData:isNeedResponse:Result:` which takes a single `data`
/// argument, so we prepend the opCode byte to the payload and call that.
/// The reply comes back through the result block. This matches the shape
/// the Dart caller expects: a `[Int]` response.
class CustomCmdFeature {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
func send(
address: String,
opCode: UInt8,
payload: Data,
completion: @escaping (Data?, Error?) -> Void
) {
#if canImport(JL_BLEKit)
guard let entity = server?.connectFeature.currentEntity() as? JL_EntityM,
entity.mUUID == address else {
completion(nil, PluginError("CUSTOM_CMD_ERR", "no active session"))
return
}
var frame = Data(capacity: 1 + payload.count)
frame.append(opCode)
frame.append(payload)
entity.mCmdManager.mCustomManager.cmdCustomData(
frame,
isNeedResponse: true
) { status, _, data in
if status == .success {
completion(data ?? Data(), nil)
} else {
completion(nil, PluginError("CUSTOM_CMD_ERR", "JL_CMDStatus=\(status.rawValue)"))
}
}
#else
completion(nil, PluginError("CUSTOM_CMD_ERR", "JL_BLEKit not linked"))
#endif
}
}

67
local_plugins/device_jieli/ios/Classes/Feature/DeviceInfoFeature.swift

@ -1,67 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// Device snapshot / target-feature queries. Mirrors `feature/DeviceInfoFeature.kt`.
///
/// Returns a flat dictionary readable by the Dart `Jielihome.deviceSnapshot`
/// caller. The Android schema (`name`, `firmwareVersion`, `battery`, ...) is
/// preserved; iOS-specific extras (`bleAddr`, `btAddr`, `pidvid`) are added
/// for completeness — Dart consumers ignore unknown keys.
class DeviceInfoFeature {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
func snapshot(address: String) -> [String: Any?]? {
#if canImport(JL_BLEKit)
guard let entity = server?.connectFeature.currentEntity() as? JL_EntityM,
entity.mUUID == address else {
return nil
}
let model = entity.mCmdManager.outputDeviceModel()
return [
"address": address,
"name": entity.mPeripheral.name ?? "",
"battery": Int(model.battery),
"firmwareVersion": valueIfNotEmpty(model.versionFirmware),
"hardwareVersion": valueIfNotEmpty(model.versionUBoot),
"serialNumber": valueIfNotEmpty(model.pidvid),
"manufacturer": valueIfNotEmpty(model.license),
"model": valueIfNotEmpty(model.proCode),
"btAddr": valueIfNotEmpty(model.btAddr),
"bleAddr": valueIfNotEmpty(model.bleAddr),
]
#else
return nil
#endif
}
func queryTargetInfo(
address: String,
mask: Int,
completion: @escaping ([String: Any?]?, Error?) -> Void
) {
#if canImport(JL_BLEKit)
guard let entity = server?.connectFeature.currentEntity() as? JL_EntityM,
entity.mUUID == address else {
completion(nil, PluginError("TARGET_INFO_ERR", "no active session"))
return
}
entity.mCmdManager.cmdTargetFeatureResult { [weak self] _, _, _ in
let snap = self?.snapshot(address: address)
if let s = snap { completion(s, nil) }
else { completion(nil, PluginError("TARGET_INFO_ERR", "snapshot unavailable")) }
}
#else
completion(nil, PluginError("TARGET_INFO_ERR", "JL_BLEKit not linked"))
#endif
}
private func valueIfNotEmpty(_ s: String?) -> Any {
guard let s = s, !s.isEmpty else { return NSNull() }
return s
}
}

202
local_plugins/device_jieli/ios/Classes/Feature/DeviceRecordFeature.swift

@ -1,202 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// Device-record (会议记录的通话录音). Mirrors Android
/// `feature/record/DeviceRecordFeature.kt`.
///
/// Drives RCSP `MODE_CALL_TRANSLATION` (=3) with `recordtype = .byDevice`
/// (Android equivalent of `STRATEGY_DEVICE_ALWAYS_RECORDING`). The headset
/// continuously pushes both legs (uplink = 本机麦, downlink = 对端通话音)
/// even with no real call event, which is exactly what meeting recording
/// needs. We OPUS-decode each leg into 16k mono PCM and publish:
///
/// - `deviceRecordStart` — recording started
/// - `deviceRecordAudio` — one PCM frame, with `streamId` = "in.uplink" /
/// "in.downlink"
/// - `deviceRecordStop` — stopped
/// - `deviceRecordError` — error
///
/// Mutually exclusive with TranslationFeature — both use the shared
/// `JLTranslationManager`. The MethodRouter stops the other before starting.
class DeviceRecordFeature {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
private var working = false
private var deviceAddress: String?
private var sampleRate: Int = 16000
private var upDecoder: OpusStreamDecoder?
private var downDecoder: OpusStreamDecoder?
#if canImport(JL_BLEKit)
private var sinkWrapper: RecordSinkWrapper?
#endif
func isRecording() -> Bool { working }
func start(args: [String: Any]) throws {
if working { throw PluginError("DEVICE_RECORD_ERR", "already recording") }
#if canImport(JL_BLEKit)
guard let server = server else {
throw PluginError("DEVICE_RECORD_ERR", "server gone")
}
guard let manager = server.translationCoordinator.ensureManager() else {
throw PluginError("DEVICE_RECORD_ERR", "no connected device")
}
if !manager.trIsSupportTranslate() {
throw PluginError("DEVICE_RECORD_ERR", "device does not support translation")
}
let address = (args["address"] as? String)
?? (server.connectFeature.connectedDeviceInfo()?["address"] as? String)
sampleRate = (args["sampleRate"] as? Int) ?? 16000
upDecoder = makeDecoder(streamId: "in.uplink")
downDecoder = makeDecoder(streamId: "in.downlink")
upDecoder?.start()
downDecoder?.start()
let wrapper = RecordSinkWrapper(owner: self)
server.translationCoordinator.activeSink = wrapper
sinkWrapper = wrapper
let mode = JLTranslateSetMode()
mode.modeType = .callTranslate
mode.dataType = .OPUS
mode.channel = 1
mode.sampleRate = sampleRate
manager.recordtype = .byDevice
// Fire-and-forget: failures surface through the delegate's onError →
// mapped to deviceRecordError events by the sink wrapper.
manager.trStartTranslate(mode) { [weak self] status, err in
guard let self = self else { return }
if status != .success {
self.server?.dispatcher.send([
"type": "deviceRecordError",
"address": self.deviceAddress as Any,
"code": Int(status.rawValue),
"message": "trStartTranslate failed err=\(err?.localizedDescription ?? "")",
])
// Tear ourselves down — same shape as the synchronous error path.
self.working = false
self.cleanup()
}
}
working = true
deviceAddress = address
server.dispatcher.send([
"type": "deviceRecordStart",
"address": address as Any,
"sampleRate": sampleRate,
"tsMs": Self.tsMs(),
])
#else
throw PluginError("DEVICE_RECORD_ERR", "JL_BLEKit not linked")
#endif
}
func stop() {
if !working { return }
working = false
let addr = deviceAddress
deviceAddress = nil
cleanup()
server?.dispatcher.send([
"type": "deviceRecordStop",
"address": addr as Any,
"tsMs": Self.tsMs(),
])
}
// MARK: - Internal
private func makeDecoder(streamId: String) -> OpusStreamDecoder {
OpusStreamDecoder(
channels: 1,
packetSize: 40,
sampleRate: sampleRate,
onPcm: { [weak self] pcm in
self?.publishPcm(streamId: streamId, pcm: pcm)
},
onError: { [weak self] code, msg in
self?.server?.dispatcher.send([
"type": "deviceRecordError",
"address": self?.deviceAddress as Any,
"code": code,
"message": "opus[\(streamId)]: \(msg ?? "")",
])
}
)
}
private func publishPcm(streamId: String, pcm: Data) {
server?.dispatcher.send([
"type": "deviceRecordAudio",
"address": deviceAddress as Any,
"streamId": streamId,
"sampleRate": sampleRate,
"channels": 1,
"bitsPerSample": 16,
"tsMs": Self.tsMs(),
"pcm": pcm,
])
}
private func cleanup() {
upDecoder?.stop(); upDecoder = nil
downDecoder?.stop(); downDecoder = nil
#if canImport(JL_BLEKit)
if let server = server, let wrapper = sinkWrapper,
server.translationCoordinator.activeSink === wrapper {
server.translationCoordinator.activeSink = nil
}
sinkWrapper = nil
server?.translationCoordinator.manager?.trExitMode { _, _ in }
#endif
}
static func tsMs() -> Int { Int(Date().timeIntervalSince1970 * 1000) }
// MARK: - Sink
#if canImport(JL_BLEKit)
fileprivate func handleAudio(_ audio: JLTranslateAudio) {
let payload = audio.data
if payload.isEmpty { return }
let kind = RcspTranslationRuntime.SourceKind(rawValue: Int(audio.sourceType.rawValue)) ?? .escoUp
if audio.audioType == .PCM {
let stream = (kind == .escoDown) ? "in.downlink" : "in.uplink"
publishPcm(streamId: stream, pcm: payload)
return
}
switch kind {
case .escoDown: downDecoder?.feedEncoded(payload)
default: upDecoder?.feedEncoded(payload)
}
}
#endif
}
#if canImport(JL_BLEKit)
private final class RecordSinkWrapper: TranslationManagerSink {
weak var owner: DeviceRecordFeature?
init(owner: DeviceRecordFeature) { self.owner = owner }
func onModeChange(uuid: String, mode: JLTranslateSetMode) {}
func onReceiveAudioData(uuid: String, audio: JLTranslateAudio) { owner?.handleAudio(audio) }
func onError(uuid: String, error: Error) {
owner?.server?.dispatcher.send([
"type": "deviceRecordError",
"code": (error as NSError).code,
"message": error.localizedDescription,
])
}
func onCallingStateChanged(uuid: String, isCalling: Bool) {}
func onSendAudioQueueOver(uuid: String) {}
}
#endif

165
local_plugins/device_jieli/ios/Classes/Feature/JieliEventForwarders.swift

@ -1,165 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// 把 JL_BLEKit 的 NSNotification(设备主动推送)翻译成 EventDispatcher
/// 事件。对齐 Android `event/DeviceInfoEventForwarder` + `CustomEventForwarder`:
///
/// - `kJL_MANAGER_SYSTEM_INFO` / `kJL_MANAGER_TARGET_INFO` → `battery`
/// (耳机端电量变化、放回仓、设备主动同步系统信息时都会触发;这里只在
/// `outputDeviceModel().battery` 真的变化时才往 Dart 抛,避免高频空包)
/// - `kJL_MANAGER_CALL_STATUS` → `phoneCallStatus`
/// (通话状态变化;BleManager._isCall 跟 InternalCleanupListener 都依赖它)
/// - `kJL_MANAGER_CUSTOM_DATA` → `expandFunction`
/// (耳机厂商私有协议主动上报,opCode=0x16/0x17 用来发起/停止通话翻译;
/// Android 的 `CustomEventForwarder.onExpandFunction` 等价路径)
///
/// 通知 payload 格式来自 SDK 头注释:
/// `@{ kJL_MANAGER_KEY_UUID: uuid, kJL_MANAGER_KEY_OBJECT: object }`
/// 实际可能放在 `note.object` 也可能在 `note.userInfo`,所以读取做了兜底。
final class JieliEventForwarders: NSObject {
weak var server: JieliHomeServer?
private var lastBatteryLevel: Int = -1
private var lastCallStatus: Int = -1
init(server: JieliHomeServer) {
self.server = server
super.init()
}
func attach() {
#if canImport(JL_BLEKit)
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(onSystemInfo(_:)),
name: NSNotification.Name(kJL_MANAGER_SYSTEM_INFO), object: nil)
nc.addObserver(self, selector: #selector(onTargetInfo(_:)),
name: NSNotification.Name(kJL_MANAGER_TARGET_INFO), object: nil)
nc.addObserver(self, selector: #selector(onCallStatus(_:)),
name: NSNotification.Name(kJL_MANAGER_CALL_STATUS), object: nil)
nc.addObserver(self, selector: #selector(onCustomData(_:)),
name: NSNotification.Name(kJL_MANAGER_CUSTOM_DATA), object: nil)
// 设备断开时清空去重缓存,避免下一次重连首次电量/通话状态变化被
// "和上次一样" 误判而吞掉。
nc.addObserver(self, selector: #selector(onEntityDisconnected(_:)),
name: NSNotification.Name(kJL_BLE_M_ENTITY_DISCONNECTED), object: nil)
#endif
}
func detach() {
NotificationCenter.default.removeObserver(self)
lastBatteryLevel = -1
lastCallStatus = -1
}
// MARK: - Helpers
/// SDK 把 `@{UUID, OBJECT}` 字典塞在 NSNotification 的哪个字段并不一致
/// (`object` 或 `userInfo`),这里两个位置都查一遍。
private func payloadDict(_ note: Notification) -> [AnyHashable: Any]? {
if let dict = note.object as? [AnyHashable: Any] { return dict }
if let info = note.userInfo, !info.isEmpty { return info }
return nil
}
#if canImport(JL_BLEKit)
private func uuid(from note: Notification) -> String {
let dict = payloadDict(note) ?? [:]
if let s = dict[kJL_MANAGER_KEY_UUID] as? String { return s }
return ""
}
private func obj(from note: Notification) -> Any? {
payloadDict(note)?[kJL_MANAGER_KEY_OBJECT]
}
#endif
// MARK: - Battery
@objc private func onSystemInfo(_ note: Notification) {
publishBatteryIfChanged(note: note)
}
@objc private func onTargetInfo(_ note: Notification) {
publishBatteryIfChanged(note: note)
}
private func publishBatteryIfChanged(note: Notification) {
#if canImport(JL_BLEKit)
guard let server = server, server.initialized else { return }
let address = uuid(from: note)
// 当前 session 只跟踪一个已连接设备;UUID 对不上就放弃,避免抢
// 别的 entity 的状态。
guard let entity = server.connectFeature.currentEntity() as? JL_EntityM,
!address.isEmpty, entity.mUUID == address else { return }
let battery = Int(entity.mCmdManager.outputDeviceModel().battery)
// 0 是 SDK 没拿到信息时的占位;忽略 0 → 非 0 之外的"覆盖性 0",
// 防止短暂的握手抖动把已经显示出来的电量重置回 0。
if battery == 0 && lastBatteryLevel > 0 { return }
if battery == lastBatteryLevel { return }
lastBatteryLevel = battery
server.dispatcher.send([
"type": "battery",
"address": address,
"level": battery,
])
#endif
}
// MARK: - Phone call status
@objc private func onCallStatus(_ note: Notification) {
guard let server = server, server.initialized else { return }
#if canImport(JL_BLEKit)
let address = uuid(from: note)
// 杰理 iOS SDK 的 call status 通过 NSNumber 抛上来;解不出来就当 0
// (= idle),整段链路语义都是 "status != 0 表示通话中"。
let status: Int
if let n = obj(from: note) as? NSNumber {
status = n.intValue
} else if let n = obj(from: note) as? Int {
status = n
} else {
status = 0
}
if status == lastCallStatus { return }
lastCallStatus = status
server.dispatcher.send([
"type": "phoneCallStatus",
"address": address,
"status": status,
])
#endif
}
// MARK: - Disconnect reset
@objc private func onEntityDisconnected(_ note: Notification) {
lastBatteryLevel = -1
lastCallStatus = -1
}
// MARK: - Expand function (vendor custom command, device-initiated)
@objc private func onCustomData(_ note: Notification) {
guard let server = server, server.initialized else { return }
#if canImport(JL_BLEKit)
let address = uuid(from: note)
guard let data = obj(from: note) as? Data, !data.isEmpty else { return }
// Android 的 onExpandFunction(opCode, payload) 把首字节当 opCode,
// 其余作为 payload。iOS `cmdCustomData` 出站方向也是这么拼的
// (见 CustomCmdFeature.send),按对称把入站拆回去。
let opCode = Int(data[0])
let payload = data.count > 1 ? data.subdata(in: 1..<data.count) : Data()
server.dispatcher.send([
"type": "expandFunction",
"address": address,
"opCode": opCode,
"payloadBase64": payload.base64EncodedString(),
])
#endif
}
}

137
local_plugins/device_jieli/ios/Classes/Feature/OtaFeature.swift

@ -1,137 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
#if canImport(JL_OTALib)
import JL_OTALib
#endif
/// OTA via JL_OTALib + `JL_BLEMultiple.otaFunc...`. Mirrors Android
/// `feature/ota/OtaFeature.kt`.
///
/// Drives `JL_BLEMultiple.otaFunc(withEntityM:withFilePath:Result:)` (the
/// "simple" path used by the iOS demo) and remaps the `JL_OTAResult` /
/// progress callback into the same `otaState` / `otaError` event shapes
/// the Android side publishes — Dart `_parseOtaState(...)` keeps working
/// unchanged.
class OtaFeature {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
private var running: Bool = false
func isRunning() -> Bool { running }
func start(
address: String?,
firmwareFilePath: String,
blockSize: Int,
fileFlag: Data?
) {
if running { return }
#if canImport(JL_BLEKit)
guard let server = server,
let entity = server.connectFeature.currentEntity() as? JL_EntityM else {
server?.dispatcher.send([
"type": "otaError",
"code": -1,
"message": "no active session",
])
return
}
running = true
server.dispatcher.send([
"type": "otaState",
"state": "INQUIRING",
"sent": 0, "total": 0, "percent": -1, "tsMs": Self.tsMs(),
])
// JL_OTA_RT 是 nullable closure (`JL_OTA_RT __nullable`),Swift 不接受
// trailing closure 给 nullable closure 参数 —— 显式拼出 closure 字面量。
let cb: JL_OTA_RT = { [weak self] result, progress in
self?.onOtaCallback(result: result, progress: progress)
}
// 注意 label:Obj-C selector `otaFuncWithEntityM:withFilePath:Result:`
// 经 Swift importer 的 omit-needless-words 规则把 `EntityM` 去掉(与
// 参数类型 JL_EntityM 尾部重复),第一个 label 变成 `with:`。
server.bleMultiple.otaFunc(
with: entity,
withFilePath: firmwareFilePath,
result: cb
)
#else
server?.dispatcher.send([
"type": "otaError",
"code": -1,
"message": "JL_OTALib not linked",
])
#endif
}
func cancel() {
if !running { return }
running = false
server?.dispatcher.send([
"type": "otaState",
"state": "CANCELLED",
"sent": 0, "total": 0, "percent": -1, "tsMs": Self.tsMs(),
])
}
#if canImport(JL_OTALib)
/// Map `JL_OTAResult` (~30 distinct cases) to the smaller Android-style
/// state machine used by Dart. We funnel "in-progress / preparing /
/// upgrading" into TRANSFERRING with the float `progress` (0..1) scaled
/// to 0..100, terminal success → DONE, anything else → FAILED.
private func onOtaCallback(result: JL_OTAResult, progress: Float) {
guard let server = server else { return }
switch result {
case .preparing, .prepared:
server.dispatcher.send([
"type": "otaState", "state": "ENTERING",
"sent": 0, "total": 0, "percent": -1, "tsMs": Self.tsMs(),
])
case .upgrading:
let percent = max(0, min(100, Int(progress * 100)))
server.dispatcher.send([
"type": "otaState", "state": "TRANSFERRING",
"sent": 0, "total": 0, "percent": percent, "tsMs": Self.tsMs(),
])
case .reboot, .reconnect, .reconnectWithMacAddr, .reconnectUpdateSource:
server.dispatcher.send([
"type": "otaState", "state": "REBOOTING",
"sent": 0, "total": 0, "percent": 100, "tsMs": Self.tsMs(),
])
case .success:
running = false
server.dispatcher.send([
"type": "otaState", "state": "DONE",
"sent": 0, "total": 0, "percent": 100, "tsMs": Self.tsMs(),
])
case .cancel:
running = false
server.dispatcher.send([
"type": "otaState", "state": "CANCELLED",
"sent": 0, "total": 0, "percent": -1, "tsMs": Self.tsMs(),
])
default:
// Anything else from the JL_OTAResult enum is a terminal failure.
running = false
server.dispatcher.send([
"type": "otaState", "state": "FAILED",
"sent": 0, "total": 0, "percent": -1, "tsMs": Self.tsMs(),
])
server.dispatcher.send([
"type": "otaError",
"code": Int(result.rawValue),
"message": "JL_OTAResult=\(result.rawValue)",
])
}
}
#endif
private static func tsMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}
}

263
local_plugins/device_jieli/ios/Classes/Feature/ScanFeature.swift

@ -1,263 +0,0 @@
import Foundation
import CoreBluetooth
/// BLE scan via CoreBluetooth. Mirrors Android `feature/ScanFeature.kt`.
///
/// Like the Android implementation, scanning is **not** delegated to the JieLi
/// SDK's built-in `scan()` (which has its own filterContent/strategy quirks);
/// instead we drive `CBCentralManager` directly and parse the advertisement
/// data ourselves. The published `deviceFound` event payload matches the
/// Android schema 1:1 so the Dart parser is unchanged:
///
/// ```
/// {
/// type: "deviceFound",
/// name, address, rssi,
/// edrAddr, deviceType, connectWay, // null on iOS — see note below
/// rawAdv, advFlags, manufacturerCompanyId, manufacturerData,
/// serviceUuids: [String], advRecords: [{len, type, data}]
/// }
/// ```
///
/// `edrAddr` / `deviceType` / `connectWay` are populated on Android by feeding
/// the raw scan record through `ParseDataUtil.isFilterBleDevice(...)`. On iOS
/// the equivalent live in `JL_AdvParse`'s `JLAdvParse` Obj-C class; until the
/// exact entry point is wired we leave them null and rely on the connection
/// path's BLE-only fallback (matches the Android "fallback when SDK can't
/// parse" behavior).
class ScanFeature: NSObject, CBCentralManagerDelegate {
private weak var server: JieliHomeServer?
private weak var dispatcher: EventDispatcher?
/// Dedicated central manager for scanning — separated from the SDK's own
/// `JL_BLEMultiple` central so that filter knobs do not collide.
private var centralManager: CBCentralManager?
private var scanning: Bool = false
private var stopWorkItem: DispatchWorkItem?
private var currentNameList: [String] = []
private var currentSkipUnnamed: Bool = true
private var serviceFilters: [CBUUID] = []
// Track which addresses have been published this scan to dedupe.
private var publishedIds: Set<String> = []
init(dispatcher: EventDispatcher) {
self.dispatcher = dispatcher
super.init()
}
func attach(server: JieliHomeServer) {
self.server = server
if centralManager == nil {
centralManager = CBCentralManager(delegate: self, queue: .main)
}
}
func detach() {
stopScan()
centralManager = nil
}
// MARK: - Public API (mirrors Android signatures)
func startScan(
timeoutMs: Int,
nameList: [String],
uuidList: [String],
skipUnnamed: Bool
) throws {
guard let central = centralManager else {
throw PluginError("SCAN_FAILED", "BLE central unavailable")
}
if scanning { stopScan() }
currentNameList = nameList.filter { !$0.isEmpty }
currentSkipUnnamed = skipUnnamed
serviceFilters = uuidList.compactMap { Self.parseUuid($0) }
publishedIds.removeAll(keepingCapacity: true)
if central.state != .poweredOn {
// Match Android behavior — scan request from a powered-off adapter
// surfaces the same shape of error.
throw PluginError("SCAN_FAILED", "BLE adapter not powered on (state=\(central.state.rawValue))")
}
let services: [CBUUID]? = serviceFilters.isEmpty ? nil : serviceFilters
let options: [String: Any] = [
// Allow duplicate adverts so we keep getting RSSI updates.
CBCentralManagerScanOptionAllowDuplicatesKey: false
]
central.scanForPeripherals(withServices: services, options: options)
scanning = true
dispatcher?.send([
"type": "scanStatus",
"ble": true,
"started": true,
])
let work = DispatchWorkItem { [weak self] in self?.stopScan() }
stopWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeoutMs), execute: work)
}
func stopScan() {
if let work = stopWorkItem { work.cancel(); stopWorkItem = nil }
if let central = centralManager, central.state == .poweredOn {
central.stopScan()
}
if scanning {
dispatcher?.send([
"type": "scanStatus",
"ble": true,
"started": false,
])
}
scanning = false
currentNameList = []
serviceFilters = []
}
func isScanning() -> Bool { scanning }
// MARK: - CBCentralManagerDelegate
func centralManagerDidUpdateState(_ central: CBCentralManager) {
let on = central.state == .poweredOn
dispatcher?.send([
"type": "adapterStatus",
"enabled": on,
"hasBle": true,
])
if !on && scanning { stopScan() }
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
let name = peripheral.name
?? (advertisementData[CBAdvertisementDataLocalNameKey] as? String)
// === 调试用:打印每一条广播(过滤前),完整 dump 广播 hex ===
// iOS CoreBluetooth 不暴露原始广播字节,这里把 advertisementData
// 里所有字段按 BLE AD type 拼出 hex 方便核对。
let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let serviceUuids = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data]
let txPower = advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber
let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? false
let solicited = advertisementData[CBAdvertisementDataSolicitedServiceUUIDsKey] as? [CBUUID]
let overflow = advertisementData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID]
let mfrHex = manufacturerData?.map { String(format: "%02X", $0) }.joined() ?? "nil"
let svcUuids = serviceUuids?.map { $0.uuidString }.joined(separator: ",") ?? ""
let svcDataHex = serviceData?.map { (k, v) in
"\(k.uuidString)=" + v.map { String(format: "%02X", $0) }.joined()
}.joined(separator: ";") ?? "nil"
let txPowerStr = txPower.map { "\($0.intValue)" } ?? "nil"
let solicitedStr = solicited?.map { $0.uuidString }.joined(separator: ",") ?? ""
let overflowStr = overflow?.map { $0.uuidString }.joined(separator: ",") ?? ""
NSLog("%@", "================ [JieliScan] didDiscover ================")
NSLog("%@", "[JieliScan] name = \(name ?? "<nil>")")
NSLog("%@", "[JieliScan] localName = \(localName ?? "<nil>")")
NSLog("%@", "[JieliScan] id = \(peripheral.identifier.uuidString)")
NSLog("%@", "[JieliScan] rssi = \(RSSI) txPower=\(txPowerStr) connectable=\(isConnectable)")
NSLog("%@", "[JieliScan] mfr(hex) = \(mfrHex)")
if let m = manufacturerData, m.count >= 2 {
let cid = Int(m[0]) | (Int(m[1]) << 8)
let payload = m.count > 2
? m[2..<m.count].map { String(format: "%02X", $0) }.joined()
: ""
NSLog("%@", "[JieliScan] mfr.companyId = 0x\(String(format: "%04X", cid)) payload=\(payload)")
}
NSLog("%@", "[JieliScan] svcUuids = [\(svcUuids)]")
NSLog("%@", "[JieliScan] svcData = \(svcDataHex)")
if !solicitedStr.isEmpty { NSLog("%@", "[JieliScan] solicited = [\(solicitedStr)]") }
if !overflowStr.isEmpty { NSLog("%@", "[JieliScan] overflow = [\(overflowStr)]") }
NSLog("%@", "[JieliScan] =========================================================")
if currentSkipUnnamed && (name?.isEmpty ?? true) { return }
if !currentNameList.isEmpty {
guard let n = name, currentNameList.contains(where: { $0.caseInsensitiveCompare(n) == .orderedSame }) else { return }
}
let id = peripheral.identifier.uuidString
if !publishedIds.insert(id).inserted { return }
let serviceUuidsUpper = serviceUuids?.map { $0.uuidString.uppercased() } ?? []
var advFlags: Int? = nil
var mfrCompanyId: Int? = nil
var mfrPayloadHex: String? = nil
var advRecords: [[String: String]] = []
// iOS 拿不到原始扫描记录字节,但 `JieliAdvParser` (Dart 侧) 为 iOS
// 设计的偏移本就是从 manufacturer data(company id 开头)算起的:
// bytes[0..1]=companyId, [4..5]=PID, [7..12]=MAC^0x5A, [13..22]=code
// 所以把 manufacturer 数据完整 hex 喂给 `rawAdv` 即可让绑定流程跑通。
let rawAdvHex: String? = manufacturerData?
.map { String(format: "%02X", $0) }
.joined()
if let m = manufacturerData, m.count >= 2 {
mfrCompanyId = Int(m[0]) | (Int(m[1]) << 8)
if m.count > 2 {
mfrPayloadHex = m[2..<m.count].map { String(format: "%02X", $0) }.joined()
}
}
// BLE Core Spec §11 ad-record reconstruction is best-effort on iOS:
// CoreBluetooth surfaces parsed fields only. We synthesize a minimal
// record list for parity with Android's structured payload.
if let m = manufacturerData {
// 0xFF — Manufacturer Specific Data
advRecords.append([
"len": String(format: "%02d", m.count + 1),
"type": "0xFF",
"data": "0x" + m.map { String(format: "%02X", $0) }.joined(),
])
}
if let n = name, !n.isEmpty {
let bytes = Array(n.utf8)
advRecords.append([
"len": String(format: "%02d", bytes.count + 1),
"type": "0x09", // Complete Local Name
"data": "0x" + bytes.map { String(format: "%02X", $0) }.joined(),
])
}
var payload: [String: Any?] = [
"type": "deviceFound",
"name": name ?? "",
// iOS does not expose the BLE MAC in CoreBluetooth — use the peripheral
// identifier UUID as a stable address. Downstream ConnectFeature looks
// it up by the same id.
"address": id,
"edrAddr": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available
"deviceType": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available
"connectWay": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available
"rssi": RSSI.intValue,
"rawAdv": (rawAdvHex as Any?) ?? NSNull(),
"advRecords": advRecords,
"advFlags": advFlags as Any,
"manufacturerCompanyId": mfrCompanyId as Any,
"manufacturerData": mfrPayloadHex as Any,
"serviceUuids": serviceUuidsUpper,
]
dispatcher?.send(payload)
}
// MARK: - UUID parsing (matches Android `parseUuid`)
static func parseUuid(_ raw: String) -> CBUUID? {
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !s.isEmpty else { return nil }
return CBUUID(string: s)
}
}

59
local_plugins/device_jieli/ios/Classes/Feature/SpeechFeature.swift

@ -1,59 +0,0 @@
import Foundation
/// PTT / wake-word path. Mirrors `feature/voice/SpeechFeature.kt`.
///
/// **STUBBED on iOS.** The Android implementation drives
/// `RecordOpImpl.startVoiceRecord(...)` plus an OPUS decoder that emits
/// `speechAudio` PCM frames. The iOS counterpart sits on
/// `JL_SpeechAIttsHandler` / `JL_SpeexManager`, which need explicit wiring
/// against the SDK's stream callbacks. Until that path is implemented:
///
/// - `start(...)` / `stop(...)` invoke the completion with `false` + an
/// explanatory message;
/// - `isRecording()` returns `false`.
///
/// The MethodChannel surface is preserved so the Dart layer (which already
/// expects errors here on missing devices) sees a clean `SPEECH_START_ERR`.
class SpeechFeature {
static let voiceTypePcm = 0
static let voiceTypeSpeex = 1
static let voiceTypeOpus = 2
static let sampleRate8k = 8
static let sampleRate16k = 16
static let vadWayDevice = 0
static let vadWaySdk = 1
static let reasonNormal = 0
static let reasonStop = 1
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
func isRecording(address: String?) -> Bool { false }
func start(
address: String?,
voiceType: Int,
sampleRate: Int,
vadWay: Int,
completion: @escaping (Bool, String?) -> Void
) {
// TODO: wire to JL_SpeechAIttsHandler / JL_SpeexManager.
completion(false, "speech.start not yet implemented on iOS")
}
func stop(
address: String?,
reason: Int,
completion: @escaping (Bool, String?) -> Void
) {
completion(false, "speech.stop not yet implemented on iOS")
}
func detach() {
// No resources held while stubbed.
}
}

217
local_plugins/device_jieli/ios/Classes/Feature/Translation/Mode/CallTranslationModeHandler.swift

@ -1,217 +0,0 @@
import Foundation
/// MODE_CALL_TRANSLATION — eSCO uplink + downlink. Mirrors Android
/// `mode/CallTranslationModeHandler.kt`.
///
/// Inputs: `in.uplink` (本机用户) + `in.downlink` (对端).
/// Outputs: `out.uplink` (TTS 给对端) + `out.downlink` (TTS 给本机用户).
final class CallTranslationModeHandler: BaseTranslationModeHandler {
override var modeId: Int { TranslationModeIds.MODE_CALL_TRANSLATION }
override var inputStreams: [String] {
[TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK]
}
override var outputStreams: [String] {
[TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK]
}
private var runtime: RcspTranslationRuntime?
override func start(args: [String: Any]) throws {
guard !isWorking else { return }
guard let server = server else {
throw PluginError("TRANSLATION_ERR", "server gone")
}
let sampleRate = (args["sampleRate"] as? Int) ?? 16000
let codec = Self.parseAudioCodec(args["audioType"]) ?? .opus
let recordByDevice = Self.parseRecordByDevice(args["strategy"]) ?? true
let rt = RcspTranslationRuntime(
server: server,
modeId: modeId,
codec: codec,
channels: 1,
sampleRate: sampleRate,
recordByDevice: recordByDevice,
onPcm: { [weak self] source, pcm in
guard let self = self else { return }
let stream: String
switch source {
case .escoUp: stream = TranslationStreams.IN_UPLINK
case .escoDown: stream = TranslationStreams.IN_DOWNLINK
default: return
}
self.pushFrame(
streamId: stream,
pcm: pcm,
format: TranslationAudioFormat(sampleRate: sampleRate)
)
},
onError: { [weak self] code, msg in
self?.emitError(code: code, message: msg)
}
)
do {
try rt.start()
runtime = rt
setWorking(true)
emitLog("CallTranslation start codec=\(codec) sr=\(sampleRate) recordByDevice=\(recordByDevice)")
} catch {
rt.stop()
throw error
}
}
override func stop() {
if !isWorking { return }
runtime?.stop()
runtime = nil
setWorking(false)
emitLog("CallTranslation stop")
}
@discardableResult
override func onTranslatedAudio(
outputStreamId: String,
pcm: Data,
format: TranslationAudioFormat,
isFinal: Bool
) -> Bool {
guard outputStreamId == TranslationStreams.OUT_UPLINK ||
outputStreamId == TranslationStreams.OUT_DOWNLINK else { return false }
return runtime?.feedTtsPcm(
outputStreamId: outputStreamId,
pcm: pcm,
isFinal: isFinal
) ?? false
}
}
/// MODE_CALL_TRANSLATION_WITH_STEREO — same I/O contract as
/// CallTranslationModeHandler but the SDK pushes a single stereo stream
/// (`escoMix`) which we split L=uplink / R=downlink in software.
final class StereoCallTranslationModeHandler: BaseTranslationModeHandler {
override var modeId: Int { TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO }
override var inputStreams: [String] {
[TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK]
}
override var outputStreams: [String] {
[TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK]
}
private var runtime: RcspTranslationRuntime?
override func start(args: [String: Any]) throws {
guard !isWorking else { return }
guard let server = server else {
throw PluginError("TRANSLATION_ERR", "server gone")
}
let sampleRate = (args["sampleRate"] as? Int) ?? 16000
let codec = Self.parseAudioCodec(args["audioType"]) ?? .opus
let recordByDevice = Self.parseRecordByDevice(args["strategy"]) ?? true
let rt = RcspTranslationRuntime(
server: server,
modeId: modeId,
codec: codec,
channels: 2,
sampleRate: sampleRate,
recordByDevice: recordByDevice,
onPcm: { [weak self] source, stereoPcm in
guard let self = self else { return }
// The stereo decoder produces interleaved 16-bit LE; split → two mono.
if source != .escoMix && source != .escoUp { return }
let (left, right) = PcmKit.splitStereo16(stereoPcm)
let fmt = TranslationAudioFormat(sampleRate: sampleRate)
self.pushFrame(streamId: TranslationStreams.IN_UPLINK, pcm: left, format: fmt)
self.pushFrame(streamId: TranslationStreams.IN_DOWNLINK, pcm: right, format: fmt)
},
onError: { [weak self] code, msg in
self?.emitError(code: code, message: msg)
}
)
do {
try rt.start()
runtime = rt
setWorking(true)
emitLog("StereoCallTranslation start codec=\(codec) sr=\(sampleRate)")
} catch {
rt.stop()
throw error
}
}
override func stop() {
if !isWorking { return }
runtime?.stop()
runtime = nil
setWorking(false)
emitLog("StereoCallTranslation stop")
}
@discardableResult
override func onTranslatedAudio(
outputStreamId: String,
pcm: Data,
format: TranslationAudioFormat,
isFinal: Bool
) -> Bool {
guard outputStreamId == TranslationStreams.OUT_UPLINK ||
outputStreamId == TranslationStreams.OUT_DOWNLINK else { return false }
return runtime?.feedTtsPcm(
outputStreamId: outputStreamId,
pcm: pcm,
isFinal: isFinal
) ?? false
}
}
/// MODE_RECORD — single-leg uplink only. Used by both `JieliAssistantPort`
/// and `DeviceRecordFeature` (the latter via MODE_CALL_TRANSLATION but with
/// recordByDevice=true so eSCO frames flow with no real call). Re-exported
/// here so future mode dispatching has a placeholder.
final class RecordOnlyModeHandler: BaseTranslationModeHandler {
override var modeId: Int { TranslationModeIds.MODE_RECORD }
override var inputStreams: [String] { [TranslationStreams.IN_MIC] }
override var outputStreams: [String] { [] }
private var runtime: RcspTranslationRuntime?
override func start(args: [String: Any]) throws {
guard !isWorking else { return }
guard let server = server else {
throw PluginError("TRANSLATION_ERR", "server gone")
}
let sampleRate = (args["sampleRate"] as? Int) ?? 16000
let codec = Self.parseAudioCodec(args["audioType"]) ?? .opus
let rt = RcspTranslationRuntime(
server: server,
modeId: modeId,
codec: codec,
channels: 1,
sampleRate: sampleRate,
recordByDevice: true,
onPcm: { [weak self] _, pcm in
self?.pushFrame(
streamId: TranslationStreams.IN_MIC,
pcm: pcm,
format: TranslationAudioFormat(sampleRate: sampleRate)
)
},
onError: { [weak self] code, msg in
self?.emitError(code: code, message: msg)
}
)
try rt.start()
runtime = rt
setWorking(true)
}
override func stop() {
if !isWorking { return }
runtime?.stop()
runtime = nil
setWorking(false)
}
}

415
local_plugins/device_jieli/ios/Classes/Feature/Translation/RcspTranslationRuntime.swift

@ -1,415 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// iOS counterpart of Android `feature/translation/runtime/RcspTranslationRuntime.kt`.
///
/// Drives `JLTranslationManager.trStartTranslate(_:block:)` (the iOS
/// equivalent of `TranslationImpl.enterMode`) and:
///
/// - Decodes incoming OPUS frames (`onReceiveAudioData`) to 16k mono PCM via
/// `OpusStreamDecoder`, hands them to the supplied `onPcm(source:pcm:)`
/// callback. PCM-mode (no codec) is also supported — frames pass through
/// unchanged.
/// - Buffers TTS PCM per-leg, periodically flushes (default 1 s) by encoding
/// the whole buffer with `OpusStreamEncoder` and writing it back via
/// `JLTranslationManager.trWrite(_:translate:)`. The original received
/// `JLTranslateAudio` frame is kept so we can clone its codec/CRC layout
/// into the write — exactly what the demo does (`TranslateVM.swift:295-318`).
final class RcspTranslationRuntime {
enum AudioCodecKind { case opus, pcm, jla_v2 }
/// SDK-equivalent of Android's `AudioData.SOURCE_*`. We export raw Int
/// values so callers can match against the same numbers Android uses.
enum SourceKind: Int {
case file = 0 // JLTranslateAudioTypeFile
case deviceMic = 1 // JLTranslateAudioTypeDeviceMic
case phoneMic = 2 // JLTranslateAudioTypePhoneMic
case escoUp = 3 // JLTranslateAudioTypeESCOUp
case escoDown = 4 // JLTranslateAudioTypeESCODown
case a2dp = 5 // JLTranslateAudioTypeA2DP
case escoMix = 6 // JLTranslateAudioTypeESCOMax
}
weak var server: JieliHomeServer?
/// Mode descriptor — decides what goes into `trStartTranslate`.
let modeId: Int
let codec: AudioCodecKind
let channels: Int
let sampleRate: Int
let recordByDevice: Bool
/// Callbacks supplied by the mode handler.
private let onPcm: (SourceKind, Data) -> Void
private let onError: (Int, String?) -> Void
/// Flush cadence for OPUS feed — 1 s matches the long-term-stable Android
/// setting (`PERIODIC_FLUSH_MS = 1000L`). Faster cadences cause the
/// headset's RCSP decoder to reset and produce dropouts.
private let periodicFlushSeconds: TimeInterval = 1.0
private let pcmBufferHardLimitBytes: Int = 2 * 1024 * 1024
// OPUS decoders — one per leg in stereo, two for mono call translation,
// one for assistant / device record.
private var upDecoder: OpusStreamDecoder?
private var downDecoder: OpusStreamDecoder?
private var stereoDecoder: OpusStreamDecoder?
// OPUS encoders for TTS write-back. One per output leg.
private var encoders: [String: OpusStreamEncoder] = [:]
private var encoderQueues: [String: Data] = [:] // pending encoded output
private let encoderLock = NSLock()
// TTS PCM accumulators keyed by output stream id.
private var pcmBuffers: [String: Data] = [:]
private let bufferLock = NSLock()
private var flushTimer: DispatchSourceTimer?
private var sinkWrapper: SinkWrapper?
/// Reference `JLTranslateAudio` captured from the most recent SDK uplink
/// frame. We clone it (with sourceType remapped) when writing TTS back —
/// this is the trick the demo uses (TranslateVM.swift:295-318).
#if canImport(JL_BLEKit)
private var referenceAudio: JLTranslateAudio?
#endif
private(set) var working: Bool = false
init(
server: JieliHomeServer,
modeId: Int,
codec: AudioCodecKind,
channels: Int,
sampleRate: Int,
recordByDevice: Bool,
onPcm: @escaping (SourceKind, Data) -> Void,
onError: @escaping (Int, String?) -> Void
) {
self.server = server
self.modeId = modeId
self.codec = codec
self.channels = channels
self.sampleRate = sampleRate
self.recordByDevice = recordByDevice
self.onPcm = onPcm
self.onError = onError
}
// MARK: - Public API (matches Android RcspTranslationRuntime)
func start() throws {
guard let server = server else {
throw PluginError("TRANSLATION_ERR", "server gone")
}
#if canImport(JL_BLEKit)
guard let manager = server.translationCoordinator.ensureManager() else {
throw PluginError("TRANSLATION_ERR", "no active session")
}
if !manager.trIsSupportTranslate() {
throw PluginError("TRANSLATION_ERR", "device does not support translation")
}
// Build OPUS decoders for the legs we expect to receive frames on.
if codec == .opus {
switch modeId {
case TranslationModeIds.MODE_CALL_TRANSLATION:
upDecoder = makeDecoder()
downDecoder = makeDecoder()
case TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO:
stereoDecoder = makeDecoder(channels: 2)
default:
// Assistant / record / face-to-face: a single uplink decoder.
upDecoder = makeDecoder()
}
}
upDecoder?.start()
downDecoder?.start()
stereoDecoder?.start()
// Compose the iOS mode object & install the sink.
let mode = JLTranslateSetMode()
mode.modeType = JLTranslateSetModeType(rawValue: UInt(modeId)) ?? .idle
mode.channel = channels
mode.sampleRate = sampleRate
mode.dataType = mapCodecToSpeakDataType(codec)
manager.recordtype = recordByDevice ? .byDevice : .byPhone
let wrapper = SinkWrapper(owner: self)
server.translationCoordinator.activeSink = wrapper
sinkWrapper = wrapper
// Fire-and-forget start — match Android's RcspTranslationRuntime.start
// semantics: kick off enterMode, surface terminal failures via the
// delegate's onError. The mode-change confirmation arrives on
// `onModeChange` which we already track.
manager.trStartTranslate(mode) { [weak self] status, err in
if status != .success {
let msg = err?.localizedDescription ?? "trStartTranslate status=\(status.rawValue)"
self?.onError(Int(status.rawValue), msg)
}
}
if codec == .opus { startPeriodicFlushTimer() }
working = true
#else
throw PluginError("TRANSLATION_ERR", "JL_BLEKit not linked")
#endif
}
func stop() {
cleanup()
working = false
}
/// Outside-injected TTS PCM. Mirrors Android `feedTtsPcm` — accumulate or
/// pass-through (PCM mode), flush periodically (OPUS mode).
func feedTtsPcm(outputStreamId: String, pcm: Data, isFinal: Bool) -> Bool {
#if canImport(JL_BLEKit)
guard working else { return false }
let source = sourceForOutputStream(outputStreamId)
if codec == .pcm {
writeBack(source: source, type: .PCM, payload: pcm)
return true
}
var pendingFlush: Data?
bufferLock.lock()
var buf = pcmBuffers[outputStreamId] ?? Data()
if !pcm.isEmpty {
if buf.count + pcm.count > pcmBufferHardLimitBytes {
buf.removeAll(keepingCapacity: true)
} else {
buf.append(pcm)
}
}
if isFinal {
pendingFlush = buf
buf.removeAll(keepingCapacity: true)
}
pcmBuffers[outputStreamId] = buf
bufferLock.unlock()
if let toFlush = pendingFlush, !toFlush.isEmpty {
encodeAndDispatchAsync(source: source, leg: outputStreamId, pcmBytes: toFlush)
}
return true
#else
return false
#endif
}
// MARK: - Internal
#if canImport(JL_BLEKit)
/// SinkWrapper exists so `RcspTranslationRuntime` (a class) can be
/// referenced weakly while still satisfying the @objc-class delegate
/// expectations of the coordinator.
private final class SinkWrapper: TranslationManagerSink {
weak var owner: RcspTranslationRuntime?
init(owner: RcspTranslationRuntime) { self.owner = owner }
func onModeChange(uuid: String, mode: JLTranslateSetMode) {
owner?.handleModeChange(mode: mode)
}
func onReceiveAudioData(uuid: String, audio: JLTranslateAudio) {
owner?.handleReceiveAudio(audio: audio)
}
func onError(uuid: String, error: Error) {
owner?.handleError(error)
}
func onCallingStateChanged(uuid: String, isCalling: Bool) {}
func onSendAudioQueueOver(uuid: String) {}
}
private func handleModeChange(mode: JLTranslateSetMode) {
if mode.modeType == .idle {
// Headset proactively exited (e.g., button press). Surface as error
// to the orchestrator so it can decide to fully stop.
onError(-100, "headset exited mode → IDLE")
}
}
private func handleReceiveAudio(audio: JLTranslateAudio) {
referenceAudio = audio
let payload = audio.data
if payload.isEmpty { return }
let src = SourceKind(rawValue: Int(audio.sourceType.rawValue)) ?? .escoUp
if codec == .pcm || audio.audioType == .PCM {
onPcm(src, payload)
return
}
switch src {
case .escoUp: upDecoder?.feedEncoded(payload)
case .escoDown: downDecoder?.feedEncoded(payload)
case .escoMix: stereoDecoder?.feedEncoded(payload)
default:
// Assistant / record paths still funnel through escoUp on most
// firmware; if not, fall back to upDecoder.
upDecoder?.feedEncoded(payload)
}
}
private func handleError(_ error: Error) {
let nse = error as NSError
onError(nse.code, nse.localizedDescription)
}
private func mapCodecToSpeakDataType(_ codec: AudioCodecKind) -> JL_SpeakDataType {
switch codec {
case .pcm: return .PCM
case .opus: return .OPUS
case .jla_v2: return .JLA_V2
}
}
private func sourceForOutputStream(_ id: String) -> SourceKind {
switch id {
case TranslationStreams.OUT_UPLINK: return .escoUp
case TranslationStreams.OUT_DOWNLINK: return .escoDown
default: return .phoneMic
}
}
private func makeDecoder(channels: Int = 1) -> OpusStreamDecoder {
OpusStreamDecoder(
channels: channels,
// 16k mono 20ms = 40-byte JieLi packets; stereo doubles.
packetSize: channels == 2 ? 80 : 40,
sampleRate: sampleRate,
onPcm: { [weak self] pcm in
guard let self = self else { return }
// The decoder doesn't know which leg the bytes came from, but the
// higher-level handler already routed them to a specific decoder.
// Re-tag based on which decoder fired. We can't tell here; the
// handler's `onPcm` takes a SourceKind — we report the SDK source
// when it was set via referenceAudio.sourceType.
let src = self.lastReceivedSource()
self.onPcm(src, pcm)
},
onError: { [weak self] code, msg in self?.onError(code, msg) }
)
}
/// Map the most recently received audio's sourceType to our enum so
/// downstream PCM consumers see the correct leg. We track it per decoder
/// firing — there's only one in-flight decode call at a time per decoder.
private func lastReceivedSource() -> SourceKind {
guard let ref = referenceAudio else { return .escoUp }
return SourceKind(rawValue: Int(ref.sourceType.rawValue)) ?? .escoUp
}
// MARK: - TTS write-back
private func startPeriodicFlushTimer() {
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
timer.schedule(deadline: .now() + periodicFlushSeconds,
repeating: periodicFlushSeconds)
timer.setEventHandler { [weak self] in self?.periodicFlush() }
flushTimer = timer
timer.resume()
}
private func periodicFlush() {
var pending: [(SourceKind, String, Data)] = []
bufferLock.lock()
for (leg, buf) in pcmBuffers where !buf.isEmpty {
pending.append((sourceForOutputStream(leg), leg, buf))
pcmBuffers[leg] = Data()
}
bufferLock.unlock()
for (src, leg, pcm) in pending {
encodeAndDispatchAsync(source: src, leg: leg, pcmBytes: pcm)
}
}
private func encodeAndDispatchAsync(source: SourceKind, leg: String, pcmBytes: Data) {
encoderLock.lock()
let encoder: OpusStreamEncoder
if let existing = encoders[leg] {
encoder = existing
} else {
let queueKey = leg
encoderQueues[queueKey] = Data()
let e = OpusStreamEncoder(
channels: 1,
sampleRate: sampleRate,
onEncoded: { [weak self] opus in
guard let self = self else { return }
self.encoderLock.lock()
var q = self.encoderQueues[queueKey] ?? Data()
q.append(opus)
self.encoderQueues[queueKey] = q
self.encoderLock.unlock()
},
onError: { [weak self] c, m in self?.onError(c, "encoder[\(leg)]: \(m ?? "")") }
)
e.start()
encoders[leg] = e
encoder = e
}
encoderLock.unlock()
encoder.feedPcm(pcmBytes)
// After feeding, drain whatever the encoder emitted into a single write.
encoderLock.lock()
let drained = encoderQueues[leg] ?? Data()
encoderQueues[leg] = Data()
encoderLock.unlock()
if !drained.isEmpty {
writeBack(source: source, type: .OPUS, payload: drained)
}
}
/// Send a packed payload back to the headset by reusing the most recent
/// reference frame, swapping its sourceType, and calling
/// `JLTranslationManager.trWrite(_:translate:)`.
private func writeBack(source: SourceKind, type: JL_SpeakDataType, payload: Data) {
guard let server = server,
let manager = server.translationCoordinator.manager else { return }
let template = referenceAudio?.copy() as? JLTranslateAudio ?? JLTranslateAudio()
template.sourceType = JLTranslateAudioSourceType(rawValue: UInt(source.rawValue)) ?? .typeESCOUp
template.audioType = type
template.data = payload
template.len = Int32(payload.count)
manager.trWrite(template, translate: payload)
}
#endif
private func cleanup() {
flushTimer?.cancel()
flushTimer = nil
upDecoder?.stop(); upDecoder = nil
downDecoder?.stop(); downDecoder = nil
stereoDecoder?.stop(); stereoDecoder = nil
encoderLock.lock()
encoders.values.forEach { $0.stop() }
encoders.removeAll()
encoderQueues.removeAll()
encoderLock.unlock()
bufferLock.lock()
pcmBuffers.removeAll()
bufferLock.unlock()
#if canImport(JL_BLEKit)
if let server = server, let manager = server.translationCoordinator.manager {
manager.trExitMode { _, _ in }
}
if let server = server, let wrapper = sinkWrapper {
// Only clear the active sink if it is still us — guards against
// races with a follow-up handler that already installed itself.
if server.translationCoordinator.activeSink === wrapper {
server.translationCoordinator.activeSink = nil
}
}
sinkWrapper = nil
referenceAudio = nil
#endif
}
}

134
local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationAudioBridge.swift

@ -1,134 +0,0 @@
import Foundation
/// Bridge between a TranslationModeHandler and the outside world (Flutter
/// EventChannel by default; can be replaced by host code for native-direct
/// orchestration). Mirrors Android `feature/translation/TranslationAudioBridge.kt`.
protocol TranslationAudioBridge: AnyObject {
/// Plugin → outside: one captured PCM frame from the device.
func emitAudioFrame(
modeId: Int,
streamId: String,
pcm: Data,
format: TranslationAudioFormat,
sequence: Int64,
tsMs: Int64,
isFinal: Bool
)
/// Plugin → outside: subtitle text (purely informational).
func emitTranslationResult(
modeId: Int,
srcLang: String?,
srcText: String?,
destLang: String?,
destText: String?,
requestId: String?
)
/// Plugin → outside: log line.
func emitLog(modeId: Int, content: String)
/// Plugin → outside: error.
func emitError(modeId: Int, code: Int, message: String?)
/// Outside → plugin: TTS PCM coming back from the translation service.
/// The mode handler decides how to repackage and write it back to the
/// device (uplink / downlink / local speaker).
@discardableResult
func feedTranslatedAudio(
modeId: Int,
outputStreamId: String,
pcm: Data,
format: TranslationAudioFormat,
isFinal: Bool
) -> Bool
}
/// Default bridge backed by the EventDispatcher → Flutter EventChannel
/// path. Mirrors Android `feature/translation/EventChannelAudioBridge.kt`.
final class EventChannelAudioBridge: TranslationAudioBridge {
private weak var dispatcher: EventDispatcher?
/// Closure invoked when feedTranslatedAudio is called. The default impl
/// routes back into TranslationFeature — same as Android's `injector`.
private let injector: (Int, String, Data, TranslationAudioFormat, Bool) -> Bool
init(
dispatcher: EventDispatcher,
injector: @escaping (Int, String, Data, TranslationAudioFormat, Bool) -> Bool
) {
self.dispatcher = dispatcher
self.injector = injector
}
func emitAudioFrame(
modeId: Int,
streamId: String,
pcm: Data,
format: TranslationAudioFormat,
sequence: Int64,
tsMs: Int64,
isFinal: Bool
) {
dispatcher?.send([
"type": "translationAudio",
"modeId": modeId,
"streamId": streamId,
"sampleRate": format.sampleRate,
"channels": format.channels,
"bitsPerSample": format.bitsPerSample,
"seq": sequence,
"tsMs": tsMs,
"final": isFinal,
"pcm": pcm,
])
}
func emitTranslationResult(
modeId: Int,
srcLang: String?,
srcText: String?,
destLang: String?,
destText: String?,
requestId: String?
) {
dispatcher?.send([
"type": "translationResult",
"modeId": modeId,
"srcLang": srcLang as Any,
"srcText": srcText as Any,
"destLang": destLang as Any,
"destText": destText as Any,
"requestId": requestId as Any,
])
}
func emitLog(modeId: Int, content: String) {
dispatcher?.send([
"type": "translationLog",
"modeId": modeId,
"content": content,
])
}
func emitError(modeId: Int, code: Int, message: String?) {
dispatcher?.send([
"type": "translationError",
"modeId": modeId,
"code": code,
"message": message as Any,
])
}
@discardableResult
func feedTranslatedAudio(
modeId: Int,
outputStreamId: String,
pcm: Data,
format: TranslationAudioFormat,
isFinal: Bool
) -> Bool {
injector(modeId, outputStreamId, pcm, format, isFinal)
}
}

110
local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationCoordinator.swift

@ -1,110 +0,0 @@
import Foundation
#if canImport(JL_BLEKit)
import JL_BLEKit
#endif
/// Internal sink protocol that `RcspTranslationRuntime` (and any other
/// `JLTranslationManager` consumer) implements. The coordinator routes
/// incoming SDK callbacks to the currently active sink.
protocol TranslationManagerSink: AnyObject {
#if canImport(JL_BLEKit)
func onModeChange(uuid: String, mode: JLTranslateSetMode)
func onReceiveAudioData(uuid: String, audio: JLTranslateAudio)
func onError(uuid: String, error: Error)
func onCallingStateChanged(uuid: String, isCalling: Bool)
func onSendAudioQueueOver(uuid: String)
#endif
}
/// Owns the shared `JLTranslationManager` for the connected device.
///
/// Why share: the JieLi iOS SDK keeps state inside `JLTranslationManager`
/// (current mode, send queue, isCalling KVO target). The demo creates one
/// per device session and reuses it across mode switches via `trStartTranslate`
/// / `trExitMode`. We follow the same pattern: lazily build the manager on
/// first use, route delegate callbacks to the active runtime sink, recreate
/// only when the device disconnects.
///
/// Only one [TranslationManagerSink] is active at a time. Each mode handler
/// (call translation / assistant / device record) installs itself before
/// calling `trStartTranslate(...)` and removes itself on exit.
final class TranslationCoordinator: NSObject {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) {
self.server = server
super.init()
}
#if canImport(JL_BLEKit)
private(set) var manager: JLTranslationManager?
#endif
/// Single active sink — only set while a mode is running.
weak var activeSink: TranslationManagerSink?
private var calledOnInitSuccess = false
// MARK: - Manager lifecycle
#if canImport(JL_BLEKit)
/// Build the shared manager if needed; reuse existing one if alive.
/// Returns `nil` only if the device is not connected at all.
func ensureManager() -> JLTranslationManager? {
if let m = manager { return m }
guard let server = server,
let entity = server.connectFeature.currentEntity() as? JL_EntityM else {
return nil
}
let m = JLTranslationManager(
delegate: self,
manager: entity.mCmdManager
) { [weak self] _, _ in
// init result — we ignore the boolean because trIsSupportTranslate
// gives us the same answer when we actually try to enter a mode.
self?.calledOnInitSuccess = true
}
manager = m
return m
}
#endif
func tearDown() {
activeSink = nil
#if canImport(JL_BLEKit)
manager?.trDestory()
manager = nil
#endif
calledOnInitSuccess = false
}
}
#if canImport(JL_BLEKit)
extension TranslationCoordinator: JLTranslationManagerDelegate {
func onInitSuccess(_ uuid: String) {
calledOnInitSuccess = true
}
func onModeChange(_ uuid: String, mode: JLTranslateSetMode) {
activeSink?.onModeChange(uuid: uuid, mode: mode)
}
func onReceiveAudioData(_ uuid: String, audioData data: JLTranslateAudio) {
activeSink?.onReceiveAudioData(uuid: uuid, audio: data)
}
func onError(_ uuid: String, error: Error) {
activeSink?.onError(uuid: uuid, error: error)
}
func isOnCalling(_ isCalling: Bool, uuid: String) {
activeSink?.onCallingStateChanged(uuid: uuid, isCalling: isCalling)
}
func onSendAudioQueueOver(_ uuid: String) {
activeSink?.onSendAudioQueueOver(uuid: uuid)
}
}
#endif

130
local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationModeHandler.swift

@ -1,130 +0,0 @@
import Foundation
/// Mirrors Android `feature/translation/TranslationModeHandler.kt`.
///
/// Each mode handler declares which input/output streams it consumes/produces,
/// owns an `RcspTranslationRuntime` (when the mode actually uses RCSP), and
/// routes incoming PCM frames to the bridge.
protocol TranslationModeHandler: AnyObject {
var modeId: Int { get }
var inputStreams: [String] { get }
var outputStreams: [String] { get }
var isWorking: Bool { get }
func start(args: [String: Any]) throws
func stop()
/// Outside service hands back TTS PCM; `outputStreamId` selects which
/// physical leg to write to.
@discardableResult
func onTranslatedAudio(
outputStreamId: String,
pcm: Data,
format: TranslationAudioFormat,
isFinal: Bool
) -> Bool
}
/// Shared scaffolding — keeps the bridge reference and a `working` flag.
class BaseTranslationModeHandler: TranslationModeHandler {
weak var server: JieliHomeServer?
weak var bridge: TranslationAudioBridge?
init(server: JieliHomeServer, bridge: TranslationAudioBridge) {
self.server = server
self.bridge = bridge
}
var modeId: Int { TranslationModeIds.MODE_IDLE }
var inputStreams: [String] { [] }
var outputStreams: [String] { [] }
private(set) var working: Bool = false
var isWorking: Bool { working }
func start(args: [String: Any]) throws { fatalError("override") }
func stop() { working = false }
@discardableResult
func onTranslatedAudio(
outputStreamId: String,
pcm: Data,
format: TranslationAudioFormat,
isFinal: Bool
) -> Bool { false }
// MARK: - Helpers for subclasses
func setWorking(_ on: Bool) { working = on }
func pushFrame(streamId: String, pcm: Data, format: TranslationAudioFormat) {
bridge?.emitAudioFrame(
modeId: modeId,
streamId: streamId,
pcm: pcm,
format: format,
sequence: Self.nextSeq(),
tsMs: Self.tsMs(),
isFinal: false
)
}
func emitLog(_ content: String) {
bridge?.emitLog(modeId: modeId, content: content)
}
func emitError(code: Int, message: String?) {
bridge?.emitError(modeId: modeId, code: code, message: message)
}
// MARK: - Static helpers
private static var seqGen: Int64 = 0
private static let seqLock = NSLock()
static func nextSeq() -> Int64 {
seqLock.lock(); defer { seqLock.unlock() }
seqGen &+= 1
return seqGen
}
static func tsMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
// MARK: - Arg parsing utilities
static func parseAudioCodec(_ raw: Any?) -> RcspTranslationRuntime.AudioCodecKind? {
if let s = raw as? String {
switch s.lowercased() {
case "opus": return .opus
case "pcm": return .pcm
case "jla_v2", "jlav2": return .jla_v2
default: return nil
}
}
if let n = raw as? Int {
switch n {
case 0: return .pcm
case 2: return .opus
case 4: return .jla_v2
default: return nil
}
}
return nil
}
/// Map "always" / "auto" / "device" / "phone" → `recordByDevice` boolean.
/// The iOS SDK collapses Android's three strategies to two
/// (`byPhone`/`byDevice`); both ALWAYS and AUTO map to byDevice.
static func parseRecordByDevice(_ raw: Any?) -> Bool? {
if let b = raw as? Bool { return b }
if let s = raw as? String {
switch s.lowercased() {
case "always", "auto", "device", "byDevice".lowercased(): return true
case "phone", "byPhone".lowercased(), "custom": return false
default: return nil
}
}
return nil
}
}

35
local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationStreams.swift

@ -1,35 +0,0 @@
import Foundation
/// Stream-id constants. Mirror Android `TranslationStreams.kt` 1:1 so the
/// Dart side keeps the same routing semantics.
enum TranslationStreams {
// input streams (plugin → translation service)
static let IN_MIC = "in.mic"
static let IN_UPLINK = "in.uplink"
static let IN_DOWNLINK = "in.downlink"
static let IN_AUDIO_FILE = "in.audioFile"
// output streams (translation service → plugin → device)
static let OUT_SPEAKER = "out.speaker"
static let OUT_UPLINK = "out.uplink"
static let OUT_DOWNLINK = "out.downlink"
static let OUT_LOCAL_PLAYBACK = "out.localPlayback"
}
/// Mode-ID constants — same numeric values as Android `TranslationModeIds`
/// and the iOS SDK enum `JLTranslateSetModeType`.
enum TranslationModeIds {
static let MODE_IDLE = 0
static let MODE_RECORD = 1
static let MODE_RECORDING_TRANSLATION = 2
static let MODE_CALL_TRANSLATION = 3
static let MODE_AUDIO_TRANSLATION = 4
static let MODE_FACE_TO_FACE_TRANSLATION = 5
static let MODE_CALL_TRANSLATION_WITH_STEREO = 6
}
/// PCM frame format — bare minimum metadata for downstream consumers.
struct TranslationAudioFormat: Equatable {
var sampleRate: Int = 16000
var channels: Int = 1
var bitsPerSample: Int = 16
}

156
local_plugins/device_jieli/ios/Classes/Feature/TranslationFeature.swift

@ -1,156 +0,0 @@
import Foundation
/// Translation entry point. Mirrors Android `feature/translation/TranslationFeature.kt`.
///
/// Dispatches `start(modeId:args:)` to the matching mode handler. The set of
/// supported modes mirrors Android — call translation (mode 3 / 6) plus the
/// record-only path used internally by AssistantBridge / DeviceRecordFeature
/// (mode 1).
///
/// The `feedTranslatedAudio` / `feedTranslationResult` paths route to the
/// active handler / bridge, exactly like Android.
class TranslationFeature: NSObject {
weak var server: JieliHomeServer?
private var bridge: TranslationAudioBridge?
private var handlers: [Int: TranslationModeHandler] = [:]
private var current: TranslationModeHandler?
init(server: JieliHomeServer) {
self.server = server
super.init()
let defaultBridge = EventChannelAudioBridge(
dispatcher: server.dispatcher,
injector: { [weak self] modeId, streamId, pcm, fmt, isFinal in
return self?.feedTranslatedAudio(
streamId: streamId,
pcm: pcm,
sampleRate: fmt.sampleRate,
channels: fmt.channels,
bitsPerSample: fmt.bitsPerSample,
isFinal: isFinal
) ?? false
}
)
setBridge(defaultBridge)
}
/// Replace the active bridge — used by host code that wants to keep
/// audio inside native (mirrors Android `setTranslationBridge`).
func setBridge(_ bridge: TranslationAudioBridge) {
self.bridge = bridge
guard let server = server else { return }
handlers = [
TranslationModeIds.MODE_CALL_TRANSLATION:
CallTranslationModeHandler(server: server, bridge: bridge),
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO:
StereoCallTranslationModeHandler(server: server, bridge: bridge),
TranslationModeIds.MODE_RECORD:
RecordOnlyModeHandler(server: server, bridge: bridge),
]
}
// MARK: - Public API
func isWorking() -> Bool { current?.isWorking ?? false }
func currentModeId() -> Int? {
guard let h = current, h.isWorking else { return nil }
return h.modeId
}
func currentInputStreams() -> [String] {
current?.isWorking == true ? (current?.inputStreams ?? []) : []
}
func currentOutputStreams() -> [String] {
current?.isWorking == true ? (current?.outputStreams ?? []) : []
}
func start(modeId: Int, args: [String: Any]) throws {
// Match Android logic — auto-upgrade mode=3 + OPUS to mode=6 stereo when
// the device supports it (and caller did not opt out).
let bypassStereo = (args["bypassStereoUpgrade"] as? Bool) ?? false
let codecRaw = args["audioType"]
let codecIsOpus: Bool = {
if codecRaw == nil { return true }
if let s = codecRaw as? String { return s.lowercased() == "opus" }
if let n = codecRaw as? Int { return n == 2 }
return false
}()
var effectiveModeId = modeId
if !bypassStereo &&
modeId == TranslationModeIds.MODE_CALL_TRANSLATION &&
codecIsOpus &&
isSupportCallTranslationWithStereo(address: args["address"] as? String) {
effectiveModeId = TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO
}
guard let handler = handlers[effectiveModeId] else {
throw PluginError("TRANSLATION_ERR", "unknown modeId=\(effectiveModeId)")
}
current?.stop()
try handler.start(args: args)
current = handler
}
func stop() {
current?.stop()
current = nil
}
@discardableResult
func feedTranslatedAudio(
streamId: String,
pcm: Data,
sampleRate: Int,
channels: Int,
bitsPerSample: Int,
isFinal: Bool
) -> Bool {
guard let h = current, h.isWorking else { return false }
if !h.outputStreams.contains(streamId) { return false }
return h.onTranslatedAudio(
outputStreamId: streamId,
pcm: pcm,
format: TranslationAudioFormat(
sampleRate: sampleRate,
channels: channels,
bitsPerSample: bitsPerSample
),
isFinal: isFinal
)
}
func feedTranslationResult(
srcLang: String?, srcText: String?,
destLang: String?, destText: String?,
requestId: String?
) {
guard let mid = current?.modeId else { return }
bridge?.emitTranslationResult(
modeId: mid,
srcLang: srcLang, srcText: srcText,
destLang: destLang, destText: destText,
requestId: requestId
)
}
/// Mirrors Android `isSupportCallTranslationWithStereo`. The iOS SDK does
/// not expose a direct boolean for this; we use `trIsPlayWithA2dp`
/// (which is true on TWS earphones that support the stereo path) plus the
/// `JLDeviceConfigTws.isSupportOpusStereo` flag when available. As a
/// pragmatic default we just return `trIsPlayWithA2dp()`, matching the
/// demo's `setupModeForCall` heuristic.
func isSupportCallTranslationWithStereo(address: String?) -> Bool {
#if canImport(JL_BLEKit)
guard let server = server else { return false }
return server.translationCoordinator.manager?.trIsPlayWithA2dp() ?? false
#else
return false
#endif
}
/// Audio-translation mode (file-driven) is not yet wired on iOS — the
/// dedicated handler maps to MODE_AUDIO_TRANSLATION which the demo drives
/// by starting recording-and-play. Returns `false` until implemented to
/// keep the Dart contract intact.
func feedAudioFilePcm(pcm: Data, sampleRate: Int) -> Bool { false }
}

31
local_plugins/device_jieli/ios/Classes/Integration/JieliNativeDevicePlugin.swift

@ -1,31 +0,0 @@
import Foundation
/// Vendor registration entry — placeholder symmetric with Android's
/// `integration/JieliNativeDevicePlugin.kt` calling
/// `NativeDevicePluginRegistry.register(...)`.
///
/// On Android the registry lives in `device_plugin_interface` (Kotlin) and is
/// a process-wide map keyed by vendor key. On iOS the equivalent registry has
/// not yet been ported (the `device_plugin_interface` iOS pod is currently a
/// no-op, matching its no-op Android side). When/if the Swift-side registry
/// lands, this method should:
///
/// 1. Build a `VendorDescriptor(vendorKey: "jieli", displayName: "JieLi (杰理)",
/// capabilities: [.scan, .connect, .bond, .customCommand,
/// .onDeviceCallTranslation, .onDeviceFaceToFaceTranslation,
/// .onDeviceRecordingTranslation])`;
/// 2. Provide a factory that returns a `NativeDevicePlugin` Swift wrapper
/// around `JieliHomeServer.shared`;
/// 3. Hand both to the registry.
///
/// Until then `register()` is a no-op so the plugin still loads cleanly.
enum JieliNativeDevicePlugin {
static let kVendor = "jieli"
static func register() {
// TODO: when device_plugin_interface iOS gains a NativeDevicePluginRegistry,
// wire the JieLi factory here, mirroring Android's
// `JieliNativeDevicePlugin.register(context)`.
}
}

50
local_plugins/device_jieli/ios/Classes/JielihomePlugin.swift

@ -1,50 +0,0 @@
import Flutter
import UIKit
/// Flutter entry point — mirrors the Android `JielihomePlugin.kt`.
///
/// Responsibilities (kept symmetric with Android):
/// 1. Bind `device_jieli/method` MethodChannel to [MethodRouter];
/// 2. Bind `device_jieli/event` EventChannel to the shared [EventDispatcher];
/// 3. Register the JieLi vendor with the device-manager native registry
/// (placeholder until [JieliNativeDevicePlugin.register] is wired).
///
/// All actual business logic lives in [JieliHomeServer] and the feature/event
/// submodules under `Classes/Feature/`.
public class JielihomePlugin: NSObject, FlutterPlugin {
private var methodChannel: FlutterMethodChannel?
private var eventChannel: FlutterEventChannel?
private var router: MethodRouter?
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = JielihomePlugin()
instance.attach(registrar: registrar)
}
private func attach(registrar: FlutterPluginRegistrar) {
let server = JieliHomeServer.shared
let router = MethodRouter(server: server)
self.router = router
let methodChannel = FlutterMethodChannel(
name: "device_jieli/method",
binaryMessenger: registrar.messenger()
)
methodChannel.setMethodCallHandler { call, result in
router.handle(call: call, result: result)
}
self.methodChannel = methodChannel
let eventChannel = FlutterEventChannel(
name: "device_jieli/event",
binaryMessenger: registrar.messenger()
)
eventChannel.setStreamHandler(server.dispatcher)
self.eventChannel = eventChannel
// Vendor registration with the device-manager registry. Symmetric to
// Android's `JieliNativeDevicePlugin.register(...)`.
JieliNativeDevicePlugin.register()
}
}

18
local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/Headers/JLAV2Lib.h

@ -1,18 +0,0 @@
//
// JLAV2Lib.h
// JLAV2Lib
//
// Created by ZPC21-032 on 2025/4/9.
//
#import <Foundation/Foundation.h>
//! Project version number for JLAV2Lib.
FOUNDATION_EXPORT double JLAV2LibVersionNumber;
//! Project version string for JLAV2Lib.
FOUNDATION_EXPORT const unsigned char JLAV2LibVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <JLAV2Lib/PublicHeader.h>
#import <JLAV2Lib/jla_v2_codec_api.h>

96
local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/Headers/jla_v2_codec_api.h

@ -1,96 +0,0 @@
#ifndef _JLA_V2_CODEC_h
#define _JLA_V2_CODEC_h
#ifndef u16
#define u32 unsigned int
#define u16 unsigned short
#define s16 short
#define u8 unsigned char
#endif
#define SET_DECODE_MODE 0x80
#define SET_DEC_CH_MODE 0x81
//#define SET_FADE_IN_TMS 0x82 //for dec plc deleted
#define SET_FADE_IN_OUT 0x82 //淡入淡出 设置生效.
#define SET_NEW_BITRATE 0x90 //enc.
#define GET_EDFRAME_LEN 0x91 //
#define SET_EDFRAME_LEN 0x92 //only for dec.
#define SET_HW_FFT_CALLBACK 0xA0 //br56_fft.
enum
{
JLA_V2_NORMAL_CH = 0, //仅在此模式下,音源为双声道时输出双声道pcm数据.
JLA_V2_L_OUT,
JLA_V2_R_OUT,
JLA_V2_LR_MEAN
};
enum
{
JLA_V2_2CH_L_OR_R = 0x80
};
struct audio_decode_para
{
u32 mode; /*通过codec_config,cmd=SET_DECODE_MODE设置解码输出模式*/
};
struct audio_set_bitrate
{
u32 br;
};
struct jla_v2_dec_ch_oput_para
{
u32 mode;
short pL; //mode_3_channel_L_coefficient Q13 8192:50%
short pR; //mode_3_channel_R_coefficient Q13
};
struct jla_v2_codec_info
{
u32 sr; ///< sample rate
u32 br; ///< bit rate
u16 fr_idx; ///< 帧长索引. JLA_V2_FRLEN[9 + 4 +1] = {32, 40, 48, 60, 64, 80, 96, 120, 128, 160, 240, 320, 400, 480}; 0~13.
u16 nch;
u16 if_s24; ///< 编码输入数据 或者解码输出数据位宽是否为24比特.
}; ///< enc_para + dec_para
struct jla_v2_codec_io
{
void* priv;
u16(*input_data)(void* priv, u8* buf, u16 len); //bytes
u16(*output_data)(void* priv, u8* buf, u16 len); //bytes /*解码器在struct audio_decode_para参数mode=1时判断是否输出完整*/
};
struct jla_v2_codec_ops {
u32(*open)(void* work_buf, const struct jla_v2_codec_io* audioIO, void* para); ///<打开解码器
u32(*run)(void* work_buf, void* scratch); ///<解码主循环(buffer 32bit对齐)
u32(*need_dcbuf_size)(void* para); ///<获取编解码需要的空间work_buf
u32(*need_stbuf_size)(void* para); ///<获取编解码需要的堆栈scratch
u32(*codec_config)(void* work_buf, u32 cmd, void* parm);
};
extern struct jla_v2_codec_ops* get_jla_v2_enc_ops();
extern struct jla_v2_codec_ops* get_jla_v2_dec_ops();
extern const int JLA_V2_PLC_EN;
extern const int JLA_V2_HW_FFT;
extern const int JLA_V2_ENCODE_I24bit_ENABLE;
extern const int JLA_V2_DECODE_O24bit_ENABLE;
extern const int JLA_V2_PLC_FADE_OUT_START_POINT;
extern const int JLA_V2_PLC_FADE_OUT_POINTS;
extern const int JLA_V2_PLC_FADE_IN_POINTS;
extern const unsigned short JLA_V2_FRAMELEN_MASK;
#endif

BIN
local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/JLAV2Lib

Binary file not shown.

6
local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/Modules/module.modulemap

@ -1,6 +0,0 @@
framework module JLAV2Lib {
umbrella header "JLAV2Lib.h"
export *
module * { export * }
}

41
local_plugins/device_jieli/ios/device_jieli.podspec

@ -1,41 +0,0 @@
Pod::Spec.new do |s|
s.name = 'device_jieli'
s.version = '0.0.1'
s.summary = 'JieLi Home SDK Flutter plugin (iOS).'
s.description = <<-DESC
JieLi (杰理) device plugin — iOS counterpart of the Android implementation under
android/src/main/kotlin/com/jielihome/jielihome.
DESC
s.homepage = 'https://example.com'
s.license = { :type => 'MIT', :file => '../LICENSE' }
s.author = { 'AI Agent' => 'liwei1dao@gmail.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*.{h,m,swift}'
s.dependency 'Flutter'
s.dependency 'device_plugin_interface'
s.platform = :ios, '12.0'
s.swift_version = '5.0'
# `ota` plugin 已废弃,OTA 全部走本插件。Vendor 完整的 JieLi SDK
# framework 集合(来自 JIELI-Release 同一套,版本一致,避免 ABI 漂移)。
# JLAudioUnitKit 不在此列表 —— `ble_service` 的 SPM binary target 已提供,
# 同名 vendor 会触发 Xcode "Multiple commands produce ..." 错误。
s.vendored_frameworks = [
'Frameworks/JL_BLEKit.xcframework',
'Frameworks/JL_OTALib.xcframework',
'Frameworks/JL_AdvParse.xcframework',
'Frameworks/JL_HashPair.xcframework',
'Frameworks/JLLogHelper.xcframework',
'Frameworks/JLAV2Lib.framework'
]
s.frameworks = 'CoreBluetooth', 'AVFoundation', 'UIKit', 'Foundation'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64',
# JieLi's xcframeworks ship Obj-C headers; expose them to Swift via the
# generated module map.
'CLANG_ENABLE_MODULES' => 'YES'
}
end

44
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/Info.plist

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>JLAudioUnitKit.framework/JLAudioUnitKit</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>JLAudioUnitKit.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>x86_64</string>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>JLAudioUnitKit.framework/JLAudioUnitKit</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>JLAudioUnitKit.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

26
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitKit.h

@ -0,0 +1,26 @@
//
// JLAudioUnitKit.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
//
#import <Foundation/Foundation.h>
//! Project version number for JLAudioUnitKit.
FOUNDATION_EXPORT double JLAudioUnitKitVersionNumber;
//! Project version string for JLAudioUnitKit.
FOUNDATION_EXPORT const unsigned char JLAudioUnitKitVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <JLAudioUnitKit/PublicHeader.h>
#import <JLAudioUnitKit/JLSpeexDecoder.h>
#import <JLAudioUnitKit/JLOpusDecoder.h>
#import <JLAudioUnitKit/JLOpusEncoder.h>
#import <JLAudioUnitKit/JLOpusFormat.h>
#import <JLAudioUnitKit/JLOpusEncodeConfig.h>
#import <JLAudioUnitKit/JLPcmToWtg.h>
#import <JLAudioUnitKit/JLPcmToWav.h>
#import <JLAudioUnitKit/JLAudioUnitPlayer.h>
#import <JLAudioUnitKit/JLOpusToOgg.h>

58
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitPlayer.h

@ -0,0 +1,58 @@
//
// JLAudioUnitPlayer.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/4/16.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <AudioToolbox/AudioToolbox.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, JLAudioUnitPlayerType) {
JLAudioUnitPlayerTypeFile, // 支持 MP3/WAV/AAC 等文件格式(AVAudioPlayer)
JLAudioUnitPlayerTypePCM // 支持 PCM 流式播放(Audio Queue)
};
@class JLAudioUnitPlayer;
@protocol JLAudioPlayerDelegate <NSObject>
@optional
// 播放进度更新(秒)
- (void)audioPlayer:(JLAudioUnitPlayer *)player didUpdateProgress:(NSTimeInterval)currentTime duration:(NSTimeInterval)duration;
// 播放完成
- (void)audioPlayerDidFinishPlaying:(JLAudioUnitPlayer *)player;
// 播放错误
- (void)audioPlayer:(JLAudioUnitPlayer *)player didFailWithError:(NSError *)error;
@end
@interface JLAudioUnitPlayer : NSObject
@property (nonatomic, weak) id<JLAudioPlayerDelegate> delegate;
@property (nonatomic, readonly) NSTimeInterval duration; // 总时长(文件模式下有效)
@property (nonatomic, readonly) NSTimeInterval currentTime;
@property (nonatomic, readonly) BOOL isPlaying;
#pragma mark - 初始化方法
/// 文件播放初始化(MP3/WAV/AAC)
- (instancetype)initWithAudioFile:(NSString *)filePath;
/// PCM 流播放初始化(需指定格式)
- (instancetype)initWithPCMFormat:(AudioStreamBasicDescription)format;
#pragma mark - 播放控制
- (void)play;
- (void)pause;
- (void)stop;
- (void)seekToTime:(NSTimeInterval)time; // 仅文件模式有效
#pragma mark - PCM 流式输入(仅 PCM 模式有效)
- (void)appendPCMData:(NSData *)pcmData;
- (void)endPCMStream; // 结束 PCM 流输入
@end
NS_ASSUME_NONNULL_END

66
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusDecoder.h

@ -0,0 +1,66 @@
//
// JLOpusDecoder.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JLAudioUnitKit/JLOpusFormat.h>
NS_ASSUME_NONNULL_BEGIN
@class JLOpusDecoder;
/// Opus 解码代理
@protocol JLOpusDecoderDelegate <NSObject>
/// Opus 数据解码
/// - Parameters:
/// - decoder: 解码器
/// - data: pcm 数据
/// - error: 错误信息
-(void)opusDecoder:(JLOpusDecoder *)decoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error;
@end
typedef void(^JLOpusDecoderConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error);
/// Opus 解码
@interface JLOpusDecoder : NSObject
/// 数据格式参数
@property (nonatomic, strong) JLOpusFormat *opusFormat;
/// 代理委托
@property(nonatomic, weak) id<JLOpusDecoderDelegate> delegate;
- (instancetype)init NS_UNAVAILABLE;
/// 初始化
/// - Parameters:
/// - format: 解码格式
/// - delegate: 代理
- (instancetype)initDecoder:(JLOpusFormat *)format delegate:(id<JLOpusDecoderDelegate>)delegate;
/// 重置解码格式
/// - Parameter format: 解码格式
-(void)resetOpusFramet:(JLOpusFormat *)format;
/// 输入 Opus 数据
/// - Parameter data: Opus 数据
-(void)opusDecoderInputData:(NSData *)data;
/// 解码文件
/// - Parameters:
/// - input: opus 文件
/// - outPut: 输出路径
/// - result: 结果回调
-(void)opusDecodeFile:(NSString *)input outPut:(NSString *_Nullable)outPut Resoult:(JLOpusDecoderConvertBlock _Nullable)result;
/// 释放
-(void)opusOnRelease;
@end
NS_ASSUME_NONNULL_END

83
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusEncodeConfig.h

@ -0,0 +1,83 @@
//
// JLOpusEncodeConfig.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/8/27.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, JLOpusEncoderBandwidth) {
///(4kHz bandpass)
JLOpusEncoderBandwidthNarrowband = 1101, // 对应 OPUS_BANDWIDTH_NARROWBAND
/// (6kHz bandpass)
JLOpusEncoderBandwidthMediumband = 1102, // 对应 OPUS_BANDWIDTH_MEDIUMBAND
/// (8kHz bandpass)
JLOpusEncoderBandwidthWideband = 1103, // 对应 OPUS_BANDWIDTH_WIDEBAND
/// (12kHz bandpass)
JLOpusEncoderBandwidthSuperwideband = 1104,// 对应 OPUS_BANDWIDTH_SUPERWIDEBAND
/// (20kHz bandpass)
JLOpusEncoderBandwidthFullband = 1105 // 对应 OPUS_BANDWIDTH_FULLBAND
};
@interface JLOpusEncodeConfig : NSObject
/// 采样率
@property (nonatomic, assign) int sampleRate;
/// 单/双声道
@property (nonatomic, assign) int channels;
/// 帧长度 20ms 默认值
@property (nonatomic, assign) int frameDuration;
/// 帧长度
@property (nonatomic, assign) int frameSize;
/// BitRate
@property (nonatomic, assign) int bitRate;
/// 可变比特率开关,默认 NO(使用 CBR)
@property (nonatomic, assign) BOOL useVBR;
/// VBR 限制模式,默认 NO
@property (nonatomic, assign) BOOL constrainedVBR;
/// 编码复杂度 0~10,默认 5
@property (nonatomic, assign) int complexity;
/// 强制输出声道数 -1: 自适应, 1: 单声道, 2: 双声道, 默认 -1
@property (nonatomic, assign) int forceChannels;
/// 是否启用 DTX(静音段不发送数据),默认 NO
@property (nonatomic, assign) BOOL useDTX;
/// 网络丢包百分比,默认 0
@property (nonatomic, assign) int packetLossPercent;
/// 最大带宽限制(OPUS_BANDWIDTH_*),默认 FULLBAND
@property (nonatomic, assign) int bandwidth;
/// PCM 输入有效位深,默认 16
@property (nonatomic, assign) int lsbDepth;
/// 专家模式下帧时长(ms),默认与 frameDuration 一致
@property (nonatomic, assign) int expertFrameDuration;
/// 是否启用数据头
/// 默认是启用的
@property (nonatomic, assign) BOOL hasDataHeader;
/// 创建默认编码配置
+ (instancetype)defaultConfig;
/// 获取默认配置
/// 杰理的无头配置
+ (instancetype)defaultJL;
@end
NS_ASSUME_NONNULL_END

69
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusEncoder.h

@ -0,0 +1,69 @@
//
// JLOpusEncoder.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JLAudioUnitKit/JLOpusEncodeConfig.h>
NS_ASSUME_NONNULL_BEGIN
@class JLOpusEncoder;
/// Opus 编码代理
@protocol JLOpusEncoderDelegate <NSObject>
/// PCM 数据编码
/// - Parameters:
/// - encoder: 解码器
/// - data: opus 数据
/// - error: 错误信息
-(void)opusEncoder:(JLOpusEncoder *)encoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error;
@end
typedef void(^JLOpusEncoderConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error);
/// Opus 编码
@interface JLOpusEncoder : NSObject
/// 音频格式
@property (nonatomic, strong) JLOpusEncodeConfig *opusFormat;
/// 代理
@property (nonatomic, weak) id<JLOpusEncoderDelegate> delegate;
-(instancetype)init NS_UNAVAILABLE;
/// 初始化
/// - Parameters:
/// - config: 音频格式
/// - delegate: 代理
-(instancetype)initFormat:(JLOpusEncodeConfig *)format delegate:(id<JLOpusEncoderDelegate>)delegate;
/// PCM 数据
/// - Parameter data: PCM 数据
-(void)opusEncodeData:(NSData *)data;
/// PCM 文件转换成 Opus 文件(推荐新接口,流式读取,避免一次性加载内存)
/// - Parameters:
/// - pcmPath: PCM 文件存放路径
/// - output: 文件输出路径(可空,默认 ~/Documents/pcmToOpus.opus)
/// - result: 结果回调,回传输出路径或错误
-(void)opusEncodeFile:(NSString *)pcmPath output:(NSString *_Nullable)output result:(JLOpusEncoderConvertBlock _Nullable)result;
/// 兼容旧接口(已废弃:命名拼写问题,且旧实现一次性读入文件)
/// - Parameters:
/// - pcmPath: PCM 文件存放路径
/// - outPut: 文件输出路径
/// - result: 结果回调
-(void)opusEncodeFile:(NSString *)pcmPath outPut:(NSString *_Nullable)outPut Resoult:(JLOpusEncoderConvertBlock _Nullable)result __attribute__((deprecated("Use opusEncodeFile:output:result: instead.")));
/// 释放
-(void)opusOnRelease;
@end
NS_ASSUME_NONNULL_END

59
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusFormat.h

@ -0,0 +1,59 @@
//
// JLOpusOptions.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
#define OPUS_JL_MAX_FRAME_SIZE 48000 * 2
#define OPUS_JL_MAX_PACKET_SIZE 1500
@interface JLOpusFormat : NSObject
/// 采样率
@property (nonatomic, assign) int sampleRate;
/// 单/双声道
@property (nonatomic, assign) int channels;
/// 帧长度 20ms 默认值
@property (nonatomic, assign) int frameDuration;
/// BitRate
@property (nonatomic, assign) int bitRate;
/// 数据帧大小
@property (nonatomic, assign, readonly) int frameSize;
/// 数据大小
@property (nonatomic, assign) int dataSize;
/// 是否包含数据头部
@property (nonatomic, assign) BOOL hasDataHeader;
/// 是否开启slik语音优化
@property (nonatomic, assign) BOOL usVoipSlik;
-(instancetype)init NS_UNAVAILABLE;
/// 默认配置
/// sampleRate: 16000
/// channels: 1
/// frameDuration: 20
/// dataSize: 40
/// frameSize: 320
/// hasDataHeader: YES
/// bitRate: 16000
/// usVoipSlik: false
+(JLOpusFormat*)defaultFormats;
@end
NS_ASSUME_NONNULL_END

67
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusToOgg.h

@ -0,0 +1,67 @@
//
// JLOpusToOgg.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/5/16.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef void(^JLOpusToOggConvertBlock)(NSData *_Nullable oggData, BOOL isLast, NSError *_Nullable error);
/// 将Opus数据转换为Ogg数据
@interface JLOpusToOgg : NSObject
/// 转码内容的回调
@property(nonatomic,strong)JLOpusToOggConvertBlock _Nullable convertBlock;
/// 帧长
/// - Parameter frameLen: 帧长
/// 默认值 40
- (instancetype)initWithFrameLength:(uint32_t)frameLen;
/// 开始流
-(void)startStream;
/// 添加Opus数据
/// - Parameter opusData: 符合杰理的无头裸 opus 数据
- (void)appendOpusData:(NSData *)opusData;
/// 关闭流
- (void)closeStream;
/// 将Opus数据转换为Ogg数据
/// opus 必须为 16000
/// opus 必须是 单声道
/// - Parameters:
/// - opusData: 符合杰理的无头裸 opus 数据
/// - frameLen: 帧长 公版默认帧长是 40
/// - duration: 转换后的时长
/// - error: 错误
+ (NSData *_Nullable)convertOpusDataToOgg:(NSData *)opusData
frameLen:(uint32_t)frameLen
duration:(double *)duration
error:(NSError *__autoreleasing _Nullable *)error ;
/// 将Opus文件转换为Ogg文件
/// 符合杰理的无头裸 opus 文件
/// opus 必须为 16000
/// opus 必须是 单声道
/// - Parameters:
/// - opusFilePath: opus文件路径
/// - oggFilePath: ogg文件路径
/// - frameLen: 帧长 公版默认帧长是 40
/// - duration: 转换后的时长
+(void)convertOpusFileToOgg:(NSString *)opusFilePath
oggFilePath:(NSString *)oggFilePath
frameLen:(uint32_t)frameLen
duration:(double *)duration;
@end
NS_ASSUME_NONNULL_END

55
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWav.h

@ -0,0 +1,55 @@
//
// JLPcmToWav.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/4/15.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// PCM 转 WAV
@interface JLPcmToWav : NSObject
#pragma mark - 流式编码接口
/// 初始化流式编码器
/// @param outputPath 输出文件路径
/// @param sampleRate 采样率(如 16000)
/// @param numChannels 声道数(1 或 2)
/// @param bitsPerSample 位深(16)
- (instancetype)initWithOutputPath:(NSString *)outputPath
sampleRate:(uint32_t)sampleRate
numChannels:(uint16_t)numChannels
bitsPerSample:(uint16_t)bitsPerSample;
/// 追加 PCM 数据(需确保数据格式与初始化参数一致)
/// @param pcmData PCM 数据块
/// @param error 错误信息
- (BOOL)appendPCMData:(NSData *)pcmData error:(NSError **)error;
/// 完成编码并关闭文件(必须调用以更新 WAV 头)
/// @param error 错误信息
- (BOOL)finishWithError:(NSError **)error;
#pragma mark - 一次性编码接口(便捷方法)
/// 将 PCM 数据一次性转为 WAV 文件
/// @param pcmData PCM 数据块
/// @param outputPath 输出文件路径
/// @param sampleRate 采样率(如 16000)
/// @param numChannels 声道数(1 或 2)
/// @param bitsPerSample 位深(16)
/// @param error 错误信息
+ (BOOL)convertPCMData:(NSData *)pcmData
toWAVFile:(NSString *)outputPath
sampleRate:(uint32_t)sampleRate
numChannels:(uint16_t)numChannels
bitsPerSample:(uint16_t)bitsPerSample
error:(NSError **)error;
@end
NS_ASSUME_NONNULL_END

51
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWtg.h

@ -0,0 +1,51 @@
//
// JLPcmToWtg.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/1/21.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface JLPcm2WtgModel : NSObject <NSCopying>
/// 音频文件
@property (nonatomic, copy) NSString *pcmPath;
/// 输出文件
@property (nonatomic, copy) NSString *wtgPath;
@end
/// JLPcmToWtgProtocol
@protocol JLPcmToWtgDelegate <NSObject>
/// 音频转码完成
/// - Parameter model: 音频转码模型
- (void)convertPcmToWtgDone:(JLPcm2WtgModel *)model;
@end
/// 音频转码
/// PCM 转 WTG
@interface JLPcmToWtg : NSObject
- (instancetype)init NS_UNAVAILABLE;
/// 音频转码初始化
/// - Parameter delegate: 音频转码代理
- (instancetype)initWithDelegate:(id<JLPcmToWtgDelegate>)delegate;
/// 音频转码
/// 当前接口模型只限制 PCM 文件格式为:8k 16bit
/// 输入的pcm文件(16bit,小端,8k采样率),wtg_filename:输出的wtg文件
/// - Parameter model: 音频转码模型
-(void)convertPcmToWtg:(JLPcm2WtgModel *)model;
@end
NS_ASSUME_NONNULL_END

54
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLSpeexDecoder.h

@ -0,0 +1,54 @@
//
// JLSpeexUnit.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class JLSpeexDecoder;
@protocol JLSpeexDelegate <NSObject>
/// Speex 数据解码
/// - Parameters:
/// - decoder: 解码器
/// - data: PCM 数据
/// - error: 错误信息
- (void)speexDecoder:(JLSpeexDecoder *)decoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error;
@end
typedef void(^JLSpeexConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error);
/// Speex 数据解码
@interface JLSpeexDecoder : NSObject
-(instancetype)init NS_UNAVAILABLE;
/// 初始化
/// - Parameter delegate: 委托
-(instancetype)initWithDelegate:(id<JLSpeexDelegate>)delegate;
/// 委托
@property (nonatomic, weak) id<JLSpeexDelegate> delegate;
/// 输入 Speex 数据
/// - Parameter data: Speex 数据
-(void)speexInputData:(NSData*)data;
/// 将 Speex 转换为 PCM
/// - Parameters:
/// - filePath: Speex 路径
/// - opPath: 输出路径
/// - result: 结果回调
-(void)speexConvertToPcm:(NSString *)filePath outPutFilePath:(NSString * _Nullable)opPath Result:(JLSpeexConvertBlock)result;
/// 释放对象
-(void)speexOnRelease;
@end
NS_ASSUME_NONNULL_END

BIN
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Info.plist

Binary file not shown.

BIN
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/JLAudioUnitKit

Binary file not shown.

6
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Modules/module.modulemap

@ -0,0 +1,6 @@
framework module JLAudioUnitKit {
umbrella header "JLAudioUnitKit.h"
export *
module * { export * }
}

52
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/readme.txt

@ -0,0 +1,52 @@
DESCRIPTION
-----------
This package includes the files needed to build the fixed point implementation
of the ITU-T G.729 Appendix IV.
DIFFERENCES WITH G.729B
-----------------------
The ANSI-C source code simulating the bit-exact, fixed point simulation
software of enhanced VAD is integrated into the G.729B source code.
The modification to the G.729B source code updated files of coder.c,
cod_ld8k.c, bits.c, dec_sid.c, dtx.c, qsidgain.c, qsidlsf.c, tab_dtx.c,
tab_dtx.h, ld8k.h, deleted vad.c/h and integrated the enhanced VAD functions
into the G.729B project. The modification also replaced the original basic
operators used in the original G.729B source code by the basic operators of
ITU-T Software Tool Library STL2005.
COMPILATION
-----------
For UNIX systems the following makefiles are provided
coder.mak
decoder.mak
Edit the makefiles coder.mak and decoder.mak to set the proper options
for your system.
The command to compile and link all code on a UNIX system is
make -f coder.mak
make -f decoder.mak
For other platforms, the *.mak files can be used to work out the
compilation procedures.
USAGE
-----
The command line instruction for the encoder is as follows:
coder inputfile bitstreamfile dtx_option mode x
where:
inputfile : 8 kHz sampled data file containing 16 bit linear PCM signal
bitstreamfile: binary file containing bitstream
dtx_option : = 1 : DTX enabled 0 : DTX disabled
mode X : Run encoder with enhanced VAD in operating point X,
X=0 or 1 or 2 representing balanced or quality-preferred
or bandwidth saving operating point, respectively
The command line for the decoder is as follows:
decoder bitstreamfile outputfile
where:
bitstreamfile: binary file containing bitstream
outputfile : 8 kHz sampled data file containing 16 bit linear PCM signal

26
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLAudioUnitKit.h

@ -0,0 +1,26 @@
//
// JLAudioUnitKit.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
//
#import <Foundation/Foundation.h>
//! Project version number for JLAudioUnitKit.
FOUNDATION_EXPORT double JLAudioUnitKitVersionNumber;
//! Project version string for JLAudioUnitKit.
FOUNDATION_EXPORT const unsigned char JLAudioUnitKitVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <JLAudioUnitKit/PublicHeader.h>
#import <JLAudioUnitKit/JLSpeexDecoder.h>
#import <JLAudioUnitKit/JLOpusDecoder.h>
#import <JLAudioUnitKit/JLOpusEncoder.h>
#import <JLAudioUnitKit/JLOpusFormat.h>
#import <JLAudioUnitKit/JLOpusEncodeConfig.h>
#import <JLAudioUnitKit/JLPcmToWtg.h>
#import <JLAudioUnitKit/JLPcmToWav.h>
#import <JLAudioUnitKit/JLAudioUnitPlayer.h>
#import <JLAudioUnitKit/JLOpusToOgg.h>

58
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLAudioUnitPlayer.h

@ -0,0 +1,58 @@
//
// JLAudioUnitPlayer.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/4/16.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <AudioToolbox/AudioToolbox.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, JLAudioUnitPlayerType) {
JLAudioUnitPlayerTypeFile, // 支持 MP3/WAV/AAC 等文件格式(AVAudioPlayer)
JLAudioUnitPlayerTypePCM // 支持 PCM 流式播放(Audio Queue)
};
@class JLAudioUnitPlayer;
@protocol JLAudioPlayerDelegate <NSObject>
@optional
// 播放进度更新(秒)
- (void)audioPlayer:(JLAudioUnitPlayer *)player didUpdateProgress:(NSTimeInterval)currentTime duration:(NSTimeInterval)duration;
// 播放完成
- (void)audioPlayerDidFinishPlaying:(JLAudioUnitPlayer *)player;
// 播放错误
- (void)audioPlayer:(JLAudioUnitPlayer *)player didFailWithError:(NSError *)error;
@end
@interface JLAudioUnitPlayer : NSObject
@property (nonatomic, weak) id<JLAudioPlayerDelegate> delegate;
@property (nonatomic, readonly) NSTimeInterval duration; // 总时长(文件模式下有效)
@property (nonatomic, readonly) NSTimeInterval currentTime;
@property (nonatomic, readonly) BOOL isPlaying;
#pragma mark - 初始化方法
/// 文件播放初始化(MP3/WAV/AAC)
- (instancetype)initWithAudioFile:(NSString *)filePath;
/// PCM 流播放初始化(需指定格式)
- (instancetype)initWithPCMFormat:(AudioStreamBasicDescription)format;
#pragma mark - 播放控制
- (void)play;
- (void)pause;
- (void)stop;
- (void)seekToTime:(NSTimeInterval)time; // 仅文件模式有效
#pragma mark - PCM 流式输入(仅 PCM 模式有效)
- (void)appendPCMData:(NSData *)pcmData;
- (void)endPCMStream; // 结束 PCM 流输入
@end
NS_ASSUME_NONNULL_END

66
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusDecoder.h

@ -0,0 +1,66 @@
//
// JLOpusDecoder.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JLAudioUnitKit/JLOpusFormat.h>
NS_ASSUME_NONNULL_BEGIN
@class JLOpusDecoder;
/// Opus 解码代理
@protocol JLOpusDecoderDelegate <NSObject>
/// Opus 数据解码
/// - Parameters:
/// - decoder: 解码器
/// - data: pcm 数据
/// - error: 错误信息
-(void)opusDecoder:(JLOpusDecoder *)decoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error;
@end
typedef void(^JLOpusDecoderConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error);
/// Opus 解码
@interface JLOpusDecoder : NSObject
/// 数据格式参数
@property (nonatomic, strong) JLOpusFormat *opusFormat;
/// 代理委托
@property(nonatomic, weak) id<JLOpusDecoderDelegate> delegate;
- (instancetype)init NS_UNAVAILABLE;
/// 初始化
/// - Parameters:
/// - format: 解码格式
/// - delegate: 代理
- (instancetype)initDecoder:(JLOpusFormat *)format delegate:(id<JLOpusDecoderDelegate>)delegate;
/// 重置解码格式
/// - Parameter format: 解码格式
-(void)resetOpusFramet:(JLOpusFormat *)format;
/// 输入 Opus 数据
/// - Parameter data: Opus 数据
-(void)opusDecoderInputData:(NSData *)data;
/// 解码文件
/// - Parameters:
/// - input: opus 文件
/// - outPut: 输出路径
/// - result: 结果回调
-(void)opusDecodeFile:(NSString *)input outPut:(NSString *_Nullable)outPut Resoult:(JLOpusDecoderConvertBlock _Nullable)result;
/// 释放
-(void)opusOnRelease;
@end
NS_ASSUME_NONNULL_END

83
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusEncodeConfig.h

@ -0,0 +1,83 @@
//
// JLOpusEncodeConfig.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/8/27.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, JLOpusEncoderBandwidth) {
///(4kHz bandpass)
JLOpusEncoderBandwidthNarrowband = 1101, // 对应 OPUS_BANDWIDTH_NARROWBAND
/// (6kHz bandpass)
JLOpusEncoderBandwidthMediumband = 1102, // 对应 OPUS_BANDWIDTH_MEDIUMBAND
/// (8kHz bandpass)
JLOpusEncoderBandwidthWideband = 1103, // 对应 OPUS_BANDWIDTH_WIDEBAND
/// (12kHz bandpass)
JLOpusEncoderBandwidthSuperwideband = 1104,// 对应 OPUS_BANDWIDTH_SUPERWIDEBAND
/// (20kHz bandpass)
JLOpusEncoderBandwidthFullband = 1105 // 对应 OPUS_BANDWIDTH_FULLBAND
};
@interface JLOpusEncodeConfig : NSObject
/// 采样率
@property (nonatomic, assign) int sampleRate;
/// 单/双声道
@property (nonatomic, assign) int channels;
/// 帧长度 20ms 默认值
@property (nonatomic, assign) int frameDuration;
/// 帧长度
@property (nonatomic, assign) int frameSize;
/// BitRate
@property (nonatomic, assign) int bitRate;
/// 可变比特率开关,默认 NO(使用 CBR)
@property (nonatomic, assign) BOOL useVBR;
/// VBR 限制模式,默认 NO
@property (nonatomic, assign) BOOL constrainedVBR;
/// 编码复杂度 0~10,默认 5
@property (nonatomic, assign) int complexity;
/// 强制输出声道数 -1: 自适应, 1: 单声道, 2: 双声道, 默认 -1
@property (nonatomic, assign) int forceChannels;
/// 是否启用 DTX(静音段不发送数据),默认 NO
@property (nonatomic, assign) BOOL useDTX;
/// 网络丢包百分比,默认 0
@property (nonatomic, assign) int packetLossPercent;
/// 最大带宽限制(OPUS_BANDWIDTH_*),默认 FULLBAND
@property (nonatomic, assign) int bandwidth;
/// PCM 输入有效位深,默认 16
@property (nonatomic, assign) int lsbDepth;
/// 专家模式下帧时长(ms),默认与 frameDuration 一致
@property (nonatomic, assign) int expertFrameDuration;
/// 是否启用数据头
/// 默认是启用的
@property (nonatomic, assign) BOOL hasDataHeader;
/// 创建默认编码配置
+ (instancetype)defaultConfig;
/// 获取默认配置
/// 杰理的无头配置
+ (instancetype)defaultJL;
@end
NS_ASSUME_NONNULL_END

69
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusEncoder.h

@ -0,0 +1,69 @@
//
// JLOpusEncoder.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JLAudioUnitKit/JLOpusEncodeConfig.h>
NS_ASSUME_NONNULL_BEGIN
@class JLOpusEncoder;
/// Opus 编码代理
@protocol JLOpusEncoderDelegate <NSObject>
/// PCM 数据编码
/// - Parameters:
/// - encoder: 解码器
/// - data: opus 数据
/// - error: 错误信息
-(void)opusEncoder:(JLOpusEncoder *)encoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error;
@end
typedef void(^JLOpusEncoderConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error);
/// Opus 编码
@interface JLOpusEncoder : NSObject
/// 音频格式
@property (nonatomic, strong) JLOpusEncodeConfig *opusFormat;
/// 代理
@property (nonatomic, weak) id<JLOpusEncoderDelegate> delegate;
-(instancetype)init NS_UNAVAILABLE;
/// 初始化
/// - Parameters:
/// - config: 音频格式
/// - delegate: 代理
-(instancetype)initFormat:(JLOpusEncodeConfig *)format delegate:(id<JLOpusEncoderDelegate>)delegate;
/// PCM 数据
/// - Parameter data: PCM 数据
-(void)opusEncodeData:(NSData *)data;
/// PCM 文件转换成 Opus 文件(推荐新接口,流式读取,避免一次性加载内存)
/// - Parameters:
/// - pcmPath: PCM 文件存放路径
/// - output: 文件输出路径(可空,默认 ~/Documents/pcmToOpus.opus)
/// - result: 结果回调,回传输出路径或错误
-(void)opusEncodeFile:(NSString *)pcmPath output:(NSString *_Nullable)output result:(JLOpusEncoderConvertBlock _Nullable)result;
/// 兼容旧接口(已废弃:命名拼写问题,且旧实现一次性读入文件)
/// - Parameters:
/// - pcmPath: PCM 文件存放路径
/// - outPut: 文件输出路径
/// - result: 结果回调
-(void)opusEncodeFile:(NSString *)pcmPath outPut:(NSString *_Nullable)outPut Resoult:(JLOpusEncoderConvertBlock _Nullable)result __attribute__((deprecated("Use opusEncodeFile:output:result: instead.")));
/// 释放
-(void)opusOnRelease;
@end
NS_ASSUME_NONNULL_END

59
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusFormat.h

@ -0,0 +1,59 @@
//
// JLOpusOptions.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
#define OPUS_JL_MAX_FRAME_SIZE 48000 * 2
#define OPUS_JL_MAX_PACKET_SIZE 1500
@interface JLOpusFormat : NSObject
/// 采样率
@property (nonatomic, assign) int sampleRate;
/// 单/双声道
@property (nonatomic, assign) int channels;
/// 帧长度 20ms 默认值
@property (nonatomic, assign) int frameDuration;
/// BitRate
@property (nonatomic, assign) int bitRate;
/// 数据帧大小
@property (nonatomic, assign, readonly) int frameSize;
/// 数据大小
@property (nonatomic, assign) int dataSize;
/// 是否包含数据头部
@property (nonatomic, assign) BOOL hasDataHeader;
/// 是否开启slik语音优化
@property (nonatomic, assign) BOOL usVoipSlik;
-(instancetype)init NS_UNAVAILABLE;
/// 默认配置
/// sampleRate: 16000
/// channels: 1
/// frameDuration: 20
/// dataSize: 40
/// frameSize: 320
/// hasDataHeader: YES
/// bitRate: 16000
/// usVoipSlik: false
+(JLOpusFormat*)defaultFormats;
@end
NS_ASSUME_NONNULL_END

67
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLOpusToOgg.h

@ -0,0 +1,67 @@
//
// JLOpusToOgg.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/5/16.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef void(^JLOpusToOggConvertBlock)(NSData *_Nullable oggData, BOOL isLast, NSError *_Nullable error);
/// 将Opus数据转换为Ogg数据
@interface JLOpusToOgg : NSObject
/// 转码内容的回调
@property(nonatomic,strong)JLOpusToOggConvertBlock _Nullable convertBlock;
/// 帧长
/// - Parameter frameLen: 帧长
/// 默认值 40
- (instancetype)initWithFrameLength:(uint32_t)frameLen;
/// 开始流
-(void)startStream;
/// 添加Opus数据
/// - Parameter opusData: 符合杰理的无头裸 opus 数据
- (void)appendOpusData:(NSData *)opusData;
/// 关闭流
- (void)closeStream;
/// 将Opus数据转换为Ogg数据
/// opus 必须为 16000
/// opus 必须是 单声道
/// - Parameters:
/// - opusData: 符合杰理的无头裸 opus 数据
/// - frameLen: 帧长 公版默认帧长是 40
/// - duration: 转换后的时长
/// - error: 错误
+ (NSData *_Nullable)convertOpusDataToOgg:(NSData *)opusData
frameLen:(uint32_t)frameLen
duration:(double *)duration
error:(NSError *__autoreleasing _Nullable *)error ;
/// 将Opus文件转换为Ogg文件
/// 符合杰理的无头裸 opus 文件
/// opus 必须为 16000
/// opus 必须是 单声道
/// - Parameters:
/// - opusFilePath: opus文件路径
/// - oggFilePath: ogg文件路径
/// - frameLen: 帧长 公版默认帧长是 40
/// - duration: 转换后的时长
+(void)convertOpusFileToOgg:(NSString *)opusFilePath
oggFilePath:(NSString *)oggFilePath
frameLen:(uint32_t)frameLen
duration:(double *)duration;
@end
NS_ASSUME_NONNULL_END

55
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLPcmToWav.h

@ -0,0 +1,55 @@
//
// JLPcmToWav.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/4/15.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// PCM 转 WAV
@interface JLPcmToWav : NSObject
#pragma mark - 流式编码接口
/// 初始化流式编码器
/// @param outputPath 输出文件路径
/// @param sampleRate 采样率(如 16000)
/// @param numChannels 声道数(1 或 2)
/// @param bitsPerSample 位深(16)
- (instancetype)initWithOutputPath:(NSString *)outputPath
sampleRate:(uint32_t)sampleRate
numChannels:(uint16_t)numChannels
bitsPerSample:(uint16_t)bitsPerSample;
/// 追加 PCM 数据(需确保数据格式与初始化参数一致)
/// @param pcmData PCM 数据块
/// @param error 错误信息
- (BOOL)appendPCMData:(NSData *)pcmData error:(NSError **)error;
/// 完成编码并关闭文件(必须调用以更新 WAV 头)
/// @param error 错误信息
- (BOOL)finishWithError:(NSError **)error;
#pragma mark - 一次性编码接口(便捷方法)
/// 将 PCM 数据一次性转为 WAV 文件
/// @param pcmData PCM 数据块
/// @param outputPath 输出文件路径
/// @param sampleRate 采样率(如 16000)
/// @param numChannels 声道数(1 或 2)
/// @param bitsPerSample 位深(16)
/// @param error 错误信息
+ (BOOL)convertPCMData:(NSData *)pcmData
toWAVFile:(NSString *)outputPath
sampleRate:(uint32_t)sampleRate
numChannels:(uint16_t)numChannels
bitsPerSample:(uint16_t)bitsPerSample
error:(NSError **)error;
@end
NS_ASSUME_NONNULL_END

51
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLPcmToWtg.h

@ -0,0 +1,51 @@
//
// JLPcmToWtg.h
// JLAudioUnitKit
//
// Created by EzioChan on 2025/1/21.
// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface JLPcm2WtgModel : NSObject <NSCopying>
/// 音频文件
@property (nonatomic, copy) NSString *pcmPath;
/// 输出文件
@property (nonatomic, copy) NSString *wtgPath;
@end
/// JLPcmToWtgProtocol
@protocol JLPcmToWtgDelegate <NSObject>
/// 音频转码完成
/// - Parameter model: 音频转码模型
- (void)convertPcmToWtgDone:(JLPcm2WtgModel *)model;
@end
/// 音频转码
/// PCM 转 WTG
@interface JLPcmToWtg : NSObject
- (instancetype)init NS_UNAVAILABLE;
/// 音频转码初始化
/// - Parameter delegate: 音频转码代理
- (instancetype)initWithDelegate:(id<JLPcmToWtgDelegate>)delegate;
/// 音频转码
/// 当前接口模型只限制 PCM 文件格式为:8k 16bit
/// 输入的pcm文件(16bit,小端,8k采样率),wtg_filename:输出的wtg文件
/// - Parameter model: 音频转码模型
-(void)convertPcmToWtg:(JLPcm2WtgModel *)model;
@end
NS_ASSUME_NONNULL_END

54
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Headers/JLSpeexDecoder.h

@ -0,0 +1,54 @@
//
// JLSpeexUnit.h
// JLAudioUnitKit
//
// Created by EzioChan on 2024/11/14.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class JLSpeexDecoder;
@protocol JLSpeexDelegate <NSObject>
/// Speex 数据解码
/// - Parameters:
/// - decoder: 解码器
/// - data: PCM 数据
/// - error: 错误信息
- (void)speexDecoder:(JLSpeexDecoder *)decoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error;
@end
typedef void(^JLSpeexConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error);
/// Speex 数据解码
@interface JLSpeexDecoder : NSObject
-(instancetype)init NS_UNAVAILABLE;
/// 初始化
/// - Parameter delegate: 委托
-(instancetype)initWithDelegate:(id<JLSpeexDelegate>)delegate;
/// 委托
@property (nonatomic, weak) id<JLSpeexDelegate> delegate;
/// 输入 Speex 数据
/// - Parameter data: Speex 数据
-(void)speexInputData:(NSData*)data;
/// 将 Speex 转换为 PCM
/// - Parameters:
/// - filePath: Speex 路径
/// - opPath: 输出路径
/// - result: 结果回调
-(void)speexConvertToPcm:(NSString *)filePath outPutFilePath:(NSString * _Nullable)opPath Result:(JLSpeexConvertBlock)result;
/// 释放对象
-(void)speexOnRelease;
@end
NS_ASSUME_NONNULL_END

BIN
local_plugins/device_jieli/ios/Frameworks/JLAV2Lib.framework/Info.plist → local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Info.plist

Binary file not shown.

BIN
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/JLAudioUnitKit

Binary file not shown.

6
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/Modules/module.modulemap

@ -0,0 +1,6 @@
framework module JLAudioUnitKit {
umbrella header "JLAudioUnitKit.h"
export *
module * { export * }
}

234
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/_CodeSignature/CodeResources

@ -0,0 +1,234 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Headers/JLAudioUnitKit.h</key>
<data>
hE2nr+4q2/NdSgBaayJc7ZoOXb0=
</data>
<key>Headers/JLAudioUnitPlayer.h</key>
<data>
QPMLV/XA8hjk39d+vT+S57t0fDU=
</data>
<key>Headers/JLOpusDecoder.h</key>
<data>
K1bYcaJ67prImNlGuldy8csTRHc=
</data>
<key>Headers/JLOpusEncodeConfig.h</key>
<data>
NArAew5QxnSJGsD0tRzQ2V7bHrY=
</data>
<key>Headers/JLOpusEncoder.h</key>
<data>
7DYUlvXrp1QC9l2XZ5tNHtsmsBQ=
</data>
<key>Headers/JLOpusFormat.h</key>
<data>
Du1x3VNk87NstmspRk7ElijuZrk=
</data>
<key>Headers/JLOpusToOgg.h</key>
<data>
fSJT6LcyylJHTHX1wsN3JHG2FxU=
</data>
<key>Headers/JLPcmToWav.h</key>
<data>
/BbZmqmWpWVBusXqjnzbjaBNCNg=
</data>
<key>Headers/JLPcmToWtg.h</key>
<data>
zoOdOFpIulND0F1OrfIXR1H0kh0=
</data>
<key>Headers/JLSpeexDecoder.h</key>
<data>
5ecBKtD3BDGccLiKpTs0fS0CSgs=
</data>
<key>Info.plist</key>
<data>
pXqOzYCqPnMLY0nonWZTcbfnlhU=
</data>
<key>Modules/module.modulemap</key>
<data>
2vYf0m+hGTOTapX6sliv/F1E1VI=
</data>
<key>readme.txt</key>
<data>
wijZX0Dr2OlODWS6XYbpeKabb7g=
</data>
</dict>
<key>files2</key>
<dict>
<key>Headers/JLAudioUnitKit.h</key>
<dict>
<key>hash2</key>
<data>
J5pq6Ed3fR+cQ7CGPXDoPl7BZzix7Cjf5yJZFRRvxqs=
</data>
</dict>
<key>Headers/JLAudioUnitPlayer.h</key>
<dict>
<key>hash2</key>
<data>
qBz0OA82HUH4kBtO8xiUw7Q4ohtiIvCSYfCqax5xjJY=
</data>
</dict>
<key>Headers/JLOpusDecoder.h</key>
<dict>
<key>hash2</key>
<data>
cnAj6Nt6vgRLoEH6QY3Q+gYpjcxGPN1OLLZbCgdLymI=
</data>
</dict>
<key>Headers/JLOpusEncodeConfig.h</key>
<dict>
<key>hash2</key>
<data>
qrPpsrXaNCvyxf0/gUZtvveL+ywsfgBUrMPFibjneOo=
</data>
</dict>
<key>Headers/JLOpusEncoder.h</key>
<dict>
<key>hash2</key>
<data>
YoHsvoGH2VEjvEIzEa7t+cAnzf8kHEU3dJTd03ABr1w=
</data>
</dict>
<key>Headers/JLOpusFormat.h</key>
<dict>
<key>hash2</key>
<data>
Nm6vM9/EbFzyIWbsysu6EOO2S+vZVeplcop4NvoJaMA=
</data>
</dict>
<key>Headers/JLOpusToOgg.h</key>
<dict>
<key>hash2</key>
<data>
dNSvJJnED/6E1p7TYZ4zBzUnLDVsZ8NovuNYjge6T/w=
</data>
</dict>
<key>Headers/JLPcmToWav.h</key>
<dict>
<key>hash2</key>
<data>
l/SEQhSFh7ctq/pBV+Z7X+Z0/gz30eCw3s31otjMPho=
</data>
</dict>
<key>Headers/JLPcmToWtg.h</key>
<dict>
<key>hash2</key>
<data>
MwtbX3Sa0nytA+m11x+1rDvP4WvJnZLljshuT5B3WD0=
</data>
</dict>
<key>Headers/JLSpeexDecoder.h</key>
<dict>
<key>hash2</key>
<data>
pV0X3xn07Ps7wE+0EfL9Yu/08tp8Ug8s14iDigRU46o=
</data>
</dict>
<key>Modules/module.modulemap</key>
<dict>
<key>hash2</key>
<data>
akFCUM59aSAaRM/ADWQ138YT9GD3oAjcaTvTUo24Ml8=
</data>
</dict>
<key>readme.txt</key>
<dict>
<key>hash2</key>
<data>
+2GG9ORrf2yJnBe2joKWtyBZL9p2sCH8EXqK1GIgW0E=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^.*</key>
<true/>
<key>^.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^.*</key>
<true/>
<key>^.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>

52
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLAudioUnitKit.xcframework/ios-arm64_x86_64-simulator/JLAudioUnitKit.framework/readme.txt

@ -0,0 +1,52 @@
DESCRIPTION
-----------
This package includes the files needed to build the fixed point implementation
of the ITU-T G.729 Appendix IV.
DIFFERENCES WITH G.729B
-----------------------
The ANSI-C source code simulating the bit-exact, fixed point simulation
software of enhanced VAD is integrated into the G.729B source code.
The modification to the G.729B source code updated files of coder.c,
cod_ld8k.c, bits.c, dec_sid.c, dtx.c, qsidgain.c, qsidlsf.c, tab_dtx.c,
tab_dtx.h, ld8k.h, deleted vad.c/h and integrated the enhanced VAD functions
into the G.729B project. The modification also replaced the original basic
operators used in the original G.729B source code by the basic operators of
ITU-T Software Tool Library STL2005.
COMPILATION
-----------
For UNIX systems the following makefiles are provided
coder.mak
decoder.mak
Edit the makefiles coder.mak and decoder.mak to set the proper options
for your system.
The command to compile and link all code on a UNIX system is
make -f coder.mak
make -f decoder.mak
For other platforms, the *.mak files can be used to work out the
compilation procedures.
USAGE
-----
The command line instruction for the encoder is as follows:
coder inputfile bitstreamfile dtx_option mode x
where:
inputfile : 8 kHz sampled data file containing 16 bit linear PCM signal
bitstreamfile: binary file containing bitstream
dtx_option : = 1 : DTX enabled 0 : DTX disabled
mode X : Run encoder with enhanced VAD in operating point X,
X=0 or 1 or 2 representing balanced or quality-preferred
or bandwidth saving operating point, respectively
The command line for the decoder is as follows:
decoder bitstreamfile outputfile
where:
bitstreamfile: binary file containing bitstream
outputfile : 8 kHz sampled data file containing 16 bit linear PCM signal

44
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/Info.plist

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>JLBmpConvertKit.framework/JLBmpConvertKit</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>JLBmpConvertKit.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>JLBmpConvertKit.framework/JLBmpConvertKit</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>JLBmpConvertKit.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

95
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLBmpConvert.h

@ -0,0 +1,95 @@
//
// JLBmpCovert.h
// JLBmpCovertKit
//
// Created by EzioChan on 2024/9/9.
// Copyright © 2024 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JLBmpConvertKit/JLBmpConvertKit.h>
#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
#import <UIKit/UIKit.h>
#elif TARGET_OS_OSX
#import <AppKit/AppKit.h>
#endif
NS_ASSUME_NONNULL_BEGIN
@class JLImageConvertResult;
typedef void(^JLBmpCovertCallBack)(NSString *inFilePath,NSString *__nullable outFilePath,NSError *__nullable error) __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
typedef void(^JLBmpCovertCallBack2)(NSData *__nullable outFileData,NSError *__nullable error) __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
/// 图像转换
@interface JLBmpConvert : NSObject
#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
/// 图片转换
/// - Parameters:
/// - type: 转换类型
/// - inFilePath: 输入文件
/// - outFilePath: 输出文件
/// - completion: 回调
+(void)covert:(JLBmpConvertType)type
inFilePath:(NSString *_Nonnull)inFilePath
outFilePath:(NSString * __nullable)outFilePath
completion:(JLBmpCovertCallBack _Nonnull)completion __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
/// 图片转换
/// - Parameters:
/// - type: 转换类型
/// - inImage: 输入图片
/// - completion: 回调
+(void)covert:(JLBmpConvertType)type
Image:(UIImage *)inImage
completion:(JLBmpCovertCallBack2 _Nonnull)completion __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
/// 图片转换
/// - Parameters:
/// - option: 类型
/// 当前仅仅 707N 系列芯片可指定图像格式
/// - inFilePath: 输入文件
/// - outFilePath: 输出文件
/// 文件路径转换入口
+ (JLImageConvertResult *)convert:(JLBmpConvertOption *)option
inFilePath:(NSString *)inFilePath
outFilePath:(NSString * __nullable)outFilePath;
/// UIImage 转换
/// 当前仅仅 707N 系列芯片可指定图像格式
/// - Parameters:
/// - option: 类型
/// - imgData: 图片的原始数据
+(JLImageConvertResult *)convert:(JLBmpConvertOption *)option ImageData:(NSData *)imgData;
/// UIImage 转换成 BGRA
/// - Parameter image: 图片
+(NSData*)convertImageToBGRABytes:(UIImage *)image;
/// UIImage 转换成 RGBA
/// - Parameter image: 图片
+(NSData*)convertImageToRGBABytes:(UIImage *)image;
/// 图片缩放
/// - Parameters:
/// - image: 图片
/// - newSize: 缩放大小
/// - Returns: 缩放后的图片
+(NSData *)resizeImage:(UIImage*)image andResizeTo:(CGSize)newSize;
#elif TARGET_OS_OSX
/// 图片转换
/// - Parameters:
/// - option: 转换类型
/// - inImage: 输入图片
+(JLImageConvertResult *)convert:(JLBmpConvertOption *)option Image:(NSImage *)image;
#endif
@end
NS_ASSUME_NONNULL_END

24
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLBmpConvertKit.h

@ -0,0 +1,24 @@
//
// JLBmpConvertKit.h
// JLBmpConvertKit
//
// Created by EzioChan on 2024/9/9.
// Copyright © 2024 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
//! Project version number for JLBmpConvertKit.
FOUNDATION_EXPORT double JLBmpConvertKitVersionNumber;
//! Project version string for JLBmpConvertKit.
FOUNDATION_EXPORT const unsigned char JLBmpConvertKitVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <JLBmpConvertKit/PublicHeader.h>
#import <JLBmpConvertKit/JLBmpConvertOption.h>
#import <JLBmpConvertKit/JLBmpConvert.h>
#import <JLBmpConvertKit/JLGifBin.h>
#import <JLBmpConvertKit/JLImageConvertResult.h>

89
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLBmpConvertOption.h

@ -0,0 +1,89 @@
//
// JLBmpConvertOption.h
// JLBmpConvertKit
//
// Created by EzioChan on 2025/5/23.
// Copyright © 2025 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 图像转换类型
typedef NS_ENUM(NSUInteger, JLBmpConvertType) {
/// 695N 图像转换 RGB 转换成 695N 芯片对应的图像资源
/// 对应原 BR23 转换算法
JLBmpConvertType695N_RBG = 0,
/// 701N 图像转换 RGB 转换成 701N 芯片对应的图像资源
/// 对应原 BR28 转换的算法
JLBmpConvertType701N_RBG = 1,
/// 701N 图像转换 ARGB 转换成 701N 芯片对应的图像资源
/// 对应原 BR28_ARBG 转换的算法
JLBmpConvertType701N_ARBG = 2,
/// 701N 图像转换 RGB 转换成 701N 芯片对应的图像资源(不打包封装)
/// 对应原 BR28_RGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType701N_RBG_NO_PACK = 3,
/// 701N 图像转换 ARGB 转换成 701N 芯片对应的图像资源(不打包封装)
/// 对应原 BR28_ARGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType701N_ARGB_NO_PACK = 4,
/// 707N 图像转换 RGB 转换成 707N 芯片对应的图像资源
/// 对应 BR35 转换的算法
JLBmpConvertType707N_RBG = 5,
/// 707N 图像转换 ARGB 转换成 707N 芯片对应的图像资源
/// 对应 BR35_ARGB 转换的算法
JLBmpConvertType707N_ARGB = 6,
/// 707N 图像转换 RGB 转换成 707N 芯片对应的图像资源(不打包封装)
/// 对应 BR35_RGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType707N_ARGB_NO_PACK = 7,
/// 707N 图像转换 RGB 转换成 707N 芯片对应的图像资源(不打包封装)
/// 对应 BR35_RGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType707N_RBG_NO_PACK = 8,
/// 701N 图像转换 JPEG
/// 仅仅支持 JPEG 类型
JLBmpConvertType701N_JPEG = 9
};
/// 图像格式
/// 仅(707N 系列芯片支持)
typedef NS_ENUM(NSUInteger, JLBmpPixelformat) {
/// ARBG8888/RBG888 格式
JLBmpPixelformat_888,
/// ARBG8565/RBG565 格式
JLBmpPixelformat_565,
/// 自动选择最小的格式
JLBmpPixelformat_Auto,
};
/// LVGL 打包格式
typedef NS_ENUM(NSUInteger, JLBmpPacketFormat) {
JLBmpPacketFormatNone = 0,
JLBmpPacketFormatJLUI = 1,
JLBmpPacketFormatLVGL = 2
};
/// 图像转换选项
@interface JLBmpConvertOption : NSObject
/// 图像转换类型
@property (nonatomic, assign) JLBmpConvertType convertType;
/// 图像格式
/// 默认为 auto
/// 仅(707N 系列芯片支持)
@property (nonatomic, assign) JLBmpPixelformat pixelformat;
/// LVGL 打包格式
/// 默认为 JLUI
/// 仅(707N 系列芯片支持)
@property (nonatomic, assign) JLBmpPacketFormat packetFormat;
/// 是否转换成 BGRA
/// 当使用的是 JL 的 UI 框架时需要使用
/// 使用其他第三方框架时,需要根据固件端框架进行调整
/// default YES
@property (nonatomic, assign) BOOL convertToBGRA;
@end
NS_ASSUME_NONNULL_END

77
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLGifBin.h

@ -0,0 +1,77 @@
//
// JLGifBin.h
// JLGifLib
//
// Created by EzioChan on 2024/1/18.
// Copyright © 2024 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Gif 转 Bin 支持的芯片类型
typedef NS_ENUM(NSUInteger, JLGIFBinChipType) {
/// 701N
JLGIFBinChipJL_701N,
/// 707N
JLGIFBinChipJL_707N,
};
///packet 是否打包
typedef NS_ENUM(NSInteger, JLGIFBinPackageType) {
/// 不做其他打包使用源数据,适用 LVGL 的公版UI框架
JLGIFBinPackageTypeLVGL = 0,
/// 杰理打包格式,适用 JLVGL 的杰理定制版 UI 框架
JLGIFBinPackageTypeJLVGL = 1,
};
typedef void(^JLGif2BinBlock)(int code,NSData *_Nullable binData);
/// Gif 转 Bin
@interface JLGifBin : NSObject
/// 打印当前SDK的版本
+(void)sdkVersion;
/// 根据 Gif 创建 Bin
/// - Parameters:
/// - gifData: Gif 图片数据
/// - level: 等级
/// 1: 低码率
/// 2: 中码率
/// 3: 高码率
/// - block: 回调内容
+(void)makeDataToBin:(NSData *)gifData Level:(int)level Result:(JLGif2BinBlock)block __attribute__((deprecated("Use makeDataToBin:Level:ChipType:Result: instead.")));
/// 根据 Gif 创建 Bin
/// - Parameters:
/// - gifData: Gif 图片数据
/// - level: 等级
/// 1: 低码率
/// 2: 中码率 (仅 JLGIFBinChipJL_701N 支持)
/// 3: 高码率 (仅 JLGIFBinChipJL_701N 支持)
/// - chip: 芯片类型
/// - block: 回调内容
+(void)makeDataToBin:(NSData *)gifData Level:(int)level ChipType:(JLGIFBinChipType)chip Result:(JLGif2BinBlock)block __attribute__((deprecated("Use makeDataToBin:Level:ChipType:PackageType:Result: instead.")));
/// 根据 Gif 创建 Bin
/// - Parameters:
/// - gifData: Gif 图片数据
/// - level: 等级
/// 1: 低码率
/// 2: 中码率 (仅 JLGIFBinChipJL_701N 支持)
/// 3: 高码率 (仅 JLGIFBinChipJL_701N 支持)
/// - chip: 芯片类型
/// - packageType: 打包类型
/// 1: JLUI 杰理 UI 格式
/// 0: 无 不打包
/// - block: 回调内容
+(void)makeDataToBin:(NSData *)gifData Level:(int)level ChipType:(JLGIFBinChipType)chip PackageType:(JLGIFBinPackageType)packageType Result:(JLGif2BinBlock)block;
@end
NS_ASSUME_NONNULL_END

34
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Headers/JLImageConvertResult.h

@ -0,0 +1,34 @@
//
// JLImageConvertResult.h
// JLBmpConvertKit
//
// Created by EzioChan on 2025/3/6.
// Copyright © 2025 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 图像转换结果
@interface JLImageConvertResult : NSObject
/// 结果码
@property (nonatomic, assign) int result;
/// 数据大小
@property (nonatomic, assign) int buf_size;
/// 图像格式
@property (nonatomic, assign) int pixel_format;
/// 压缩模式
@property (nonatomic, assign) int compress_mode;
#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
/// 输出文件路径
@property (nullable, nonatomic, copy) NSString *outFilePath;
#endif
/// 输出文件内容
@property (nullable, nonatomic, copy) NSData *outFileData;
@end
NS_ASSUME_NONNULL_END

BIN
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Info.plist

Binary file not shown.

BIN
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/JLBmpConvertKit

Binary file not shown.

6
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64/JLBmpConvertKit.framework/Modules/module.modulemap

@ -0,0 +1,6 @@
framework module JLBmpConvertKit {
umbrella header "JLBmpConvertKit.h"
export *
module * { export * }
}

95
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLBmpConvert.h

@ -0,0 +1,95 @@
//
// JLBmpCovert.h
// JLBmpCovertKit
//
// Created by EzioChan on 2024/9/9.
// Copyright © 2024 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JLBmpConvertKit/JLBmpConvertKit.h>
#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
#import <UIKit/UIKit.h>
#elif TARGET_OS_OSX
#import <AppKit/AppKit.h>
#endif
NS_ASSUME_NONNULL_BEGIN
@class JLImageConvertResult;
typedef void(^JLBmpCovertCallBack)(NSString *inFilePath,NSString *__nullable outFilePath,NSError *__nullable error) __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
typedef void(^JLBmpCovertCallBack2)(NSData *__nullable outFileData,NSError *__nullable error) __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
/// 图像转换
@interface JLBmpConvert : NSObject
#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
/// 图片转换
/// - Parameters:
/// - type: 转换类型
/// - inFilePath: 输入文件
/// - outFilePath: 输出文件
/// - completion: 回调
+(void)covert:(JLBmpConvertType)type
inFilePath:(NSString *_Nonnull)inFilePath
outFilePath:(NSString * __nullable)outFilePath
completion:(JLBmpCovertCallBack _Nonnull)completion __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
/// 图片转换
/// - Parameters:
/// - type: 转换类型
/// - inImage: 输入图片
/// - completion: 回调
+(void)covert:(JLBmpConvertType)type
Image:(UIImage *)inImage
completion:(JLBmpCovertCallBack2 _Nonnull)completion __deprecated_msg("This interface is deprecated. Please use convert:inFilePath:outFilePath: or convert:Image: instead.");
/// 图片转换
/// - Parameters:
/// - option: 类型
/// 当前仅仅 707N 系列芯片可指定图像格式
/// - inFilePath: 输入文件
/// - outFilePath: 输出文件
/// 文件路径转换入口
+ (JLImageConvertResult *)convert:(JLBmpConvertOption *)option
inFilePath:(NSString *)inFilePath
outFilePath:(NSString * __nullable)outFilePath;
/// UIImage 转换
/// 当前仅仅 707N 系列芯片可指定图像格式
/// - Parameters:
/// - option: 类型
/// - imgData: 图片的原始数据
+(JLImageConvertResult *)convert:(JLBmpConvertOption *)option ImageData:(NSData *)imgData;
/// UIImage 转换成 BGRA
/// - Parameter image: 图片
+(NSData*)convertImageToBGRABytes:(UIImage *)image;
/// UIImage 转换成 RGBA
/// - Parameter image: 图片
+(NSData*)convertImageToRGBABytes:(UIImage *)image;
/// 图片缩放
/// - Parameters:
/// - image: 图片
/// - newSize: 缩放大小
/// - Returns: 缩放后的图片
+(NSData *)resizeImage:(UIImage*)image andResizeTo:(CGSize)newSize;
#elif TARGET_OS_OSX
/// 图片转换
/// - Parameters:
/// - option: 转换类型
/// - inImage: 输入图片
+(JLImageConvertResult *)convert:(JLBmpConvertOption *)option Image:(NSImage *)image;
#endif
@end
NS_ASSUME_NONNULL_END

24
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLBmpConvertKit.h

@ -0,0 +1,24 @@
//
// JLBmpConvertKit.h
// JLBmpConvertKit
//
// Created by EzioChan on 2024/9/9.
// Copyright © 2024 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
//! Project version number for JLBmpConvertKit.
FOUNDATION_EXPORT double JLBmpConvertKitVersionNumber;
//! Project version string for JLBmpConvertKit.
FOUNDATION_EXPORT const unsigned char JLBmpConvertKitVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <JLBmpConvertKit/PublicHeader.h>
#import <JLBmpConvertKit/JLBmpConvertOption.h>
#import <JLBmpConvertKit/JLBmpConvert.h>
#import <JLBmpConvertKit/JLGifBin.h>
#import <JLBmpConvertKit/JLImageConvertResult.h>

89
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLBmpConvertOption.h

@ -0,0 +1,89 @@
//
// JLBmpConvertOption.h
// JLBmpConvertKit
//
// Created by EzioChan on 2025/5/23.
// Copyright © 2025 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 图像转换类型
typedef NS_ENUM(NSUInteger, JLBmpConvertType) {
/// 695N 图像转换 RGB 转换成 695N 芯片对应的图像资源
/// 对应原 BR23 转换算法
JLBmpConvertType695N_RBG = 0,
/// 701N 图像转换 RGB 转换成 701N 芯片对应的图像资源
/// 对应原 BR28 转换的算法
JLBmpConvertType701N_RBG = 1,
/// 701N 图像转换 ARGB 转换成 701N 芯片对应的图像资源
/// 对应原 BR28_ARBG 转换的算法
JLBmpConvertType701N_ARBG = 2,
/// 701N 图像转换 RGB 转换成 701N 芯片对应的图像资源(不打包封装)
/// 对应原 BR28_RGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType701N_RBG_NO_PACK = 3,
/// 701N 图像转换 ARGB 转换成 701N 芯片对应的图像资源(不打包封装)
/// 对应原 BR28_ARGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType701N_ARGB_NO_PACK = 4,
/// 707N 图像转换 RGB 转换成 707N 芯片对应的图像资源
/// 对应 BR35 转换的算法
JLBmpConvertType707N_RBG = 5,
/// 707N 图像转换 ARGB 转换成 707N 芯片对应的图像资源
/// 对应 BR35_ARGB 转换的算法
JLBmpConvertType707N_ARGB = 6,
/// 707N 图像转换 RGB 转换成 707N 芯片对应的图像资源(不打包封装)
/// 对应 BR35_RGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType707N_ARGB_NO_PACK = 7,
/// 707N 图像转换 RGB 转换成 707N 芯片对应的图像资源(不打包封装)
/// 对应 BR35_RGB_NO_PACK 转换的算法(不打包封装)
JLBmpConvertType707N_RBG_NO_PACK = 8,
/// 701N 图像转换 JPEG
/// 仅仅支持 JPEG 类型
JLBmpConvertType701N_JPEG = 9
};
/// 图像格式
/// 仅(707N 系列芯片支持)
typedef NS_ENUM(NSUInteger, JLBmpPixelformat) {
/// ARBG8888/RBG888 格式
JLBmpPixelformat_888,
/// ARBG8565/RBG565 格式
JLBmpPixelformat_565,
/// 自动选择最小的格式
JLBmpPixelformat_Auto,
};
/// LVGL 打包格式
typedef NS_ENUM(NSUInteger, JLBmpPacketFormat) {
JLBmpPacketFormatNone = 0,
JLBmpPacketFormatJLUI = 1,
JLBmpPacketFormatLVGL = 2
};
/// 图像转换选项
@interface JLBmpConvertOption : NSObject
/// 图像转换类型
@property (nonatomic, assign) JLBmpConvertType convertType;
/// 图像格式
/// 默认为 auto
/// 仅(707N 系列芯片支持)
@property (nonatomic, assign) JLBmpPixelformat pixelformat;
/// LVGL 打包格式
/// 默认为 JLUI
/// 仅(707N 系列芯片支持)
@property (nonatomic, assign) JLBmpPacketFormat packetFormat;
/// 是否转换成 BGRA
/// 当使用的是 JL 的 UI 框架时需要使用
/// 使用其他第三方框架时,需要根据固件端框架进行调整
/// default YES
@property (nonatomic, assign) BOOL convertToBGRA;
@end
NS_ASSUME_NONNULL_END

77
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLGifBin.h

@ -0,0 +1,77 @@
//
// JLGifBin.h
// JLGifLib
//
// Created by EzioChan on 2024/1/18.
// Copyright © 2024 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Gif 转 Bin 支持的芯片类型
typedef NS_ENUM(NSUInteger, JLGIFBinChipType) {
/// 701N
JLGIFBinChipJL_701N,
/// 707N
JLGIFBinChipJL_707N,
};
///packet 是否打包
typedef NS_ENUM(NSInteger, JLGIFBinPackageType) {
/// 不做其他打包使用源数据,适用 LVGL 的公版UI框架
JLGIFBinPackageTypeLVGL = 0,
/// 杰理打包格式,适用 JLVGL 的杰理定制版 UI 框架
JLGIFBinPackageTypeJLVGL = 1,
};
typedef void(^JLGif2BinBlock)(int code,NSData *_Nullable binData);
/// Gif 转 Bin
@interface JLGifBin : NSObject
/// 打印当前SDK的版本
+(void)sdkVersion;
/// 根据 Gif 创建 Bin
/// - Parameters:
/// - gifData: Gif 图片数据
/// - level: 等级
/// 1: 低码率
/// 2: 中码率
/// 3: 高码率
/// - block: 回调内容
+(void)makeDataToBin:(NSData *)gifData Level:(int)level Result:(JLGif2BinBlock)block __attribute__((deprecated("Use makeDataToBin:Level:ChipType:Result: instead.")));
/// 根据 Gif 创建 Bin
/// - Parameters:
/// - gifData: Gif 图片数据
/// - level: 等级
/// 1: 低码率
/// 2: 中码率 (仅 JLGIFBinChipJL_701N 支持)
/// 3: 高码率 (仅 JLGIFBinChipJL_701N 支持)
/// - chip: 芯片类型
/// - block: 回调内容
+(void)makeDataToBin:(NSData *)gifData Level:(int)level ChipType:(JLGIFBinChipType)chip Result:(JLGif2BinBlock)block __attribute__((deprecated("Use makeDataToBin:Level:ChipType:PackageType:Result: instead.")));
/// 根据 Gif 创建 Bin
/// - Parameters:
/// - gifData: Gif 图片数据
/// - level: 等级
/// 1: 低码率
/// 2: 中码率 (仅 JLGIFBinChipJL_701N 支持)
/// 3: 高码率 (仅 JLGIFBinChipJL_701N 支持)
/// - chip: 芯片类型
/// - packageType: 打包类型
/// 1: JLUI 杰理 UI 格式
/// 0: 无 不打包
/// - block: 回调内容
+(void)makeDataToBin:(NSData *)gifData Level:(int)level ChipType:(JLGIFBinChipType)chip PackageType:(JLGIFBinPackageType)packageType Result:(JLGif2BinBlock)block;
@end
NS_ASSUME_NONNULL_END

34
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Headers/JLImageConvertResult.h

@ -0,0 +1,34 @@
//
// JLImageConvertResult.h
// JLBmpConvertKit
//
// Created by EzioChan on 2025/3/6.
// Copyright © 2025 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 图像转换结果
@interface JLImageConvertResult : NSObject
/// 结果码
@property (nonatomic, assign) int result;
/// 数据大小
@property (nonatomic, assign) int buf_size;
/// 图像格式
@property (nonatomic, assign) int pixel_format;
/// 压缩模式
@property (nonatomic, assign) int compress_mode;
#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
/// 输出文件路径
@property (nullable, nonatomic, copy) NSString *outFilePath;
#endif
/// 输出文件内容
@property (nullable, nonatomic, copy) NSData *outFileData;
@end
NS_ASSUME_NONNULL_END

BIN
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Info.plist

Binary file not shown.

BIN
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/JLBmpConvertKit

Binary file not shown.

6
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/Modules/module.modulemap

@ -0,0 +1,6 @@
framework module JLBmpConvertKit {
umbrella header "JLBmpConvertKit.h"
export *
module * { export * }
}

192
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLBmpConvertKit.xcframework/ios-arm64_x86_64-simulator/JLBmpConvertKit.framework/_CodeSignature/CodeResources

@ -0,0 +1,192 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Headers/JLBmpConvert.h</key>
<data>
5urDCSjpqHBbBhVbnkpZDUAqfUQ=
</data>
<key>Headers/JLBmpConvertKit.h</key>
<data>
rJo680NKOdjmobcOdHzU3oQ05lQ=
</data>
<key>Headers/JLBmpConvertOption.h</key>
<data>
bPMnU/cb1ZNEvx+6wLH+tNggsWM=
</data>
<key>Headers/JLGifBin.h</key>
<data>
+UKivjyP0JG0+mVZPKUAWwhParQ=
</data>
<key>Headers/JLImageConvertResult.h</key>
<data>
oTrRd0HS6yuPr1JxLpx1Qgr6sKw=
</data>
<key>Info.plist</key>
<data>
mH2wyDJH099CSL7ndYKtKlBiJ3M=
</data>
<key>Modules/module.modulemap</key>
<data>
VG59AHi+XKWb6IYCdGbZKHPgBbA=
</data>
</dict>
<key>files2</key>
<dict>
<key>Headers/JLBmpConvert.h</key>
<dict>
<key>hash</key>
<data>
5urDCSjpqHBbBhVbnkpZDUAqfUQ=
</data>
<key>hash2</key>
<data>
Ps103art4k9gGQecTWk7WrWV5o2nY38Acp7pydnv0oc=
</data>
</dict>
<key>Headers/JLBmpConvertKit.h</key>
<dict>
<key>hash</key>
<data>
rJo680NKOdjmobcOdHzU3oQ05lQ=
</data>
<key>hash2</key>
<data>
9kChXiw3D4JFaLjgBlaq+/2FazTsVCbA3kfmrYQ+9/A=
</data>
</dict>
<key>Headers/JLBmpConvertOption.h</key>
<dict>
<key>hash</key>
<data>
bPMnU/cb1ZNEvx+6wLH+tNggsWM=
</data>
<key>hash2</key>
<data>
4rgpTV2uvjOrNb0Bx7HOxoqBCtJjHaA7CcNBjIImXzY=
</data>
</dict>
<key>Headers/JLGifBin.h</key>
<dict>
<key>hash</key>
<data>
+UKivjyP0JG0+mVZPKUAWwhParQ=
</data>
<key>hash2</key>
<data>
A8LhdAy5NiJx3J5nKUE53rzYmIKX6Cp9oTpGfJJk+tQ=
</data>
</dict>
<key>Headers/JLImageConvertResult.h</key>
<dict>
<key>hash</key>
<data>
oTrRd0HS6yuPr1JxLpx1Qgr6sKw=
</data>
<key>hash2</key>
<data>
vacF3oh76vh4n8lafP3hAi/fYXjU4YoqA6CbjC05MYw=
</data>
</dict>
<key>Modules/module.modulemap</key>
<dict>
<key>hash</key>
<data>
VG59AHi+XKWb6IYCdGbZKHPgBbA=
</data>
<key>hash2</key>
<data>
nU2jpsOghgGuDPwpIFZ+hGnZ5Xj24fG1pjw1H8vpwEs=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^.*</key>
<true/>
<key>^.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^.*</key>
<true/>
<key>^.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>

44
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/Info.plist

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>JLDialUnit.framework/JLDialUnit</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>JLDialUnit.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>JLDialUnit.framework/JLDialUnit</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>JLDialUnit.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

127
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/DialManager.h

@ -0,0 +1,127 @@
//
// DialManager.h
// JLDialUnit
//
// Created by 杰理科技 on 2021/7/20.
//
#import <Foundation/Foundation.h>
#import <JLDialUnit/FatfsObject.h>
#import <JLLogHelper/JLLogHelper.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, DialOperateType) {
DialOperateTypeNoSpace = 0, //空间不足
DialOperateTypeDoing = 1, //正在操作
DialOperateTypeFail = 2, //操作失败
DialOperateTypeSuccess = 3, //操作成功
DialOperateTypeUnnecessary = 4, //无需重复打开文件系统
DialOperateTypeResetFial = 5, //重置文件系统失败
DialOperateTypeNormal = 6, //文件系统正常
DialOperateTypeCmdFail = 7, //流程命令执行失败
};
typedef NS_ENUM(NSInteger, DialUpdateResult) {
DialUpdateResultFinished = 0, //更新资源完成
DialUpdateResultNewest = 1, //资源已最新
DialUpdateResultInvalid = 2, //资源无效
DialUpdateResultEmpty = 3, //资源为空
DialUpdateResultReplace = 4, //资源替换
DialUpdateResultAdd = 5, //资源新增
DialUpdateResultNoSpace = 6, //空间不足
DialUpdateResultZipError = 7, //ZIP资源文件错误
DialUpdateResultCompareFail = 8, //表盘资源对比失败
DialUpdateResultUpdateUfw = 9, //更新固件 ufw
};
typedef void(^DialOperateBK)(DialOperateType type, float progress);
typedef void(^DialListBK)(DialOperateType type, NSArray* __nullable array);
typedef void(^DialUpdateBK)(DialUpdateResult updateResult,
NSArray* __nullable array,
NSString * _Nullable filePath,
NSInteger index ,float progress);
typedef void(^FatfsFreeBlock)(uint32_t freeSize);
/// 表盘管理类
@interface DialManager : NSObject
#pragma mark - 连接成功后,必须调用一次!
+(void)openDialFileSystemWithCmdManager:(JL_ManagerM *)manager withResult:(DialOperateBK)result;
#pragma mark - 重置表盘系统
+(void)resetDialFileSystemWithCmdManager:(JL_ManagerM *)manager withResult:(DialOperateBK)result;
#pragma mark - 查询文件
/// 查询文件
/// @param result 操作回调
+(void)listFile:(DialListBK __nullable)result;
#pragma mark - 添加文件
/// 添加文件
/// @param file 文件名需要加斜杠,类似@“/WACTH1”。
/// @param content 文件数据
/// @param result 操作回调
+(void)addFile:(NSString*)file
Content:(NSData*)content
Result:(DialOperateBK)result;
#pragma mark - 删除文件
/// 删除文件(第一种)
/// @param file 文件名需要加斜杠,类似@“/WACTH1”。
/// @param result 操作回调
+(void)deleteFile:(NSString*)file
Result:(DialOperateBK)result;
/// 删除文件(第二种)
/// @param fileModel JLModel_File类型
/// @param result 操作回调
+(void)deleteDialResourceWithFileModel:(JLModel_File*)fileModel Result:(DialOperateBK)result;
#pragma mark - 替换文件
/// 替换文件
/// @param file 文件名需要加斜杠,类似@“/WACTH1”。
/// @param content 文件数据
/// @param result 操作回调
+(void)repaceFile:(NSString*)file
Content:(NSData*)content
Result:(DialOperateBK)result;
#pragma mark - 格式化外部Flash
/// 格式化外部Flash操作
/// @param handle 设备句柄
/// @param result 操作回调
+(void)formatFlash:(NSString*)handle Result:(DialOperateBK)result;
#pragma mark - 更新设备的表盘资源(异步调用)
/// 更新设备的表盘资源(异步调用)
/// @param path 资源文件
/// @param array 表盘列表(当前)
/// @param result 更新结果
+(void)updateResourcePath:(NSString*)path
List:(NSArray*)array
Result:(DialUpdateBK)result;
/// 获取 fatfs 系统的剩余空间(仅仅fatfs 系统可用)
/// 需要异步执行
+(uint32_t)getFatfsFree;
/// 获取 fatfs 系统的剩余空间(仅仅fatfs 系统可用)
/// @param block 回调空间
+(void)getFatfsFree:(FatfsFreeBlock) block;
/// 获取某文件的文件大小
/// @param fileName 文件名
+(uint32_t)getSizeOfFileName:(NSString*)fileName;
/// 记录文件大小(私有接口)
/// @param size 文件大小
/// @param fileName 文件名
+(void)setFileSize:(uint32_t)size FileName:(NSString*)fileName;
@end
NS_ASSUME_NONNULL_END

92
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/FatfsObject.h

@ -0,0 +1,92 @@
//
// FatfsObject.h
// JLDialUnit
//
// Created by 杰理科技 on 2021/7/20.
//
#import <Foundation/Foundation.h>
#import <JL_BLEKit/JL_BLEKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface FatfsObject : NSObject
#pragma mark ---> FATFS API
/// 输入命令中心类,在JL_Entity内。
/// @param manager 命令中心类
+(void)makeCmdManager:(JL_ManagerM*)manager;
/// 返回 当前 JL_ManagerM
+(JL_ManagerM*)outputCmdManager;
/// 设置外挂Flash的大小,FATFS的大小。
/// @param flashSize Flash大小
/// @param fatsSize FATFS的大小
+(BOOL)makeFlashSize:(uint32_t)flashSize
FatsSize:(uint32_t)fatsSize;
/// 获取Flash上的文件
/// @param path 文件名数组,其中"JL",“FONT”不能操作。
+(NSArray*)makeListPath:(NSString*)path;
typedef void(^FatfsCreateFile_BK)(float progress);
/// 新增文件
/// @param path “/文件名”
/// @param data 文件数据内容
/// @param result 进度
+(BOOL)makeCreateFile:(NSString*)path
Content:(NSData* __nullable)data
Result:(FatfsCreateFile_BK __nullable)result;
typedef void(^FatfsReplaceFile_BK)(float progress);
/// 替换文件
/// @param path “/文件名”
/// @param data 文件数据内容
+(BOOL)makeReplaceFile:(NSString*)path
Content:(NSData* __nullable)data
Result:(FatfsReplaceFile_BK __nullable)result;
/// 删除文件
/// @param path “/文件名”
+(BOOL)makeRemoveFile:(NSString*)path;
/*---------------------------------------------------------------------------------
FatSize -- FAT文件系统认为自己的大小
因为小机的FAT项按4K对齐了大小,所以实际上会认为FAT系统占用的空间比FLASH实际大小还要大
用 f_getfree 获取的是 FAT文件系统认为自己剩余的空间的簇个数,其中有一部分是在Flash上实际不存在的
所以需要减掉这部分
计算方式如下:
FatUsed = FatSize - FreeSize
FlashRemainSize = FlashSize - FatUsed
----------------------------------------------------------------------------------*/
/// 获取FATFS系统剩余空间,“FreeSize”。
+(uint32_t)makeGetFree;
/// 查看watch本间版本
/// @param path 文件路径
+(NSString*)makeVersionWatch:(NSString*)path;
/// 获取文件前面512个字节
/// @param path 文件路径
+(NSData *)makeFileHead:(NSString*)path;
/// 读文件
/// @param path 文件路径
+(NSData*)makeFileContent:(NSString*)path;
/// 解压资源文件
/// @param path 文件路径
/// @param aimDirection 存放的路径
+(NSArray*)unzipFileAtPath:(NSString *)path toDestination:(NSString *)aimDirection;
/// 重置外部flash
+(BOOL)makeResetFlash;
@end
NS_ASSUME_NONNULL_END

27
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/JLDialSourceModel.h

@ -0,0 +1,27 @@
//
// JLDialSourceModel.h
// JLDialUnit
//
// Created by EzioChan on 2025/3/19.
// Copyright © 2025 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JL_BLEKit/JL_BLEKit.h>
#import <JLLogHelper/JLLogHelper.h>
NS_ASSUME_NONNULL_BEGIN
@interface JLDialSourceModel : JLModel_File
/// 文件大小
@property (nonatomic, assign)int size;
/// CRC
@property (nonatomic, assign)uint16_t crc;
+(JLDialSourceModel *)createModel:(JLModel_File *)model;
@end
NS_ASSUME_NONNULL_END

30
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/JLDialUnit.h

@ -0,0 +1,30 @@
//
// JLDialUnit.h
// JLDialUnit
//
// Created by EzioChan on 2023/2/28.
// Copyright © 2023 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
//! Project version number for JLDialUnit.
FOUNDATION_EXPORT double JLDialUnitVersionNumber;
//! Project version string for JLDialUnit.
FOUNDATION_EXPORT const unsigned char JLDialUnitVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <JLDialUnit/PublicHeader.h>
#import <JLDialUnit/DialManager.h>
#import <JLDialUnit/FatfsObject.h>
#import <JLDialUnit/ZipZap.h>
#import <JLDialUnit/ZZArchive.h>
#import <JLDialUnit/ZZArchiveEntry.h>
#import <JLDialUnit/ZZConstants.h>
#import <JLDialUnit/ZZError.h>
#import <JLDialUnit/JLDialSourceModel.h>
#import <JLDialUnit/JLDialUnitMgr.h>
#import <JLDialUnit/JLSourceUpdate.h>

115
local_plugins/device_jieli/ios/device_jieli/Frameworks/JLDialUnit.xcframework/ios-arm64/JLDialUnit.framework/Headers/JLDialUnitMgr.h

@ -0,0 +1,115 @@
//
// JLDialUnitMgr.h
// JLDialUnit
//
// Created by EzioChan on 2025/3/18.
// Copyright © 2025 www.zh-jieli.com. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <JL_HashPair/JL_HashPair.h>
#import <JLLogHelper/JLLogHelper.h>
#import <JL_BLEKit/JL_BLEKit.h>
NS_ASSUME_NONNULL_BEGIN
@class JLDialSourceModel;
@interface JLDialUnitMgr : NSObject
/// 设备文件列表
@property (nonatomic,strong)NSMutableArray <JLDialSourceModel *> *fileArray;
/// 当前 Flash 操控句柄
@property (nonatomic, strong) JLFlashOpMgr *currentFlaghOpMgr;
/// 当前使用的表盘文件
@property (nonatomic,strong)JLDialSourceModel *currentFileSource;
/// 当前使用的表盘背景
@property (nonatomic,strong)JLDialSourceModel *_Nullable currentBackground;
/// 初始化
/// - Parameters:
/// - mgr: 蓝牙管理对象
/// - completion: 回调
-(instancetype)initWithManager:(JL_ManagerM*)mgr completion:(void (^)(NSError * _Nullable))completion;
/// 获取文件列表
/// - Parameters:
/// - cardType: 设备存储的类型
/// - count: 请求的数目
/// - completion: 回调
/// 返回时,是返回累计数目
-(void)getFileList:(JL_CardType)cardType count:(NSInteger)count completion:(void (^)(NSArray <JLDialSourceModel *> * _Nullable,NSError * _Nullable))completion;
/// 清空文件列表
/// - Parameter cardType: 设备存储的类型
-(void)cleanFileList;
/// 更新文件列表信息
/// - Parameters:
/// - fileSources: NSArray<JLModel_File *>
/// - completion: 回调
-(void)updateFileSources:(NSArray<JLModel_File *> *)fileSources completion:(void (^)(NSArray<JLDialSourceModel *> * _Nullable,NSError * _Nullable))completion;
/// 更新文件到设备
/// - Parameters:
/// - fileHandleType: 设备目标存储句柄
/// - fileData: 文件数据
/// - filePath: 文件存储路径
/// - resultCompletion: 回调
/// state: 0:成功 1:传输中 -1:失败
/// progress: 传输进度
/// error: 错误
-(void)updateFileToDevice:(JL_FileHandleType)fileHandleType Data:(NSData *)fileData FilePath:(NSString *)filePath completion:(void (^)(int,double,NSError * _Nullable))resultCompletion;
/// 删除文件
/// - Parameters:
/// - filePath: 文件路径
/// - resultCompletion: 回调
-(void)deleteFile:(NSString *)filePath getFileList:(BOOL)isGet Completion:(void (^)(BOOL state, NSError * _Nullable error))resultCompletion;
/// 删除多个文件
/// - Parameters:
/// - filePathList: 文件路径
/// - resultCompletion: 回调
-(void)deleteFiles:(NSArray <NSString*>*) filePathList Completion:(void (^)(BOOL state, NSError * _Nullable error))resultCompletion;
/// 获取剩余空间
/// - Parameter completion: 回调
-(void)getRemainingSpace:(void(^)(int freeSize, NSError * _Nullable error))completion;
/// 获取剩余空间 V1
/// - Parameter completion: 回调
-(void)getFlashRemainingSpace:(void(^)(int freeSize, NSError * _Nullable error))completion;
//MARK: 表盘操作
/// 获取当前正在使用的表盘
/// - Parameter completion: 回调
-(void)dialGetCurrent:(void (^)(JLDialSourceModel * _Nullable))completion;
/// 设置当前表盘
/// - Parameters:
/// - model: 表盘
/// - resultCompletion: 回调
-(void)dialSetCurrent:(JLDialSourceModel *)model Completion:(void (^)(BOOL state, NSError * _Nullable error))resultCompletion;
/// 激活自定义表盘背景
/// - Parameters:
/// - model: 表盘背景对象
/// - resultCompletion: 回调
-(void)dialActiveCustomBackground:(JLDialSourceModel *)model Completion:(void (^)(BOOL state, NSError * _Nullable error))resultCompletion;
/// 重置自定义表盘背景
/// - Parameter resultCompletion: 回调
-(void)dialResetCustomBackground:(void (^)(BOOL state, NSError * _Nullable error))resultCompletion;
/// 获取表盘背景
/// - Parameters:
/// - path: 路径 诸如“/WATCH”
/// - resultCompletion: 回调
-(void)dialGetDial:(NSString *)path BackgroundInfo:(void (^)(JLDialSourceModel * _Nullable model, NSError * _Nullable error))resultCompletion;
@end
NS_ASSUME_NONNULL_END

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save