You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
5.4 KiB
156 lines
5.4 KiB
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 }
|
|
}
|
|
|