diff --git a/local_plugins/device_jieli/ios/Classes/Audio/OpusStreamCodec.swift b/local_plugins/device_jieli/ios/Classes/Audio/OpusStreamCodec.swift new file mode 100644 index 000000000..1f2c3ed43 --- /dev/null +++ b/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.. 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 diff --git a/local_plugins/device_jieli/ios/Classes/Audio/PcmKit.swift b/local_plugins/device_jieli/ios/Classes/Audio/PcmKit.swift new file mode 100644 index 000000000..b4197e24e --- /dev/null +++ b/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) + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Bridge/EventDispatcher.swift b/local_plugins/device_jieli/ios/Classes/Bridge/EventDispatcher.swift new file mode 100644 index 000000000..fa55d2929 --- /dev/null +++ b/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 + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Bridge/MethodRouter.swift b/local_plugins/device_jieli/ios/Classes/Bridge/MethodRouter.swift new file mode 100644 index 000000000..30fb4cd20 --- /dev/null +++ b/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 + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift b/local_plugins/device_jieli/ios/Classes/Core/JieliHomeServer.swift new file mode 100644 index 000000000..aea6c0c10 --- /dev/null +++ b/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 + } + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/AssistantBridge.swift b/local_plugins/device_jieli/ios/Classes/Feature/AssistantBridge.swift new file mode 100644 index 000000000..1cd8f0c55 --- /dev/null +++ b/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 diff --git a/local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/ConnectFeature.swift new file mode 100644 index 000000000..e36587beb --- /dev/null +++ b/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]) + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/CustomCmdFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/CustomCmdFeature.swift new file mode 100644 index 000000000..065b15dc7 --- /dev/null +++ b/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 + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/DeviceInfoFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/DeviceInfoFeature.swift new file mode 100644 index 000000000..ae8ec68d8 --- /dev/null +++ b/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 + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/DeviceRecordFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/DeviceRecordFeature.swift new file mode 100644 index 000000000..8fdbf0075 --- /dev/null +++ b/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 diff --git a/local_plugins/device_jieli/ios/Classes/Feature/OtaFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/OtaFeature.swift new file mode 100644 index 000000000..540a15af0 --- /dev/null +++ b/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) + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/ScanFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/ScanFeature.swift new file mode 100644 index 000000000..31ed88077 --- /dev/null +++ b/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 = [] + + 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.. CBUUID? { + let s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !s.isEmpty else { return nil } + return CBUUID(string: s) + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/SpeechFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/SpeechFeature.swift new file mode 100644 index 000000000..6b3eb1329 --- /dev/null +++ b/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. + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/Translation/Mode/CallTranslationModeHandler.swift b/local_plugins/device_jieli/ios/Classes/Feature/Translation/Mode/CallTranslationModeHandler.swift new file mode 100644 index 000000000..071ff848b --- /dev/null +++ b/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) + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/Translation/RcspTranslationRuntime.swift b/local_plugins/device_jieli/ios/Classes/Feature/Translation/RcspTranslationRuntime.swift new file mode 100644 index 000000000..6b9027a3f --- /dev/null +++ b/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 + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationAudioBridge.swift b/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationAudioBridge.swift new file mode 100644 index 000000000..3af7da017 --- /dev/null +++ b/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) + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationCoordinator.swift b/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationCoordinator.swift new file mode 100644 index 000000000..2cff76ab6 --- /dev/null +++ b/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 diff --git a/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationModeHandler.swift b/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationModeHandler.swift new file mode 100644 index 000000000..32f3eea44 --- /dev/null +++ b/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 + } +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationStreams.swift b/local_plugins/device_jieli/ios/Classes/Feature/Translation/TranslationStreams.swift new file mode 100644 index 000000000..6869ab515 --- /dev/null +++ b/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 +} diff --git a/local_plugins/device_jieli/ios/Classes/Feature/TranslationFeature.swift b/local_plugins/device_jieli/ios/Classes/Feature/TranslationFeature.swift new file mode 100644 index 000000000..115d4648f --- /dev/null +++ b/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 } +} diff --git a/local_plugins/device_jieli/ios/Classes/Integration/JieliNativeDevicePlugin.swift b/local_plugins/device_jieli/ios/Classes/Integration/JieliNativeDevicePlugin.swift new file mode 100644 index 000000000..4966eef30 --- /dev/null +++ b/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)`. + } +} diff --git a/local_plugins/device_jieli/ios/Classes/JielihomePlugin.swift b/local_plugins/device_jieli/ios/Classes/JielihomePlugin.swift new file mode 100644 index 000000000..8972c4f4f --- /dev/null +++ b/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() + } +} diff --git a/local_plugins/device_jieli/ios/device_jieli.podspec b/local_plugins/device_jieli/ios/device_jieli.podspec new file mode 100644 index 000000000..06524ecf0 --- /dev/null +++ b/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 diff --git a/local_plugins/device_jieli/pubspec.yaml b/local_plugins/device_jieli/pubspec.yaml index 2b92c6a1c..5065b209a 100644 --- a/local_plugins/device_jieli/pubspec.yaml +++ b/local_plugins/device_jieli/pubspec.yaml @@ -40,6 +40,8 @@ flutter: android: package: com.jielihome.jielihome pluginClass: JielihomePlugin + ios: + pluginClass: JielihomePlugin # To add assets to your plugin package, add an assets section, like this: # assets: diff --git a/local_plugins/device_plugin_interface/ios/Classes/DevicePluginInterfacePlugin.swift b/local_plugins/device_plugin_interface/ios/Classes/DevicePluginInterfacePlugin.swift new file mode 100644 index 000000000..485c56d4d --- /dev/null +++ b/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. + } +} diff --git a/local_plugins/device_plugin_interface/ios/device_plugin_interface.podspec b/local_plugins/device_plugin_interface/ios/device_plugin_interface.podspec new file mode 100644 index 000000000..735ed2613 --- /dev/null +++ b/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 diff --git a/local_plugins/device_plugin_interface/pubspec.yaml b/local_plugins/device_plugin_interface/pubspec.yaml index 03a4b62bb..7712630e2 100644 --- a/local_plugins/device_plugin_interface/pubspec.yaml +++ b/local_plugins/device_plugin_interface/pubspec.yaml @@ -21,3 +21,5 @@ flutter: android: package: com.aiagent.device_plugin_interface pluginClass: DevicePluginInterfacePlugin + ios: + pluginClass: DevicePluginInterfacePlugin