Browse Source
完整复刻 Android 端架构,接通三大业务模块: - 通话翻译(mono + stereo,含立体声自动升级) - AI 助理(MODE_ONLY_RECORD + recordtype=byDevice 始终录音) - 会议记录的通话录音(MODE_CALL_TRANSLATION 双声道) MethodChannel / EventChannel schema 与 Android 完全一致,Dart 层无需改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>weicu
27 changed files with 3270 additions and 0 deletions
@ -0,0 +1,181 @@ |
|||||
|
import Foundation |
||||
|
|
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
import JLAudioUnitKit |
||||
|
#endif |
||||
|
|
||||
|
/// Streaming OPUS decoder + encoder built on top of `JLAudioUnitKit`. |
||||
|
/// |
||||
|
/// Mirrors the Android `audio/OpusStreamCodec.kt` (decoder side). The codec |
||||
|
/// pushes 16 kHz / 16-bit / mono / 20 ms PCM frames through the supplied |
||||
|
/// callback, matching the Android contract used by `RcspTranslationRuntime`. |
||||
|
/// |
||||
|
/// Important: `JLOpusDecoder.opusDecoderInputData(_:)` accepts **one OPUS |
||||
|
/// packet at a time**. The headset advertises packets at 40 bytes for 16k/20ms; |
||||
|
/// pass the byte stream directly — the decoder does not buffer. If the |
||||
|
/// upstream feed combines multiple packets in one chunk, we slice along |
||||
|
/// `packetSize` to keep the decoder happy. |
||||
|
final class OpusStreamDecoder: NSObject { |
||||
|
|
||||
|
let sampleRate: Int |
||||
|
let channels: Int |
||||
|
let packetSize: Int |
||||
|
|
||||
|
private let onPcm: (Data) -> Void |
||||
|
private let onError: (Int, String?) -> Void |
||||
|
|
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
private var decoder: JLOpusDecoder? |
||||
|
#endif |
||||
|
private var started = false |
||||
|
private let lock = NSLock() |
||||
|
|
||||
|
init( |
||||
|
channels: Int = 1, |
||||
|
packetSize: Int = 40, |
||||
|
sampleRate: Int = 16000, |
||||
|
onPcm: @escaping (Data) -> Void, |
||||
|
onError: @escaping (Int, String?) -> Void = { _, _ in } |
||||
|
) { |
||||
|
self.channels = channels |
||||
|
self.packetSize = packetSize |
||||
|
self.sampleRate = sampleRate |
||||
|
self.onPcm = onPcm |
||||
|
self.onError = onError |
||||
|
} |
||||
|
|
||||
|
func start() { |
||||
|
lock.lock(); defer { lock.unlock() } |
||||
|
if started { return } |
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
let format = JLOpusFormat.defaultFormats() |
||||
|
format.sampleRate = Int32(sampleRate) |
||||
|
format.channels = Int32(channels) |
||||
|
format.frameDuration = 20 |
||||
|
// The headset's stream is the JieLi-flavored OPUS (no header). The |
||||
|
// demo's `defaultFormats` already disables the header for the headset |
||||
|
// direction, so we keep that default. |
||||
|
format.dataSize = Int32(packetSize) |
||||
|
decoder = JLOpusDecoder(decoder: format, delegate: self) |
||||
|
#endif |
||||
|
started = true |
||||
|
} |
||||
|
|
||||
|
func stop() { |
||||
|
lock.lock(); defer { lock.unlock() } |
||||
|
if !started { return } |
||||
|
started = false |
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
decoder?.opusOnRelease() |
||||
|
decoder = nil |
||||
|
#endif |
||||
|
} |
||||
|
|
||||
|
/// Feed one or more OPUS packets concatenated. The decoder is fed one |
||||
|
/// `packetSize`-sized chunk at a time when the input length is a multiple |
||||
|
/// of `packetSize`; otherwise the entire buffer is forwarded as-is and |
||||
|
/// the decoder does its own resync. |
||||
|
func feedEncoded(_ data: Data) { |
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
guard started, let dec = decoder else { return } |
||||
|
if packetSize > 0 && data.count > packetSize && data.count % packetSize == 0 { |
||||
|
var offset = 0 |
||||
|
while offset < data.count { |
||||
|
let end = min(offset + packetSize, data.count) |
||||
|
dec.opusDecoderInputData(data.subdata(in: offset..<end)) |
||||
|
offset = end |
||||
|
} |
||||
|
} else { |
||||
|
dec.opusDecoderInputData(data) |
||||
|
} |
||||
|
#endif |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
extension OpusStreamDecoder: JLOpusDecoderDelegate { |
||||
|
func opusDecoder(_ decoder: JLOpusDecoder, data: Data?, error: Error?) { |
||||
|
if let err = error { |
||||
|
onError((err as NSError).code, err.localizedDescription) |
||||
|
return |
||||
|
} |
||||
|
guard let pcm = data, !pcm.isEmpty else { return } |
||||
|
onPcm(pcm) |
||||
|
} |
||||
|
} |
||||
|
#endif |
||||
|
|
||||
|
/// Streaming OPUS encoder used to repackage TTS PCM for the headset's |
||||
|
/// downlink path. Buffers PCM chunks and emits encoded packets through the |
||||
|
/// callback. |
||||
|
final class OpusStreamEncoder: NSObject { |
||||
|
|
||||
|
let sampleRate: Int |
||||
|
let channels: Int |
||||
|
private let onEncoded: (Data) -> Void |
||||
|
private let onError: (Int, String?) -> Void |
||||
|
|
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
private var encoder: JLOpusEncoder? |
||||
|
#endif |
||||
|
private var started = false |
||||
|
private let lock = NSLock() |
||||
|
|
||||
|
init( |
||||
|
channels: Int = 1, |
||||
|
sampleRate: Int = 16000, |
||||
|
onEncoded: @escaping (Data) -> Void, |
||||
|
onError: @escaping (Int, String?) -> Void = { _, _ in } |
||||
|
) { |
||||
|
self.sampleRate = sampleRate |
||||
|
self.channels = channels |
||||
|
self.onEncoded = onEncoded |
||||
|
self.onError = onError |
||||
|
} |
||||
|
|
||||
|
func start() { |
||||
|
lock.lock(); defer { lock.unlock() } |
||||
|
if started { return } |
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
// Use the JieLi default config (no OGG header — same as the headset |
||||
|
// expects, matches the demo's `JLOpusEncodeConfig.default()`). |
||||
|
let cfg = JLOpusEncodeConfig.default() |
||||
|
cfg.sampleRate = Int32(sampleRate) |
||||
|
cfg.channels = Int32(channels) |
||||
|
cfg.frameDuration = 20 |
||||
|
encoder = JLOpusEncoder(format: cfg, delegate: self) |
||||
|
#endif |
||||
|
started = true |
||||
|
} |
||||
|
|
||||
|
func stop() { |
||||
|
lock.lock(); defer { lock.unlock() } |
||||
|
if !started { return } |
||||
|
started = false |
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
encoder?.opusOnRelease() |
||||
|
encoder = nil |
||||
|
#endif |
||||
|
} |
||||
|
|
||||
|
/// Feed one or more raw PCM bytes (16-bit LE, `channels` interleaved when >1). |
||||
|
func feedPcm(_ pcm: Data) { |
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
guard started, let enc = encoder else { return } |
||||
|
enc.opusEncode(pcm) |
||||
|
#endif |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#if canImport(JLAudioUnitKit) |
||||
|
extension OpusStreamEncoder: JLOpusEncoderDelegate { |
||||
|
func opusEncoder(_ encoder: JLOpusEncoder, data: Data?, error: Error?) { |
||||
|
if let err = error { |
||||
|
onError((err as NSError).code, err.localizedDescription) |
||||
|
return |
||||
|
} |
||||
|
guard let opus = data, !opus.isEmpty else { return } |
||||
|
onEncoded(opus) |
||||
|
} |
||||
|
} |
||||
|
#endif |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
@ -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]) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,217 @@ |
|||||
|
import Foundation |
||||
|
import CoreBluetooth |
||||
|
|
||||
|
/// BLE scan via CoreBluetooth. Mirrors Android `feature/ScanFeature.kt`. |
||||
|
/// |
||||
|
/// Like the Android implementation, scanning is **not** delegated to the JieLi |
||||
|
/// SDK's built-in `scan()` (which has its own filterContent/strategy quirks); |
||||
|
/// instead we drive `CBCentralManager` directly and parse the advertisement |
||||
|
/// data ourselves. The published `deviceFound` event payload matches the |
||||
|
/// Android schema 1:1 so the Dart parser is unchanged: |
||||
|
/// |
||||
|
/// ``` |
||||
|
/// { |
||||
|
/// type: "deviceFound", |
||||
|
/// name, address, rssi, |
||||
|
/// edrAddr, deviceType, connectWay, // null on iOS — see note below |
||||
|
/// rawAdv, advFlags, manufacturerCompanyId, manufacturerData, |
||||
|
/// serviceUuids: [String], advRecords: [{len, type, data}] |
||||
|
/// } |
||||
|
/// ``` |
||||
|
/// |
||||
|
/// `edrAddr` / `deviceType` / `connectWay` are populated on Android by feeding |
||||
|
/// the raw scan record through `ParseDataUtil.isFilterBleDevice(...)`. On iOS |
||||
|
/// the equivalent live in `JL_AdvParse`'s `JLAdvParse` Obj-C class; until the |
||||
|
/// exact entry point is wired we leave them null and rely on the connection |
||||
|
/// path's BLE-only fallback (matches the Android "fallback when SDK can't |
||||
|
/// parse" behavior). |
||||
|
public class ScanFeature: NSObject, CBCentralManagerDelegate { |
||||
|
|
||||
|
private weak var server: JieliHomeServer? |
||||
|
private weak var dispatcher: EventDispatcher? |
||||
|
|
||||
|
/// Dedicated central manager for scanning — separated from the SDK's own |
||||
|
/// `JL_BLEMultiple` central so that filter knobs do not collide. |
||||
|
private var centralManager: CBCentralManager? |
||||
|
|
||||
|
private var scanning: Bool = false |
||||
|
private var stopWorkItem: DispatchWorkItem? |
||||
|
|
||||
|
private var currentNameList: [String] = [] |
||||
|
private var currentSkipUnnamed: Bool = true |
||||
|
private var serviceFilters: [CBUUID] = [] |
||||
|
|
||||
|
// Track which addresses have been published this scan to dedupe. |
||||
|
private var publishedIds: Set<String> = [] |
||||
|
|
||||
|
public init(dispatcher: EventDispatcher) { |
||||
|
self.dispatcher = dispatcher |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
public func attach(server: JieliHomeServer) { |
||||
|
self.server = server |
||||
|
if centralManager == nil { |
||||
|
centralManager = CBCentralManager(delegate: self, queue: .main) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func detach() { |
||||
|
stopScan() |
||||
|
centralManager = nil |
||||
|
} |
||||
|
|
||||
|
// MARK: - Public API (mirrors Android signatures) |
||||
|
|
||||
|
public func startScan( |
||||
|
timeoutMs: Int, |
||||
|
nameList: [String], |
||||
|
uuidList: [String], |
||||
|
skipUnnamed: Bool |
||||
|
) throws { |
||||
|
guard let central = centralManager else { |
||||
|
throw PluginError("SCAN_FAILED", "BLE central unavailable") |
||||
|
} |
||||
|
if scanning { stopScan() } |
||||
|
|
||||
|
currentNameList = nameList.filter { !$0.isEmpty } |
||||
|
currentSkipUnnamed = skipUnnamed |
||||
|
serviceFilters = uuidList.compactMap { Self.parseUuid($0) } |
||||
|
publishedIds.removeAll(keepingCapacity: true) |
||||
|
|
||||
|
if central.state != .poweredOn { |
||||
|
// Match Android behavior — scan request from a powered-off adapter |
||||
|
// surfaces the same shape of error. |
||||
|
throw PluginError("SCAN_FAILED", "BLE adapter not powered on (state=\(central.state.rawValue))") |
||||
|
} |
||||
|
|
||||
|
let services: [CBUUID]? = serviceFilters.isEmpty ? nil : serviceFilters |
||||
|
let options: [String: Any] = [ |
||||
|
// Allow duplicate adverts so we keep getting RSSI updates. |
||||
|
CBCentralManagerScanOptionAllowDuplicatesKey: false |
||||
|
] |
||||
|
central.scanForPeripherals(withServices: services, options: options) |
||||
|
scanning = true |
||||
|
dispatcher?.send([ |
||||
|
"type": "scanStatus", |
||||
|
"ble": true, |
||||
|
"started": true, |
||||
|
]) |
||||
|
|
||||
|
let work = DispatchWorkItem { [weak self] in self?.stopScan() } |
||||
|
stopWorkItem = work |
||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeoutMs), execute: work) |
||||
|
} |
||||
|
|
||||
|
public func stopScan() { |
||||
|
if let work = stopWorkItem { work.cancel(); stopWorkItem = nil } |
||||
|
if let central = centralManager, central.state == .poweredOn { |
||||
|
central.stopScan() |
||||
|
} |
||||
|
if scanning { |
||||
|
dispatcher?.send([ |
||||
|
"type": "scanStatus", |
||||
|
"ble": true, |
||||
|
"started": false, |
||||
|
]) |
||||
|
} |
||||
|
scanning = false |
||||
|
currentNameList = [] |
||||
|
serviceFilters = [] |
||||
|
} |
||||
|
|
||||
|
public func isScanning() -> Bool { scanning } |
||||
|
|
||||
|
// MARK: - CBCentralManagerDelegate |
||||
|
|
||||
|
public func centralManagerDidUpdateState(_ central: CBCentralManager) { |
||||
|
let on = central.state == .poweredOn |
||||
|
dispatcher?.send([ |
||||
|
"type": "adapterStatus", |
||||
|
"enabled": on, |
||||
|
"hasBle": true, |
||||
|
]) |
||||
|
if !on && scanning { stopScan() } |
||||
|
} |
||||
|
|
||||
|
public func centralManager( |
||||
|
_ central: CBCentralManager, |
||||
|
didDiscover peripheral: CBPeripheral, |
||||
|
advertisementData: [String: Any], |
||||
|
rssi RSSI: NSNumber |
||||
|
) { |
||||
|
let name = peripheral.name |
||||
|
?? (advertisementData[CBAdvertisementDataLocalNameKey] as? String) |
||||
|
|
||||
|
if currentSkipUnnamed && (name?.isEmpty ?? true) { return } |
||||
|
if !currentNameList.isEmpty { |
||||
|
guard let n = name, currentNameList.contains(where: { $0.caseInsensitiveCompare(n) == .orderedSame }) else { return } |
||||
|
} |
||||
|
|
||||
|
let id = peripheral.identifier.uuidString |
||||
|
if !publishedIds.insert(id).inserted { return } |
||||
|
|
||||
|
let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data |
||||
|
let serviceUuids = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])? |
||||
|
.map { $0.uuidString.uppercased() } ?? [] |
||||
|
|
||||
|
var advFlags: Int? = nil |
||||
|
var mfrCompanyId: Int? = nil |
||||
|
var mfrPayloadHex: String? = nil |
||||
|
var advRecords: [[String: String]] = [] |
||||
|
|
||||
|
if let m = manufacturerData, m.count >= 2 { |
||||
|
mfrCompanyId = Int(m[0]) | (Int(m[1]) << 8) |
||||
|
if m.count > 2 { |
||||
|
mfrPayloadHex = m[2..<m.count].map { String(format: "%02X", $0) }.joined() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// BLE Core Spec §11 ad-record reconstruction is best-effort on iOS: |
||||
|
// CoreBluetooth surfaces parsed fields only. We synthesize a minimal |
||||
|
// record list for parity with Android's structured payload. |
||||
|
if let m = manufacturerData { |
||||
|
// 0xFF — Manufacturer Specific Data |
||||
|
advRecords.append([ |
||||
|
"len": String(format: "%02d", m.count + 1), |
||||
|
"type": "0xFF", |
||||
|
"data": "0x" + m.map { String(format: "%02X", $0) }.joined(), |
||||
|
]) |
||||
|
} |
||||
|
if let n = name, !n.isEmpty { |
||||
|
let bytes = Array(n.utf8) |
||||
|
advRecords.append([ |
||||
|
"len": String(format: "%02d", bytes.count + 1), |
||||
|
"type": "0x09", // Complete Local Name |
||||
|
"data": "0x" + bytes.map { String(format: "%02X", $0) }.joined(), |
||||
|
]) |
||||
|
} |
||||
|
|
||||
|
var payload: [String: Any?] = [ |
||||
|
"type": "deviceFound", |
||||
|
"name": name ?? "", |
||||
|
// iOS does not expose the BLE MAC in CoreBluetooth — use the peripheral |
||||
|
// identifier UUID as a stable address. Downstream ConnectFeature looks |
||||
|
// it up by the same id. |
||||
|
"address": id, |
||||
|
"edrAddr": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available |
||||
|
"deviceType": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available |
||||
|
"connectWay": NSNull(), // TODO: parse via JL_AdvParse / JLAdvParse if available |
||||
|
"rssi": RSSI.intValue, |
||||
|
"rawAdv": NSNull(), // CoreBluetooth does not surface raw bytes |
||||
|
"advRecords": advRecords, |
||||
|
"advFlags": advFlags as Any, |
||||
|
"manufacturerCompanyId": mfrCompanyId as Any, |
||||
|
"manufacturerData": mfrPayloadHex as Any, |
||||
|
"serviceUuids": serviceUuids, |
||||
|
] |
||||
|
dispatcher?.send(payload) |
||||
|
} |
||||
|
|
||||
|
// MARK: - UUID parsing (matches Android `parseUuid`) |
||||
|
static func parseUuid(_ raw: String) -> CBUUID? { |
||||
|
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
guard !s.isEmpty else { return nil } |
||||
|
return CBUUID(string: s) |
||||
|
} |
||||
|
} |
||||
@ -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. |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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 } |
||||
|
} |
||||
@ -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)`. |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
@ -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. |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
Loading…
Reference in new issue