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