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