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

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 }
}