diff --git a/local_plugins/azure_speech/android/app/build.gradle.kts b/local_plugins/azure_speech/android/app/build.gradle.kts deleted file mode 100644 index bd9431936..000000000 --- a/local_plugins/azure_speech/android/app/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.example.azure_speech" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.azure_speech" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -flutter { - source = "../.." -} diff --git a/local_plugins/azure_speech/android/app/src/debug/AndroidManifest.xml b/local_plugins/azure_speech/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981d..000000000 --- a/local_plugins/azure_speech/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/local_plugins/azure_speech/android/app/src/main/AndroidManifest.xml b/local_plugins/azure_speech/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 388b60653..000000000 --- a/local_plugins/azure_speech/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/local_plugins/azure_speech/android/app/src/main/kotlin/com/example/azure_speech/MainActivity.kt b/local_plugins/azure_speech/android/app/src/main/kotlin/com/example/azure_speech/MainActivity.kt deleted file mode 100644 index 60cc81f0d..000000000 --- a/local_plugins/azure_speech/android/app/src/main/kotlin/com/example/azure_speech/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.azure_speech - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/local_plugins/azure_speech/android/app/src/main/res/drawable-v21/launch_background.xml b/local_plugins/azure_speech/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3f..000000000 --- a/local_plugins/azure_speech/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/local_plugins/azure_speech/android/app/src/main/res/drawable/launch_background.xml b/local_plugins/azure_speech/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f88..000000000 --- a/local_plugins/azure_speech/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/local_plugins/azure_speech/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/local_plugins/azure_speech/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b7..000000000 Binary files a/local_plugins/azure_speech/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/local_plugins/azure_speech/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/local_plugins/azure_speech/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79b..000000000 Binary files a/local_plugins/azure_speech/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/local_plugins/azure_speech/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/local_plugins/azure_speech/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d439148..000000000 Binary files a/local_plugins/azure_speech/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/local_plugins/azure_speech/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/local_plugins/azure_speech/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d34..000000000 Binary files a/local_plugins/azure_speech/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/local_plugins/azure_speech/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/local_plugins/azure_speech/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372eeb..000000000 Binary files a/local_plugins/azure_speech/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/local_plugins/azure_speech/android/app/src/main/res/values-night/styles.xml b/local_plugins/azure_speech/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be74..000000000 --- a/local_plugins/azure_speech/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/local_plugins/azure_speech/android/app/src/main/res/values/styles.xml b/local_plugins/azure_speech/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef8805..000000000 --- a/local_plugins/azure_speech/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/local_plugins/azure_speech/android/app/src/profile/AndroidManifest.xml b/local_plugins/azure_speech/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981d..000000000 --- a/local_plugins/azure_speech/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 687135011..815a067db 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -657,6 +657,14 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val useExternalAudio = call.argument("audioSourceType") ?: false val removeFirstPunctuation = call.argument("isRemoveFirstPunctuation") ?: true + + // 通话翻译模式(external):AST端到端服务已处理识别+翻译+TTS,无需启动ASR + if (useExternalAudio) { + FileLogger.d(tag, "通话模式,AST端到端服务已激活(provider=$currentAstProvider),跳过ASR") + result.success(true) + return + } + // 确保事件通道已准备好 if (asrEventSink == null) { result.error( @@ -668,15 +676,9 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, } try { - FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // - // 选择音频源类型 - val audioSourceType = if (useExternalAudio) { - AzureAsrHelper.AudioSourceType.EXTERNAL - } else { - AzureAsrHelper.AudioSourceType.MICROPHONE - } + FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") + val audioSourceType = AzureAsrHelper.AudioSourceType.MICROPHONE val success = azureAsrHelper.startContinuousRecognition(audioSourceType) - // val success = azureAsrHelper.startContinuousRecognition(audioSourceType, isRemoveFirstPunctuation = removeFirstPunctuation) result.success(true) } catch (e: Exception) { result.error("START_RECOGNITION_ERROR", e.message, null) diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index a5e261755..61590139e 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -86,6 +86,14 @@ public class AzureAsrHelper: NSObject { return useXunfei ? "xunfei" : "azure" } + /// 通话模式下强制禁用讯飞,使用微软 ASR(与 Android 对齐) + public func forceDisableXunfei() { + if useXunfei { + os_log("forceDisableXunfei: 通话模式强制切换到微软ASR", log: log, type: .info) + useXunfei = false + } + } + /** * 初始化Azure语音服务 * diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index cae8e7341..cd1e2fc17 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -19,7 +19,9 @@ import ble_service private let tag = "AzureSpeechPlugin" // 日志对象 internal let log = OSLog(subsystem: "com.azure.speech", category: "AzureSpeechPlugin") - + /// 通话翻译专用日志,Xcode Console 过滤: subsystem=com.azure.speech category=CallTranslation + internal let ctLog = OSLog(subsystem: "com.azure.speech", category: "CallTranslation") + // MARK: - 防抖相关(iOS 原生层) // 函数级注释:用于限制 startContinuousRecognition/stopContinuousRecognition 在极短时间内的重复触发 private var lastStartRecognitionAt: TimeInterval = 0 @@ -91,7 +93,9 @@ import ble_service private var astChannel: FlutterMethodChannel? private var astEventChannel: FlutterEventChannel? internal var astEventSink: FlutterEventSink? - private let azureAstHelper = IntegratedSpeechTranslationService() + // 双向 AST 服务:A(己方)和 B(对方),与 Android 对齐 + private let azureAstHelperA = IntegratedSpeechTranslationService() + private let azureAstHelperB = IntegratedSpeechTranslationService() // 端到端翻译 Helpers(豆包 / 阿里百炼) private var doubaoAstHelperA = DoubaoE2ETranslateHelper() @@ -110,6 +114,9 @@ import ble_service private var audioDataEventChannel: FlutterEventChannel? internal var audioDataEventSink: FlutterEventSink? + // 通话翻译音频统计(日志用) + private var bleAudioRecvCount: Int64 = 0 + // 录音相关 public var recordfile: RecordFile? private var isRecord = false @@ -414,15 +421,22 @@ private func sendAudioDataEvent(_ event: [String: Any]) { AzureAsrHelper.AudioSourceType.external : AzureAsrHelper.AudioSourceType.microphone - print("startContinuousRecognition: \(audioSourceType)") - - // 启动连续识别 - let success = azureAsrHelper.startContinuousRecognition( - mod: mode, - audioSourceType: audioSourceType, - isRemoveFirstPunctuation: removeFirstPunctuation - ) - result(success) + os_log("startContinuousRecognition: mode=%@, audioSourceType=%@, isExternal=%d, astProvider=%@", + log: ctLog, type: .info, mode, String(describing: audioSourceType), isExternalActive ? 1 : 0, currentAstProvider) + + // 通话翻译模式(external):AST 端到端服务已处理识别+翻译+TTS,无需启动 ASR + if isExternalActive { + os_log("startContinuousRecognition: 通话模式,AST端到端服务已激活(provider=%@),跳过ASR", log: ctLog, type: .info, currentAstProvider) + result(true) + } else { + let success = azureAsrHelper.startContinuousRecognition( + mod: mode, + audioSourceType: audioSourceType, + isRemoveFirstPunctuation: removeFirstPunctuation + ) + os_log("startContinuousRecognition 结果=%d", log: ctLog, type: .info, success ? 1 : 0) + result(true) + } case "recognizeCallback": // 确保事件通道已准备好 guard asrEventSink != nil else { @@ -451,11 +465,17 @@ private func sendAudioDataEvent(_ event: [String: Any]) { result(true) return } - - print("stopContinuousRecognition") - let success = azureAsrHelper.stopContinuousRecognition() - currentAsrCallback = nil - result(success) + + // ASR 没在运行时(通话模式下被跳过了)直接返回 + if azureAsrHelper.audioStream?.isContinuousRecognitionActive != true { + os_log("stopContinuousRecognition: ASR未运行,跳过", log: ctLog, type: .info) + result(true) + } else { + os_log("stopContinuousRecognition: 停止ASR", log: ctLog, type: .info) + let success = azureAsrHelper.stopContinuousRecognition() + currentAsrCallback = nil + result(success) + } case "isContinuousRecognitionActive": result(azureAsrHelper.isContinuousRecognitionActive()) @@ -757,50 +777,64 @@ private func sendAudioDataEvent(_ event: [String: Any]) { // } case "startContinuousTranslation": + os_log("[AST-START] provider=%{public}@", log: ctLog, type: .info, currentAstProvider) if currentAstProvider == "volcano" { - _ = doubaoAstHelperA.startContinuousTranslation() - _ = doubaoAstHelperB.startContinuousTranslation() + let rA = doubaoAstHelperA.startContinuousTranslation() + let rB = doubaoAstHelperB.startContinuousTranslation() + os_log("[AST-START] volcano: A=%d, B=%d", log: ctLog, type: .info, rA ? 1 : 0, rB ? 1 : 0) result(true) } else if currentAstProvider == "alibaba" { - _ = bailianAstHelperA.startContinuousConversation() - _ = bailianAstHelperB.startContinuousConversation() + let rA = bailianAstHelperA.startContinuousConversation() + let rB = bailianAstHelperB.startContinuousConversation() + os_log("[AST-START] alibaba: A=%d, B=%d", log: ctLog, type: .info, rA ? 1 : 0, rB ? 1 : 0) result(true) } else { do { - os_log("开启翻译", log: log, type: .info) - try azureAstHelper.startContinuousTranslation() + os_log("[AST-START] azure: starting A+B", log: ctLog, type: .info) + try azureAstHelperA.startContinuousTranslation() + try azureAstHelperB.startContinuousTranslation() + os_log("[AST-START] azure: A+B started successfully", log: ctLog, type: .info) result(true) } catch { + os_log("[AST-START] azure: FAILED: %{public}@", log: ctLog, type: .error, error.localizedDescription) result(FlutterError(code: "START_CONTINUOUS_TRANSLATION_ERROR", message: error.localizedDescription, details: nil)) } } case "stopContinuousTranslation": + os_log("[AST-STOP] provider=%{public}@", log: ctLog, type: .info, currentAstProvider) if currentAstProvider == "volcano" { - _ = doubaoAstHelperA.stopContinuousTranslation() - _ = doubaoAstHelperB.stopContinuousTranslation() + let rA = doubaoAstHelperA.stopContinuousTranslation() + let rB = doubaoAstHelperB.stopContinuousTranslation() + os_log("[AST-STOP] volcano: A=%d, B=%d", log: ctLog, type: .info, rA ? 1 : 0, rB ? 1 : 0) result(true) } else if currentAstProvider == "alibaba" { - _ = bailianAstHelperA.stopContinuousConversation() - _ = bailianAstHelperB.stopContinuousConversation() + let rA = bailianAstHelperA.stopContinuousConversation() + let rB = bailianAstHelperB.stopContinuousConversation() + os_log("[AST-STOP] alibaba: A=%d, B=%d", log: ctLog, type: .info, rA ? 1 : 0, rB ? 1 : 0) result(true) } else { do { - os_log("停止翻译", log: log, type: .info) - try azureAstHelper.stopContinuousTranslation() + os_log("[AST-STOP] azure: stopping A+B", log: ctLog, type: .info) + try azureAstHelperA.stopContinuousTranslation() + try azureAstHelperB.stopContinuousTranslation() + os_log("[AST-STOP] azure: A+B stopped successfully", log: ctLog, type: .info) result(true) } catch { + os_log("[AST-STOP] azure: FAILED: %{public}@", log: ctLog, type: .error, error.localizedDescription) result(FlutterError(code: "STOP_CONTINUOUS_TRANSLATION_ERROR", message: error.localizedDescription, details: nil)) } } case "dispose": - os_log("释放AST资源", log: log, type: .info) - azureAstHelper.dispose() + os_log("[AST-DISPOSE] Releasing all AST resources, provider=%{public}@", log: ctLog, type: .info, currentAstProvider) + azureAstHelperA.dispose() + azureAstHelperB.dispose() doubaoAstHelperA.dispose() doubaoAstHelperB.dispose() bailianAstHelperA.dispose() bailianAstHelperB.dispose() + os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info) result(true) case "recognizeCallback": @@ -849,9 +883,25 @@ private func sendAudioDataEvent(_ event: [String: Any]) { let alibabaAppId = (args["alibabaAppId"] as? String) ?? "" let alibabaAppURL = (args["alibabaAppURL"] as? String) ?? "" - os_log("initialize provider=%@ lang0=%@ lang1=%@ trans0=%@ trans1=%@", log: log, type: .info, provider, lang0, lang1, translationLang0, translationLang1) + os_log("[AST-INIT] provider=%{public}@ lang0=%{public}@ lang1=%{public}@ trans0=%{public}@ trans1=%{public}@ tts0=%{public}@ tts1=%{public}@", + log: ctLog, type: .info, provider, lang0, lang1, translationLang0, translationLang1, ttsLang0, ttsLang1) + os_log("[AST-INIT] subscriptionKey=%{public}@ region=%{public}@ wsUrl=%{public}@ appKey=%{public}@ accessKey=%{public}@ resourceId=%{public}@", + log: ctLog, type: .info, + String(subscriptionKeyI.prefix(4)) + "***", + regionI, + wsUrl, + String(appKey.prefix(4)) + "***", + String(accessKeyD.prefix(4)) + "***", + resourceId) + os_log("[AST-INIT] alibabaAppKey=%{public}@ alibabaAppId=%{public}@ alibabaAppURL=%{public}@", + log: ctLog, type: .info, + alibabaAppKey.isEmpty ? "(empty)" : String(alibabaAppKey.prefix(4)) + "***", + alibabaAppId, + alibabaAppURL) if currentAstProvider == "azure" { + os_log("[AST-INIT] Azure: configuring A(src=%{public}@ tgt=%{public}@) B(src=%{public}@ tgt=%{public}@)", + log: ctLog, type: .info, lang0, lang1, lang1, lang0) let svcA = IntegratedSpeechTranslationService.ServiceConfiguration( sourceLanguage: lang0, targetLanguage: lang1, @@ -866,18 +916,26 @@ private func sendAudioDataEvent(_ event: [String: Any]) { translationSourceLanguage: translationLang1, translationTargetLanguage: translationLang0 ) - let callbackA = AstEventCallback(plugin: self) - let callbackB = AstEventCallback(plugin: self) + let callbackA = AstEventCallback(plugin: self, serviceId: "A") + let callbackB = AstEventCallback(plugin: self, serviceId: "B") astEventCallback = callbackA let azureConfigI = AzureConfiguration(subscriptionKey: subscriptionKeyI, region: regionI) let translationConfigI = TranslationConfiguration(region: azureTranslationRegion, subscriptionKey: azureTranslationKey, location: azureTranslationRegion) Task { - await azureAstHelper.initialize(azureConfig: azureConfigI, translationConfig: translationConfigI, serviceConfig: svcA, callback: callbackA) + os_log("[AST-INIT] Azure: initializing A service...", log: self.ctLog, type: .info) + await azureAstHelperA.initialize(azureConfig: azureConfigI, translationConfig: translationConfigI, serviceConfig: svcA, callback: callbackA) + os_log("[AST-INIT] Azure: A initialized, waiting 1.2s before B...", log: self.ctLog, type: .info) + try? await Task.sleep(nanoseconds: 1_200_000_000) + os_log("[AST-INIT] Azure: initializing B service...", log: self.ctLog, type: .info) + await azureAstHelperB.initialize(azureConfig: azureConfigI, translationConfig: translationConfigI, serviceConfig: svcB, callback: callbackB) + os_log("[AST-INIT] Azure: A+B both initialized", log: self.ctLog, type: .info) } } else if currentAstProvider == "volcano" { // 初始化豆包 AST(双路) + os_log("[AST-INIT] Volcano: configuring A(%{public}@->%{public}@) B(%{public}@->%{public}@)", + log: ctLog, type: .info, translationLang0, translationLang1, translationLang1, translationLang0) let cfg = DoubaoE2ETranslateHelper.Config(wsUrl: wsUrl, appKey: appKey, accessKey: accessKeyD, resourceId: resourceId, sourceLanguage: translationLang0, targetLanguage: translationLang1, clientWaitMs: 60_000) astWsConfig = cfg let cbA = DoubaoCallbackProxy(plugin: self, serviceId: "A", direction: "\(translationLang0)->\(translationLang1)", targetLanguage: translationLang1) @@ -892,12 +950,18 @@ private func sendAudioDataEvent(_ event: [String: Any]) { cfgB.targetLanguage = translationLang0 Task { - _ = await doubaoAstHelperA.initialize(config: cfgA, cb: cbA) + os_log("[AST-INIT] Volcano: initializing A service...", log: self.ctLog, type: .info) + let rA = await doubaoAstHelperA.initialize(config: cfgA, cb: cbA) + os_log("[AST-INIT] Volcano: A service result=%d, sleeping 500ms before B...", log: self.ctLog, type: .info, rA ? 1 : 0) try? await Task.sleep(nanoseconds: 500_000_000) - _ = await doubaoAstHelperB.initialize(config: cfgB, cb: cbB) + os_log("[AST-INIT] Volcano: initializing B service...", log: self.ctLog, type: .info) + let rB = await doubaoAstHelperB.initialize(config: cfgB, cb: cbB) + os_log("[AST-INIT] Volcano: B service result=%d. Both services ready.", log: self.ctLog, type: .info, rB ? 1 : 0) } } else if currentAstProvider == "alibaba" { // 初始化阿里百炼(双路) + os_log("[AST-INIT] Alibaba: configuring A(%{public}@->%{public}@) B(%{public}@->%{public}@)", + log: ctLog, type: .info, translationLang0, translationLang1, translationLang1, translationLang0) var cfgA = AliyunBailianE2EHelper.Config() cfgA.wsUrl = alibabaAppURL cfgA.apiKey = alibabaAppKey @@ -922,8 +986,11 @@ private func sendAudioDataEvent(_ event: [String: Any]) { bailianCallbackB = cbB Task { - _ = await bailianAstHelperA.initialize(config: cfgA, cb: cbA) - _ = await bailianAstHelperB.initialize(config: cfgB, cb: cbB) + os_log("[AST-INIT] Alibaba: initializing A service...", log: self.ctLog, type: .info) + let rA = await bailianAstHelperA.initialize(config: cfgA, cb: cbA) + os_log("[AST-INIT] Alibaba: A service result=%d, initializing B service...", log: self.ctLog, type: .info, rA ? 1 : 0) + let rB = await bailianAstHelperB.initialize(config: cfgB, cb: cbB) + os_log("[AST-INIT] Alibaba: B service result=%d. Both services ready.", log: self.ctLog, type: .info, rB ? 1 : 0) } } result(true) @@ -935,7 +1002,18 @@ private func sendAudioDataEvent(_ event: [String: Any]) { /// 发送AST事件到Flutter层 internal func sendAstEvent(_ event: [String: Any]) { - if astEventSink == nil { return } + let eventType = event["type"] as? String ?? "unknown" + let sid = event["serviceId"] as? String ?? "?" + if astEventSink == nil { + os_log("[AST-EVENT] DROPPED (no sink): type=%{public}@ serviceId=%{public}@", + log: ctLog, type: .error, eventType, sid) + return + } + // 对于非高频事件(非 recognizing/translatedInterim),打印详细日志 + if eventType != "recognizing" && eventType != "translatedInterim" { + os_log("[AST-EVENT] sending: type=%{public}@ serviceId=%{public}@ direction=%{public}@", + log: ctLog, type: .info, eventType, sid, event["direction"] as? String ?? "N/A") + } DispatchQueue.main.async { [weak self] in self?.astEventSink?(event) } @@ -949,13 +1027,11 @@ private func sendAudioDataEvent(_ event: [String: Any]) { */ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventCallback { private weak var plugin: AzureSpeechPlugin? - - /** - * 构造函数 - * @param plugin AzureSpeechPlugin 实例的弱引用 - */ - init(plugin: AzureSpeechPlugin) { + private let serviceId: String + + init(plugin: AzureSpeechPlugin, serviceId: String = "A") { self.plugin = plugin + self.serviceId = serviceId } /** @@ -1135,23 +1211,15 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC */ public func onSynthesisAudioGenerated(text: String, audioData: Data) { guard let plugin = plugin else { return } - - os_log("语音合成音频生成,文本=%@,音频数据大小=%d", log: plugin.log, type: .info, text, audioData.count) - - // Azure TTS返回的是RIFF WAV格式,需要去除文件头提取PCM数据 + + // 只有 A 通道(己方翻译后的语音)推送到耳机,B 通道不推 + guard serviceId == "A" else { return } + let pcmData = plugin.extractPcmFromWav(audioData) if !pcmData.isEmpty { - os_log("提取PCM数据,大小=%d字节", log: plugin.log, type: .info, pcmData.count) + os_log("[AzureAST-%{public}@] TTS音频推送到耳机,大小=%d字节", log: plugin.ctLog, type: .info, serviceId, pcmData.count) BleService.shared.writeExternalAudioData(data: pcmData) - } else { - os_log("提取PCM数据失败,音频数据可能不是WAV格式", log: plugin.log, type: .error) } - -// plugin?.sendAsrEvent([ -// "type": "synthesisAudioGenerated", -// "text": text, -// "audioDataSize": audioData.count -// ]) } } @@ -1185,13 +1253,14 @@ extension AzureSpeechPlugin: BleService.Callback { azureAsrHelper.audioStream?.saveAudioDataTo(data: data) } else if channel == 2 { + bleAudioRecvCount += 1 if (isRecord) { recordfile?.setAudioConfig(sampleRate: 16000, channels: 2) recordfile?.saveAudioDataToWav(data) handleAudioData(data) } let sampleCount = data.count / 4 // 每个样本4字节(左右声道各2字节) - + guard sampleCount > 0 else { print("双声道数据样本数为0") return @@ -1219,23 +1288,53 @@ extension AzureSpeechPlugin: BleService.Callback { } } - // print("分离音频数据 - 左声道: \(leftBuffer.count) bytes, 右声道: \(rightBuffer.count) bytes") - - // 安全解包版本 - guard let audioStream = azureAsrHelper.audioStream else { - // os_log("音频流未初始化", type: .error) - return + // 每200帧打印一次声道音量(与 Android 对齐) + if bleAudioRecvCount % 200 == 1 { + var leftRms: Double = 0 + var rightRms: Double = 0 + for i in stride(from: 0, to: leftBuffer.count - 1, by: 2) { + let lSample = Int16(leftBuffer[i]) | (Int16(leftBuffer[i+1]) << 8) + let rSample = Int16(rightBuffer[i]) | (Int16(rightBuffer[i+1]) << 8) + leftRms += Double(lSample) * Double(lSample) + rightRms += Double(rSample) * Double(rSample) + } + let samples = max(leftBuffer.count / 2, 1) + leftRms = sqrt(leftRms / Double(samples)) + rightRms = sqrt(rightRms / Double(samples)) + os_log("声道音量: left(己方)=%d, right(对方)=%d, provider=%@, count=%lld", + log: ctLog, type: .info, Int(leftRms), Int(rightRms), currentAstProvider, bleAudioRecvCount) } - azureAsrHelper.audioStream?.saveAudioDataTo(data: rightBuffer) + // 保存右声道到 ASR audioStream(可能为 nil) + if let audioStream = azureAsrHelper.audioStream { + audioStream.saveAudioDataTo(data: rightBuffer) + } else if bleAudioRecvCount % 200 == 1 { + os_log("audioStream 未初始化,rightBuffer 未保存到ASR", log: ctLog, type: .default) + } // 根据当前 AST 提供商路由音频数据 + // 左声道 -> A(己方语音),右声道 -> B(对方语音) if currentAstProvider == "volcano" { doubaoAstHelperA.pushAudioData(leftBuffer) + doubaoAstHelperB.pushAudioData(rightBuffer) + if bleAudioRecvCount % 200 == 1 { + os_log("[STS] pushAudio: volcano A(left)=%d bytes, B(right)=%d bytes, count=%lld", + log: ctLog, type: .info, leftBuffer.count, rightBuffer.count, bleAudioRecvCount) + } } else if currentAstProvider == "alibaba" { bailianAstHelperA.pushAudioData(leftBuffer) + bailianAstHelperB.pushAudioData(rightBuffer) + if bleAudioRecvCount % 200 == 1 { + os_log("[STS] pushAudio: alibaba A(left)=%d bytes, B(right)=%d bytes, count=%lld", + log: ctLog, type: .info, leftBuffer.count, rightBuffer.count, bleAudioRecvCount) + } } else { - azureAstHelper.pushAudioData(audioData: leftBuffer) + azureAstHelperA.pushAudioData(audioData: leftBuffer) + azureAstHelperB.pushAudioData(audioData: rightBuffer) + if bleAudioRecvCount % 200 == 1 { + os_log("[STS] pushAudio: azure A(left)=%d bytes, B(right)=%d bytes, count=%lld", + log: ctLog, type: .info, leftBuffer.count, rightBuffer.count, bleAudioRecvCount) + } } } @@ -1328,70 +1427,95 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { private let serviceId: String private let direction: String private let targetLanguage: String + private let ctLog = OSLog(subsystem: "com.azure.speech", category: "CallTranslation") + + /// 从 direction(如 "zh-Hans->en-US")提取源语言("zh-Hans") + private var sourceLanguage: String { + if let range = direction.range(of: "->") { + return String(direction[direction.startIndex.. 0 { - if isRightChannel { - BleService.shared.writeExternalAudioData(data: data) - } else { - BleService.shared.writeExternalAudioData(data: data) - } + let isServiceA = (serviceId == "A") + if data.count > 0 && isServiceA { + os_log("[DoubaoCallback-%{public}@] onPartialAudio writing %d bytes to BLE", + log: ctLog, type: .debug, serviceId, data.count) + BleService.shared.writeExternalAudioData(data: data) } } func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) { let key = "\(serviceId):\(sessionId)" let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? "" + os_log("[DoubaoCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d", + log: ctLog, type: .info, serviceId, finalText, original, finalAudio.count) plugin?.sendAstEvent([ "type": "translated", "serviceId": serviceId, + "direction": direction, "translatedText": finalText, "targetLanguage": targetLanguage, "utteranceId": sessionId, @@ -1400,6 +1524,8 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { } func onSessionError(sessionId: String, code: Int, message: String) { + os_log("[DoubaoCallback-%{public}@] onSessionError code=%d msg=%{public}@", + log: ctLog, type: .error, serviceId, code, message) plugin?.sendAstEvent([ "type": "error", "serviceId": serviceId, @@ -1413,9 +1539,12 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { func onFinalTranslatedText(sessionId: String, finalText: String) { let key = "\(serviceId):\(sessionId)" let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? "" + os_log("[DoubaoCallback-%{public}@] onFinalTranslatedText finalText=%{public}@ original=%{public}@", + log: ctLog, type: .info, serviceId, finalText, original) plugin?.sendAstEvent([ "type": "translated", "serviceId": serviceId, + "direction": direction, "translatedText": finalText, "targetLanguage": targetLanguage, "utteranceId": sessionId, @@ -1430,74 +1559,106 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback { private let serviceId: String private let direction: String private let targetLanguage: String + private let ctLog = OSLog(subsystem: "com.azure.speech", category: "CallTranslation") + private var sourceTextCache: [String: String] = [:] + + /// 从 direction(如 "zh-Hans->en-US")提取源语言("zh-Hans") + private var sourceLanguage: String { + if let range = direction.range(of: "->") { + return String(direction[direction.startIndex.. 0 { - if isRightChannel { - BleService.shared.writeExternalAudioData(data: data) - } else { - BleService.shared.writeExternalAudioData(data: data) - } + let isServiceA = (serviceId == "A") + if data.count > 0 && isServiceA { + os_log("[AliyunCallback-%{public}@] onPartialAudio writing %d bytes to BLE", + log: ctLog, type: .debug, serviceId, data.count) + BleService.shared.writeExternalAudioData(data: data) } } func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) { + let key = "\(serviceId):\(sessionId)" + let original = sourceTextCache.removeValue(forKey: key) ?? "" + os_log("[AliyunCallback-%{public}@] onSessionFinished finalText=%{public}@ original=%{public}@ audioLen=%d", + log: ctLog, type: .info, serviceId, finalText, original, finalAudio.count) plugin?.sendAstEvent([ "type": "translated", "serviceId": serviceId, + "direction": direction, "translatedText": finalText, "targetLanguage": targetLanguage, - "utteranceId": sessionId + "utteranceId": sessionId, + "originalText": original ]) } func onSessionError(sessionId: String, code: Int, message: String) { + os_log("[AliyunCallback-%{public}@] onSessionError code=%d msg=%{public}@", + log: ctLog, type: .error, serviceId, code, message) plugin?.sendAstEvent([ "type": "error", "serviceId": serviceId, @@ -1509,12 +1670,18 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback { } func onFinalTranslatedText(sessionId: String, finalText: String) { + let key = "\(serviceId):\(sessionId)" + let original = sourceTextCache.removeValue(forKey: key) ?? "" + os_log("[AliyunCallback-%{public}@] onFinalTranslatedText finalText=%{public}@ original=%{public}@", + log: ctLog, type: .info, serviceId, finalText, original) plugin?.sendAstEvent([ "type": "translated", "serviceId": serviceId, + "direction": direction, "translatedText": finalText, "targetLanguage": targetLanguage, - "utteranceId": sessionId + "utteranceId": sessionId, + "originalText": original ]) } } @@ -1660,8 +1827,11 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { * @param errorDetails 错误详情 */ func onCanceled(sessionid:String,_ reason: String, _ errorDetails: String) { + if let p = plugin { + os_log("ASR onCanceled: sessionid=%@, reason=%@, error=%@", log: p.ctLog, type: .error, sessionid, reason, errorDetails) + } plugin?.sendAsrEvent([ - "type": "canceled", + "type": "canceled", "sessionid": sessionid, "provider": plugin?.currentAsrProvider() ?? "", "reason": reason, diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift index 7c7e3025c..db8323493 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift @@ -92,10 +92,13 @@ public class BleService: NSObject { private var opusProcessor: SwiftOpusAudioProcessor? // MARK: - 新增命令队列相关属性 private var commandQueue = [Data]() - //是否回复 + //是否回复(与 Android isWaitingReply 对应,true=可发送,false=等待回复) private var isReply = true // 是否回复指令码 -private var cmdReplyType: UInt8 = 0 + private var cmdReplyType: UInt8 = 0 + /// 回复超时计时器(与 Android REPLY_TIMEOUT_MS = 1000 对齐) + private var replyTimeoutTimer: Timer? + private let REPLY_TIMEOUT_MS: TimeInterval = 1.0 // 新增:音频配置参数 private var sampleRate: Int32 = 16000 @@ -482,7 +485,13 @@ private var cmdReplyType: UInt8 = 0 audioBuffer.removeAll() audioDataQueue.removeAll() audioQueueLock.unlock() - + + // 重置命令队列(与 Android reset 对齐) + replyTimeoutTimer?.invalidate() + replyTimeoutTimer = nil + commandQueue.removeAll() + isReply = true + updateConnectionState(BleConst.STATE_DISCONNECTING) centralManager?.cancelPeripheralConnection(peripheral) os_log("🔌 开始断开设备连接", log: logger, type: .info) @@ -706,7 +715,7 @@ private var cmdReplyType: UInt8 = 0 if opusProcessor?.isDecoding() != true { startOpusDecoding() } - let paramData = Data([BleConst.CODEC_CONTROL_ENCODE_ON, BleConst.AUDIO_CHANNEL_RIGHT]) + let paramData = Data([BleConst.CODEC_CONTROL_ENCODE_ON, BleConst.AUDIO_CHANNEL_STEREO]) let result = sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) if result { @@ -728,7 +737,7 @@ private var cmdReplyType: UInt8 = 0 //} // 发送命令通知设备开始解码 - let paramData = Data([BleConst.CODEC_CONTROL_DECODE_ON, BleConst.AUDIO_CHANNEL_RIGHT]) + let paramData = Data([BleConst.CODEC_CONTROL_DECODE_ON, BleConst.AUDIO_CHANNEL_STEREO]) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) } /// 获取与Flutter getExternalStorageDirectories()一致的存储目录 @@ -747,13 +756,14 @@ private var cmdReplyType: UInt8 = 0 os_log("打开A2DP解码并开始录制...", log: logger, type: .info) - let paramData = Data([BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_RIGHT]) + let paramData = Data([BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_STEREO]) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) } /// 打开通话记录解码器并开始录制 func openCallRecordDecoder() -> Bool { os_log("打开通话记录解码并开始录制...", log: logger, type: .info) - let paramData = Data([BleConst.CODEC_CONTROL_CALL_RECORD_PLAY, BleConst.AUDIO_CHANNEL_RIGHT]) + startOpusDecoding(sampleRate: 16000, channels: 2, frameSize: 1280) + let paramData = Data([BleConst.CODEC_CONTROL_CALL_RECORD_PLAY, BleConst.AUDIO_CHANNEL_STEREO]) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) } @@ -777,14 +787,15 @@ private var cmdReplyType: UInt8 = 0 requestStopOpusRecording() // 不停止Opus解码流,只发送命令通知设备关闭编解码 - let paramData = Data([BleConst.CODEC_CONTROL_CLOSE, BleConst.AUDIO_CHANNEL_RIGHT]) + let paramData = Data([BleConst.CODEC_CONTROL_CLOSE, BleConst.AUDIO_CHANNEL_STEREO]) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) } /// 播放A2DP func playA2DP() -> Bool { - os_log("切换至A2DP播放模式...", log: logger, type: .info) - return sendCommand(BleConst.CMD_PLAY_A2DP) + // 与 Android 保持一致:不发送命令,直接返回 true + // Android 已注释掉此命令,且 0x08 在 Android 端定义为 CMD_GET_WAKEUP_VERSION + return true } /// 音量增大 @@ -815,7 +826,7 @@ private var cmdReplyType: UInt8 = 0 } os_log("设置%@模式...", log: logger, type: .info, modeDesc) - let paramData = Data([0xA1, channelMode]) + let paramData = Data([BleConst.CODEC_CONTROL_ENCODE_ON, channelMode]) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) } @@ -881,24 +892,57 @@ private var cmdReplyType: UInt8 = 0 - // 加入队列并触发发送 - - if isReply{ + // 与 Android 对齐:isReply=true 时直接发送,否则入队列 + if isReply { peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse) - isReply = false; - return true - + isReply = false + startReplyTimeout() + return true } else { commandQueue.append(cmdPacket) + os_log("命令已加入队列,当前队列长度: %d", log: logger, type: .debug, commandQueue.count) + return true } - // 发送数据(使用无响应写入,与Android端保持一致) - - return true } catch { os_log("liwei---- 发送命令异常: %@", log: logger, type: .error, error.localizedDescription) return false } } + + /// 处理下一条队列命令(与 Android processNextCommand 对齐) + private func processNextCommand() { + guard !commandQueue.isEmpty else { return } + guard let characteristic = writeCharacteristic, + connectionState == BleConst.STATE_CONNECTED else { return } + + let nextCmd = commandQueue.removeFirst() + peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse) + isReply = false // 与 Android 一致:发送后置为 false,等待回复 + startReplyTimeout() + os_log("发送队列中的下一个命令,剩余队列长度: %d", log: logger, type: .debug, commandQueue.count) + } + + /// 启动回复超时计时器(与 Android REPLY_TIMEOUT_MS=1000 对齐) + private func startReplyTimeout() { + replyTimeoutTimer?.invalidate() + replyTimeoutTimer = Timer.scheduledTimer(withTimeInterval: REPLY_TIMEOUT_MS, repeats: false) { [weak self] _ in + guard let self = self else { return } + if !self.isReply { + os_log("命令响应超时,自动重置 isReply 为 true", log: self.logger, type: .default) + self.isReply = true + self.processNextCommand() + } + } + } + + /// 命令回复处理(与 Android onCommandResponse 对齐) + private func onCommandResponse(_ cmdType: UInt8) { + isReply = true + cmdReplyType = cmdType + replyTimeoutTimer?.invalidate() + replyTimeoutTimer = nil + processNextCommand() + } /// 计算CRC8/MAXIM校验值(与Android端保持一致) private func calculateCRC8(data: Data) -> UInt8 { @@ -1024,7 +1068,8 @@ private var cmdReplyType: UInt8 = 0 var hexString = data.map { String(format: "0x%02X", $0) }.joined(separator: ", ") // os_log("liwei---- 收到设备响应: %@", log: logger, type: .debug, hexString) - // 检查数据长度(与 Android 统一失败判定保持一致) + // 统一失败判定:长度字段与实际长度不匹配时,跳过 CRC 直接上报 + // (设备的短帧响应 CRC 可能基于不同帧格式计算,强校验会丢弃有效数据) if data.count != Int(length) + 4 { // 帧头 + 命令 + 长度 + 数据 + CRC os_log("设备响应数据长度不匹配: 期望=%d, 实际=%d", log: logger, type: .error, Int(length) + 4, data.count) @@ -1038,27 +1083,21 @@ private var cmdReplyType: UInt8 = 0 let resultMap: [String: Any] = [ "success": true, // 与 Android 对齐(Dart 层按 rawData 判断) "command": Int(cmd), - "rawData": Data(raw) + "rawData": raw // [UInt8] 数组,确保 Dart 端收到 List ] - // 推进命令队列(与后续正常路径一致) - isReply = true - cmdReplyType = cmd - if !commandQueue.isEmpty { - let nextCmd = commandQueue.removeFirst() - peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse) - } + // 与 Android 对齐:通过 onCommandResponse 推进队列 + onCommandResponse(cmd) // 上报到 Dart 层 notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) return } - // 计算CRC校验 - 不包括最后一个字节(CRC) + // 长度匹配的正常帧:进行 CRC 校验 let payload = data.prefix(data.count - 1) let calculatedCrc = calculateCRC8(data: payload) let receivedCrc = data[data.count - 1] - if calculatedCrc != receivedCrc { os_log("设备响应CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc) return @@ -1066,12 +1105,8 @@ private var cmdReplyType: UInt8 = 0 // 提取响应数据 let responseData = length > 0 ? data.subdata(in: 3..<(3 + Int(length))) : Data() - isReply = true - cmdReplyType = cmd - if !commandQueue.isEmpty { - let nextCmd = commandQueue.removeFirst() - peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse) - } + // 与 Android 对齐:通过 onCommandResponse 推进队列 + onCommandResponse(cmd) // 根据命令类型处理设备响应 switch cmd { @@ -1163,10 +1198,12 @@ private var cmdReplyType: UInt8 = 0 notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) case BleConst.CMD_CONTROL_CODEC: + // 与 Android 对齐:rawData 使用 [UInt8] 数组,确保 Dart 端收到 List + let rawDataArray = [UInt8](responseData) var resultMap: [String: Any] = [ "success": true, "command": Int(cmd), - "rawData": responseData + "rawData": rawDataArray ] if !responseData.isEmpty && responseData.count >= 2 {