Browse Source

上传jieli sdk的iOS适配代码

完整复刻 Android 端架构,接通三大业务模块:
- 通话翻译(mono + stereo,含立体声自动升级)
- AI 助理(MODE_ONLY_RECORD + recordtype=byDevice 始终录音)
- 会议记录的通话录音(MODE_CALL_TRANSLATION 双声道)

MethodChannel / EventChannel schema 与 Android 完全一致,Dart 层无需改动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
weicu
liwei1dao 5 months ago
parent
commit
ac6740014e
  1. 181
      local_plugins/device_jieli/ios/Classes/Audio/OpusStreamCodec.swift
  2. 27
      local_plugins/device_jieli/ios/Classes/Audio/PcmKit.swift
  3. 107
      local_plugins/device_jieli/ios/Classes/Bridge/EventDispatcher.swift
  4. 327
      local_plugins/device_jieli/ios/Classes/Bridge/MethodRouter.swift
  5. 169
      local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift
  6. 208
      local_plugins/device_jieli/ios/Classes/Feature/AssistantBridge.swift
  7. 176
      local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift
  8. 50
      local_plugins/device_jieli/ios/Classes/Feature/CustomCmdFeature.swift
  9. 67
      local_plugins/device_jieli/ios/Classes/Feature/DeviceInfoFeature.swift
  10. 202
      local_plugins/device_jieli/ios/Classes/Feature/DeviceRecordFeature.swift
  11. 127
      local_plugins/device_jieli/ios/Classes/Feature/OtaFeature.swift
  12. 217
      local_plugins/device_jieli/ios/Classes/Feature/ScanFeature.swift
  13. 59
      local_plugins/device_jieli/ios/Classes/Feature/SpeechFeature.swift
  14. 217
      local_plugins/device_jieli/ios/Classes/Feature/Translation/Mode/CallTranslationModeHandler.swift
  15. 415
      local_plugins/device_jieli/ios/Classes/Feature/Translation/RcspTranslationRuntime.swift
  16. 134
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationAudioBridge.swift
  17. 110
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationCoordinator.swift
  18. 130
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationModeHandler.swift
  19. 35
      local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationStreams.swift
  20. 156
      local_plugins/device_jieli/ios/Classes/Feature/TranslationFeature.swift
  21. 31
      local_plugins/device_jieli/ios/Classes/Integration/JieliNativeDevicePlugin.swift
  22. 50
      local_plugins/device_jieli/ios/Classes/JielihomePlugin.swift
  23. 38
      local_plugins/device_jieli/ios/device_jieli.podspec
  24. 2
      local_plugins/device_jieli/pubspec.yaml
  25. 14
      local_plugins/device_plugin_interface/ios/Classes/DevicePluginInterfacePlugin.swift
  26. 19
      local_plugins/device_plugin_interface/ios/device_plugin_interface.podspec
  27. 2
      local_plugins/device_plugin_interface/pubspec.yaml

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

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

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

@ -0,0 +1,27 @@
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)
}
}

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

@ -0,0 +1,107 @@
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.
@objc public 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`.
public class EventDispatcher: NSObject, FlutterStreamHandler {
private let lock = NSLock()
private var sink: FlutterEventSink?
private var listeners: [JieliEventListener] = []
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink
) -> FlutterError? {
lock.lock(); defer { lock.unlock() }
sink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
lock.lock(); defer { lock.unlock() }
sink = nil
return nil
}
// MARK: - Native listeners
public func addNativeListener(_ listener: JieliEventListener) {
lock.lock(); defer { lock.unlock() }
if !listeners.contains(where: { $0 === listener }) {
listeners.append(listener)
}
}
public 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.
public func send(_ payload: [String: Any?]) {
let snapshot: [JieliEventListener]
let sinkRef: FlutterEventSink?
lock.lock()
snapshot = listeners
sinkRef = sink
lock.unlock()
for l in snapshot {
l.onEvent(payload: payload)
}
guard let s = sinkRef else { return }
let sanitized = sanitize(payload)
if Thread.isMainThread {
s(sanitized)
} else {
DispatchQueue.main.async { s(sanitized) }
}
}
/// Replace `nil` values with `NSNull()` and re-emit `Data` payloads as
/// `FlutterStandardTypedData.bytes(...)` so Dart receives `Uint8List`
/// rather than an opaque blob (matches Android's ByteArray → Uint8List
/// auto-conversion).
private func sanitize(_ raw: [String: Any?]) -> [String: Any] {
var out = [String: Any]()
out.reserveCapacity(raw.count)
for (k, v) in raw {
out[k] = sanitize(value: v)
}
return out
}
private func sanitize(value: Any?) -> Any {
guard let v = value else { return NSNull() }
if let data = v as? Data {
return FlutterStandardTypedData(bytes: data)
}
if let arr = v as? [Any?] {
return arr.map { sanitize(value: $0) }
}
if let map = v as? [String: Any?] {
return sanitize(map)
}
return v
}
}

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

@ -0,0 +1,327 @@
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).
public class MethodRouter {
private let server: JieliHomeServer
public init(server: JieliHomeServer) {
self.server = server
}
public 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.
public struct PluginError: Error {
public let code: String
public let message: String
public init(_ code: String, _ message: String) {
self.code = code
self.message = message
}
}

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

@ -0,0 +1,169 @@
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.
public class JieliHomeServer {
public static let shared = JieliHomeServer()
private(set) public var initialized: Bool = false
/// Event bus shared by Flutter EventChannel + native listeners.
public 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.
public private(set) lazy var translationCoordinator: TranslationCoordinator = {
TranslationCoordinator(server: self)
}()
// MARK: - Features (lazy-attached after initialize)
public private(set) lazy var scanFeature: ScanFeature = {
ScanFeature(dispatcher: dispatcher)
}()
public private(set) lazy var connectFeature: ConnectFeature = {
ConnectFeature(server: self)
}()
public private(set) lazy var deviceInfoFeature: DeviceInfoFeature = {
DeviceInfoFeature(server: self)
}()
public private(set) lazy var customCmdFeature: CustomCmdFeature = {
CustomCmdFeature(server: self)
}()
public private(set) lazy var translationFeature: TranslationFeature = {
TranslationFeature(server: self)
}()
public private(set) lazy var speechFeature: SpeechFeature = {
SpeechFeature(server: self)
}()
public private(set) lazy var otaFeature: OtaFeature = {
OtaFeature(server: self)
}()
public private(set) lazy var deviceRecordFeature: DeviceRecordFeature = {
DeviceRecordFeature(server: self)
}()
public private(set) lazy var assistantBridge: AssistantBridge = {
AssistantBridge(server: self)
}()
private init() {}
// MARK: - Lifecycle
public 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()
initialized = true
dispatcher.send([
"type": "adapterStatus",
"enabled": true,
"hasBle": true,
])
}
public func shutdown() {
if !initialized { return }
otaFeature.cancel()
deviceRecordFeature.stop()
translationFeature.stop()
speechFeature.detach()
assistantBridge.shutdown()
scanFeature.detach()
initialized = false
}
// MARK: - Native event subscription helpers
public func addEventListener(_ listener: JieliEventListener) {
dispatcher.addNativeListener(listener)
}
public func removeEventListener(_ listener: JieliEventListener) {
dispatcher.removeNativeListener(listener)
}
// MARK: - Internal cleanup listener
// Same role as Android `internalCleanupListener`: when the device
// disconnects or call status flips while in non-call translation mode,
// proactively stop translation/recording to avoid stale state.
private lazy var internalCleanup: InternalCleanupListener = {
InternalCleanupListener(server: self)
}()
}
private final class InternalCleanupListener: NSObject, JieliEventListener {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
func onEvent(payload: [String: Any?]) {
guard let server = server else { return }
let type = payload["type"] as? String
switch type {
case "connectionState":
let state = payload["state"] as? Int ?? -1
// 0 = CONNECTION_DISCONNECT (matches Android constant)
if state == 0 {
if server.translationFeature.isWorking() { server.translationFeature.stop() }
if server.deviceRecordFeature.isRecording() { server.deviceRecordFeature.stop() }
if server.assistantBridge.isRunning() { server.assistantBridge.stop() }
// Tear down the translation coordinator so the next session builds
// a fresh JLTranslationManager bound to the new device's manager.
server.translationCoordinator.tearDown()
}
case "phoneCallStatus":
let status = payload["status"] as? Int ?? 0
if status == 0 { return }
if let mid = server.translationFeature.currentModeId() {
// 3 = MODE_CALL_TRANSLATION, 6 = MODE_CALL_TRANSLATION_WITH_STEREO
let isCallMode = mid == 3 || mid == 6
if !isCallMode && server.translationFeature.isWorking() {
server.translationFeature.stop()
}
}
default:
break
}
}
}

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

@ -0,0 +1,208 @@
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).
public 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
public func isRunning() -> Bool { running }
public 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
}
public func stop() {
if !running { return }
running = false
cleanup()
server?.dispatcher.send([
"type": "assistantEnd",
"tsMs": Int(Date().timeIntervalSince1970 * 1000),
])
}
public 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

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

@ -0,0 +1,176 @@
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.
public 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 {
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
// 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
} else if let made = mult.makeEntity(withUUID: bleAddress) {
entity = made
entityCache[bleAddress] = entity
} else {
throw PluginError("CONNECT_FAILED", "remote device not found (UUID=\(bleAddress))")
}
mult.connectEntity(entity) { [weak self] status in
guard let self = self else { return }
switch status {
case .paired:
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 { _, _, _ in
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:
break
case .disconnectOk:
self.server?.dispatcher.send([
"type": "connectionState",
"address": bleAddress,
"state": 0,
])
default:
// Any other terminal failure → expose as 0/disconnect with a code.
self.server?.dispatcher.send([
"type": "connectionState",
"address": bleAddress,
"state": 0,
])
}
}
#else
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 {
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
server?.dispatcher.send([
"type": "connectionState",
"address": id,
"state": 0,
])
if connectedId == id { connectedId = nil; connectedName = nil }
}
}
@objc private func onAdapterOff(_ note: Notification) {
server?.dispatcher.send(["type": "adapterStatus", "enabled": false, "hasBle": true])
}
@objc private func onAdapterOn(_ note: Notification) {
server?.dispatcher.send(["type": "adapterStatus", "enabled": true, "hasBle": true])
}
}

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

@ -0,0 +1,50 @@
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.
public class CustomCmdFeature {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
public func send(
address: String,
opCode: UInt8,
payload: Data,
completion: @escaping (Data?, Error?) -> Void
) {
#if canImport(JL_BLEKit)
guard let entity = server?.connectFeature.currentEntity() as? JL_EntityM,
entity.mUUID == address else {
completion(nil, PluginError("CUSTOM_CMD_ERR", "no active session"))
return
}
var frame = Data(capacity: 1 + payload.count)
frame.append(opCode)
frame.append(payload)
entity.mCmdManager.mCustomManager.cmdCustomData(
frame,
isNeedResponse: true
) { status, _, data in
if status == .success {
completion(data ?? Data(), nil)
} else {
completion(nil, PluginError("CUSTOM_CMD_ERR", "JL_CMDStatus=\(status.rawValue)"))
}
}
#else
completion(nil, PluginError("CUSTOM_CMD_ERR", "JL_BLEKit not linked"))
#endif
}
}

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

@ -0,0 +1,67 @@
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.
public class DeviceInfoFeature {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
public 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
}
public func queryTargetInfo(
address: String,
mask: Int,
completion: @escaping ([String: Any?]?, Error?) -> Void
) {
#if canImport(JL_BLEKit)
guard let entity = server?.connectFeature.currentEntity() as? JL_EntityM,
entity.mUUID == address else {
completion(nil, PluginError("TARGET_INFO_ERR", "no active session"))
return
}
entity.mCmdManager.cmdTargetFeatureResult { [weak self] _, _, _ in
let snap = self?.snapshot(address: address)
if let s = snap { completion(s, nil) }
else { completion(nil, PluginError("TARGET_INFO_ERR", "snapshot unavailable")) }
}
#else
completion(nil, PluginError("TARGET_INFO_ERR", "JL_BLEKit not linked"))
#endif
}
private func valueIfNotEmpty(_ s: String?) -> Any {
guard let s = s, !s.isEmpty else { return NSNull() }
return s
}
}

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

@ -0,0 +1,202 @@
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.
public 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
public func isRecording() -> Bool { working }
public 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
}
public 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

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

@ -0,0 +1,127 @@
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.
public class OtaFeature {
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
private var running: Bool = false
public func isRunning() -> Bool { running }
public 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(),
])
server.bleMultiple.otaFunc(withEntityM: entity, withFilePath: firmwareFilePath) { [weak self] result, progress in
self?.onOtaCallback(result: result, progress: progress)
}
#else
server?.dispatcher.send([
"type": "otaError",
"code": -1,
"message": "JL_OTALib not linked",
])
#endif
}
public 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)
}
}

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

@ -0,0 +1,217 @@
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).
public 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> = []
public init(dispatcher: EventDispatcher) {
self.dispatcher = dispatcher
super.init()
}
public func attach(server: JieliHomeServer) {
self.server = server
if centralManager == nil {
centralManager = CBCentralManager(delegate: self, queue: .main)
}
}
public func detach() {
stopScan()
centralManager = nil
}
// MARK: - Public API (mirrors Android signatures)
public 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)
}
public 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 = []
}
public func isScanning() -> Bool { scanning }
// MARK: - CBCentralManagerDelegate
public func centralManagerDidUpdateState(_ central: CBCentralManager) {
let on = central.state == .poweredOn
dispatcher?.send([
"type": "adapterStatus",
"enabled": on,
"hasBle": true,
])
if !on && scanning { stopScan() }
}
public func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
let name = peripheral.name
?? (advertisementData[CBAdvertisementDataLocalNameKey] as? String)
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 manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let serviceUuids = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?
.map { $0.uuidString.uppercased() } ?? []
var advFlags: Int? = nil
var mfrCompanyId: Int? = nil
var mfrPayloadHex: String? = nil
var advRecords: [[String: String]] = []
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": NSNull(), // CoreBluetooth does not surface raw bytes
"advRecords": advRecords,
"advFlags": advFlags as Any,
"manufacturerCompanyId": mfrCompanyId as Any,
"manufacturerData": mfrPayloadHex as Any,
"serviceUuids": serviceUuids,
]
dispatcher?.send(payload)
}
// MARK: - UUID parsing (matches Android `parseUuid`)
static func parseUuid(_ raw: String) -> CBUUID? {
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !s.isEmpty else { return nil }
return CBUUID(string: s)
}
}

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

@ -0,0 +1,59 @@
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`.
public class SpeechFeature {
public static let voiceTypePcm = 0
public static let voiceTypeSpeex = 1
public static let voiceTypeOpus = 2
public static let sampleRate8k = 8
public static let sampleRate16k = 16
public static let vadWayDevice = 0
public static let vadWaySdk = 1
public static let reasonNormal = 0
public static let reasonStop = 1
weak var server: JieliHomeServer?
init(server: JieliHomeServer) { self.server = server }
public func isRecording(address: String?) -> Bool { false }
public 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")
}
public func stop(
address: String?,
reason: Int,
completion: @escaping (Bool, String?) -> Void
) {
completion(false, "speech.stop not yet implemented on iOS")
}
public func detach() {
// No resources held while stubbed.
}
}

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

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

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

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

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

@ -0,0 +1,134 @@
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`.
public 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`.
public 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
public init(
dispatcher: EventDispatcher,
injector: @escaping (Int, String, Data, TranslationAudioFormat, Bool) -> Bool
) {
self.dispatcher = dispatcher
self.injector = injector
}
public 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,
])
}
public 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,
])
}
public func emitLog(modeId: Int, content: String) {
dispatcher?.send([
"type": "translationLog",
"modeId": modeId,
"content": content,
])
}
public func emitError(modeId: Int, code: Int, message: String?) {
dispatcher?.send([
"type": "translationError",
"modeId": modeId,
"code": code,
"message": message as Any,
])
}
@discardableResult
public func feedTranslatedAudio(
modeId: Int,
outputStreamId: String,
pcm: Data,
format: TranslationAudioFormat,
isFinal: Bool
) -> Bool {
injector(modeId, outputStreamId, pcm, format, isFinal)
}
}

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

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

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

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

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

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

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

@ -0,0 +1,156 @@
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.
public 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`).
public 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
public func isWorking() -> Bool { current?.isWorking ?? false }
public func currentModeId() -> Int? {
guard let h = current, h.isWorking else { return nil }
return h.modeId
}
public func currentInputStreams() -> [String] {
current?.isWorking == true ? (current?.inputStreams ?? []) : []
}
public func currentOutputStreams() -> [String] {
current?.isWorking == true ? (current?.outputStreams ?? []) : []
}
public 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
}
public func stop() {
current?.stop()
current = nil
}
@discardableResult
public 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
)
}
public 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.
public 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.
public func feedAudioFilePcm(pcm: Data, sampleRate: Int) -> Bool { false }
}

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

@ -0,0 +1,31 @@
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.
public enum JieliNativeDevicePlugin {
public static let kVendor = "jieli"
public static func register() {
// TODO: when device_plugin_interface iOS gains a NativeDevicePluginRegistry,
// wire the JieLi factory here, mirroring Android's
// `JieliNativeDevicePlugin.register(context)`.
}
}

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

@ -0,0 +1,50 @@
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()
}
}

38
local_plugins/device_jieli/ios/device_jieli.podspec

@ -0,0 +1,38 @@
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'
s.vendored_frameworks = [
'Frameworks/JL_BLEKit.xcframework',
'Frameworks/JL_OTALib.xcframework',
'Frameworks/JL_AdvParse.xcframework',
'Frameworks/JL_HashPair.xcframework',
'Frameworks/JLLogHelper.xcframework',
'Frameworks/JLAudioUnitKit.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

2
local_plugins/device_jieli/pubspec.yaml

@ -40,6 +40,8 @@ flutter:
android: android:
package: com.jielihome.jielihome package: com.jielihome.jielihome
pluginClass: JielihomePlugin pluginClass: JielihomePlugin
ios:
pluginClass: JielihomePlugin
# To add assets to your plugin package, add an assets section, like this: # To add assets to your plugin package, add an assets section, like this:
# assets: # assets:

14
local_plugins/device_plugin_interface/ios/Classes/DevicePluginInterfacePlugin.swift

@ -0,0 +1,14 @@
import Flutter
import UIKit
/// No-op plugin class — `device_plugin_interface` only provides Dart-side contracts
/// and shared data classes. Vendor plugins (`device_jieli`, ...) implement them
/// in their own native code and depend on this package solely at the Flutter level.
///
/// Mirrors the Android stub at
/// `android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt`.
public class DevicePluginInterfacePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
// Intentionally empty: no MethodChannel / EventChannel surface.
}
}

19
local_plugins/device_plugin_interface/ios/device_plugin_interface.podspec

@ -0,0 +1,19 @@
Pod::Spec.new do |s|
s.name = 'device_plugin_interface'
s.version = '0.1.0'
s.summary = 'Abstract contracts for hardware device plugins (BLE / RCSP / vendor SDK).'
s.description = <<-DESC
This pod is a no-op companion for the device_plugin_interface Flutter plugin.
The Dart layer holds the actual abstract contracts; vendor plugins (e.g. device_jieli)
implement them in their own native code and depend on this package only at the Flutter level.
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.platform = :ios, '12.0'
s.swift_version = '5.0'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
end

2
local_plugins/device_plugin_interface/pubspec.yaml

@ -21,3 +21,5 @@ flutter:
android: android:
package: com.aiagent.device_plugin_interface package: com.aiagent.device_plugin_interface
pluginClass: DevicePluginInterfacePlugin pluginClass: DevicePluginInterfacePlugin
ios:
pluginClass: DevicePluginInterfacePlugin

Loading…
Cancel
Save