534 changed files with 9547 additions and 4231 deletions
@ -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 |
## 0.0.1 |
||||
|
|
||||
* TODO: Describe initial release. |
* TODO: Describe initial release. |
||||
|
|||||
@ -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 |
| Platform | Native SDK | Status | |
||||
[plug-in package](https://flutter.dev/to/develop-plugins), |
|---|---|---| |
||||
a specialized package that includes platform-specific implementation code for |
| Android | `android/libs/jl_bluetooth_rcsp_*.aar` etc. | Functional (used in production demo) | |
||||
Android and/or iOS. |
| 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 |
## iOS setup |
||||
[online documentation](https://docs.flutter.dev), which offers tutorials, |
|
||||
samples, guidance on mobile development, and a full API reference. |
|
||||
|
|
||||
|
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). |
||||
|
|||||
Binary file not shown.
@ -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 |
||||
|
} |
||||
|
} |
||||
Binary file not shown.
Binary file not shown.
@ -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 |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
@ -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]) |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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. |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
@ -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 } |
|
||||
} |
|
||||
@ -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)`. |
|
||||
} |
|
||||
} |
|
||||
@ -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() |
|
||||
} |
|
||||
} |
|
||||
@ -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> |
|
||||
|
|
||||
@ -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 |
|
||||
Binary file not shown.
@ -1,6 +0,0 @@ |
|||||
framework module JLAV2Lib { |
|
||||
umbrella header "JLAV2Lib.h" |
|
||||
|
|
||||
export * |
|
||||
module * { export * } |
|
||||
} |
|
||||
@ -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 |
|
||||
@ -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> |
||||
@ -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> |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,6 @@ |
|||||
|
framework module JLAudioUnitKit { |
||||
|
umbrella header "JLAudioUnitKit.h" |
||||
|
export * |
||||
|
|
||||
|
module * { export * } |
||||
|
} |
||||
@ -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 |
||||
|
|
||||
|
|
||||
@ -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> |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,6 @@ |
|||||
|
framework module JLAudioUnitKit { |
||||
|
umbrella header "JLAudioUnitKit.h" |
||||
|
export * |
||||
|
|
||||
|
module * { export * } |
||||
|
} |
||||
@ -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> |
||||
@ -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 |
||||
|
|
||||
|
|
||||
@ -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> |
||||
@ -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 |
||||
@ -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> |
||||
|
|
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,6 @@ |
|||||
|
framework module JLBmpConvertKit { |
||||
|
umbrella header "JLBmpConvertKit.h" |
||||
|
export * |
||||
|
|
||||
|
module * { export * } |
||||
|
} |
||||
@ -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 |
||||
@ -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> |
||||
|
|
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,6 @@ |
|||||
|
framework module JLBmpConvertKit { |
||||
|
umbrella header "JLBmpConvertKit.h" |
||||
|
export * |
||||
|
|
||||
|
module * { export * } |
||||
|
} |
||||
@ -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> |
||||
@ -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> |
||||
@ -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 |
||||
@ -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 |
||||
|
|
||||
@ -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 |
||||
@ -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> |
||||
|
|
||||
|
|
||||
@ -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…
Reference in new issue