Browse Source

上传代码

newdev_shunjiawei
liwei1dao 6 months ago
parent
commit
d5a5d903e2
  1. 44
      local_plugins/azure_speech/android/app/build.gradle.kts
  2. 7
      local_plugins/azure_speech/android/app/src/debug/AndroidManifest.xml
  3. 45
      local_plugins/azure_speech/android/app/src/main/AndroidManifest.xml
  4. 5
      local_plugins/azure_speech/android/app/src/main/kotlin/com/example/azure_speech/MainActivity.kt
  5. 12
      local_plugins/azure_speech/android/app/src/main/res/drawable-v21/launch_background.xml
  6. 12
      local_plugins/azure_speech/android/app/src/main/res/drawable/launch_background.xml
  7. BIN
      local_plugins/azure_speech/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  8. BIN
      local_plugins/azure_speech/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  9. BIN
      local_plugins/azure_speech/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  10. BIN
      local_plugins/azure_speech/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  11. BIN
      local_plugins/azure_speech/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  12. 18
      local_plugins/azure_speech/android/app/src/main/res/values-night/styles.xml
  13. 18
      local_plugins/azure_speech/android/app/src/main/res/values/styles.xml
  14. 7
      local_plugins/azure_speech/android/app/src/profile/AndroidManifest.xml
  15. 18
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  16. 8
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  17. 314
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  18. 105
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift

44
local_plugins/azure_speech/android/app/build.gradle.kts

@ -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 = "../.."
}

7
local_plugins/azure_speech/android/app/src/debug/AndroidManifest.xml

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

45
local_plugins/azure_speech/android/app/src/main/AndroidManifest.xml

@ -1,45 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="azure_speech"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

5
local_plugins/azure_speech/android/app/src/main/kotlin/com/example/azure_speech/MainActivity.kt

@ -1,5 +0,0 @@
package com.example.azure_speech
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

12
local_plugins/azure_speech/android/app/src/main/res/drawable-v21/launch_background.xml

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

12
local_plugins/azure_speech/android/app/src/main/res/drawable/launch_background.xml

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

BIN
local_plugins/azure_speech/android/app/src/main/res/mipmap-hdpi/ic_launcher.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

BIN
local_plugins/azure_speech/android/app/src/main/res/mipmap-mdpi/ic_launcher.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

BIN
local_plugins/azure_speech/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

BIN
local_plugins/azure_speech/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

BIN
local_plugins/azure_speech/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

18
local_plugins/azure_speech/android/app/src/main/res/values-night/styles.xml

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

18
local_plugins/azure_speech/android/app/src/main/res/values/styles.xml

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

7
local_plugins/azure_speech/android/app/src/profile/AndroidManifest.xml

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

18
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<Boolean>("audioSourceType") ?: false val useExternalAudio = call.argument<Boolean>("audioSourceType") ?: false
val removeFirstPunctuation = val removeFirstPunctuation =
call.argument<Boolean>("isRemoveFirstPunctuation") ?: true call.argument<Boolean>("isRemoveFirstPunctuation") ?: true
// 通话翻译模式(external):AST端到端服务已处理识别+翻译+TTS,无需启动ASR
if (useExternalAudio) {
FileLogger.d(tag, "通话模式,AST端到端服务已激活(provider=$currentAstProvider),跳过ASR")
result.success(true)
return
}
// 确保事件通道已准备好 // 确保事件通道已准备好
if (asrEventSink == null) { if (asrEventSink == null) {
result.error( result.error(
@ -668,15 +676,9 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
} }
try { try {
FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}")
// 选择音频源类型 val audioSourceType = AzureAsrHelper.AudioSourceType.MICROPHONE
val audioSourceType = if (useExternalAudio) {
AzureAsrHelper.AudioSourceType.EXTERNAL
} else {
AzureAsrHelper.AudioSourceType.MICROPHONE
}
val success = azureAsrHelper.startContinuousRecognition(audioSourceType) val success = azureAsrHelper.startContinuousRecognition(audioSourceType)
// val success = azureAsrHelper.startContinuousRecognition(audioSourceType, isRemoveFirstPunctuation = removeFirstPunctuation)
result.success(true) result.success(true)
} catch (e: Exception) { } catch (e: Exception) {
result.error("START_RECOGNITION_ERROR", e.message, null) result.error("START_RECOGNITION_ERROR", e.message, null)

8
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift

@ -86,6 +86,14 @@ public class AzureAsrHelper: NSObject {
return useXunfei ? "xunfei" : "azure" return useXunfei ? "xunfei" : "azure"
} }
/// 通话模式下强制禁用讯飞,使用微软 ASR(与 Android 对齐)
public func forceDisableXunfei() {
if useXunfei {
os_log("forceDisableXunfei: 通话模式强制切换到微软ASR", log: log, type: .info)
useXunfei = false
}
}
/** /**
* 初始化Azure语音服务 * 初始化Azure语音服务
* *

314
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -19,6 +19,8 @@ import ble_service
private let tag = "AzureSpeechPlugin" private let tag = "AzureSpeechPlugin"
// 日志对象 // 日志对象
internal let log = OSLog(subsystem: "com.azure.speech", category: "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 原生层) // MARK: - 防抖相关(iOS 原生层)
// 函数级注释:用于限制 startContinuousRecognition/stopContinuousRecognition 在极短时间内的重复触发 // 函数级注释:用于限制 startContinuousRecognition/stopContinuousRecognition 在极短时间内的重复触发
@ -91,7 +93,9 @@ import ble_service
private var astChannel: FlutterMethodChannel? private var astChannel: FlutterMethodChannel?
private var astEventChannel: FlutterEventChannel? private var astEventChannel: FlutterEventChannel?
internal var astEventSink: FlutterEventSink? internal var astEventSink: FlutterEventSink?
private let azureAstHelper = IntegratedSpeechTranslationService() // 双向 AST 服务:A(己方)和 B(对方),与 Android 对齐
private let azureAstHelperA = IntegratedSpeechTranslationService()
private let azureAstHelperB = IntegratedSpeechTranslationService()
// 端到端翻译 Helpers(豆包 / 阿里百炼) // 端到端翻译 Helpers(豆包 / 阿里百炼)
private var doubaoAstHelperA = DoubaoE2ETranslateHelper() private var doubaoAstHelperA = DoubaoE2ETranslateHelper()
@ -110,6 +114,9 @@ import ble_service
private var audioDataEventChannel: FlutterEventChannel? private var audioDataEventChannel: FlutterEventChannel?
internal var audioDataEventSink: FlutterEventSink? internal var audioDataEventSink: FlutterEventSink?
// 通话翻译音频统计(日志用)
private var bleAudioRecvCount: Int64 = 0
// 录音相关 // 录音相关
public var recordfile: RecordFile? public var recordfile: RecordFile?
private var isRecord = false private var isRecord = false
@ -414,15 +421,22 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
AzureAsrHelper.AudioSourceType.external : AzureAsrHelper.AudioSourceType.external :
AzureAsrHelper.AudioSourceType.microphone AzureAsrHelper.AudioSourceType.microphone
print("startContinuousRecognition: \(audioSourceType)") 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( let success = azureAsrHelper.startContinuousRecognition(
mod: mode, mod: mode,
audioSourceType: audioSourceType, audioSourceType: audioSourceType,
isRemoveFirstPunctuation: removeFirstPunctuation isRemoveFirstPunctuation: removeFirstPunctuation
) )
result(success) os_log("startContinuousRecognition 结果=%d", log: ctLog, type: .info, success ? 1 : 0)
result(true)
}
case "recognizeCallback": case "recognizeCallback":
// 确保事件通道已准备好 // 确保事件通道已准备好
guard asrEventSink != nil else { guard asrEventSink != nil else {
@ -452,10 +466,16 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
return return
} }
print("stopContinuousRecognition") // 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() let success = azureAsrHelper.stopContinuousRecognition()
currentAsrCallback = nil currentAsrCallback = nil
result(success) result(success)
}
case "isContinuousRecognitionActive": case "isContinuousRecognitionActive":
result(azureAsrHelper.isContinuousRecognitionActive()) result(azureAsrHelper.isContinuousRecognitionActive())
@ -757,50 +777,64 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
// } // }
case "startContinuousTranslation": case "startContinuousTranslation":
os_log("[AST-START] provider=%{public}@", log: ctLog, type: .info, currentAstProvider)
if currentAstProvider == "volcano" { if currentAstProvider == "volcano" {
_ = doubaoAstHelperA.startContinuousTranslation() let rA = doubaoAstHelperA.startContinuousTranslation()
_ = doubaoAstHelperB.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) result(true)
} else if currentAstProvider == "alibaba" { } else if currentAstProvider == "alibaba" {
_ = bailianAstHelperA.startContinuousConversation() let rA = bailianAstHelperA.startContinuousConversation()
_ = bailianAstHelperB.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) result(true)
} else { } else {
do { do {
os_log("开启翻译", log: log, type: .info) os_log("[AST-START] azure: starting A+B", log: ctLog, type: .info)
try azureAstHelper.startContinuousTranslation() try azureAstHelperA.startContinuousTranslation()
try azureAstHelperB.startContinuousTranslation()
os_log("[AST-START] azure: A+B started successfully", log: ctLog, type: .info)
result(true) result(true)
} catch { } 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)) result(FlutterError(code: "START_CONTINUOUS_TRANSLATION_ERROR", message: error.localizedDescription, details: nil))
} }
} }
case "stopContinuousTranslation": case "stopContinuousTranslation":
os_log("[AST-STOP] provider=%{public}@", log: ctLog, type: .info, currentAstProvider)
if currentAstProvider == "volcano" { if currentAstProvider == "volcano" {
_ = doubaoAstHelperA.stopContinuousTranslation() let rA = doubaoAstHelperA.stopContinuousTranslation()
_ = doubaoAstHelperB.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) result(true)
} else if currentAstProvider == "alibaba" { } else if currentAstProvider == "alibaba" {
_ = bailianAstHelperA.stopContinuousConversation() let rA = bailianAstHelperA.stopContinuousConversation()
_ = bailianAstHelperB.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) result(true)
} else { } else {
do { do {
os_log("停止翻译", log: log, type: .info) os_log("[AST-STOP] azure: stopping A+B", log: ctLog, type: .info)
try azureAstHelper.stopContinuousTranslation() try azureAstHelperA.stopContinuousTranslation()
try azureAstHelperB.stopContinuousTranslation()
os_log("[AST-STOP] azure: A+B stopped successfully", log: ctLog, type: .info)
result(true) result(true)
} catch { } 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)) result(FlutterError(code: "STOP_CONTINUOUS_TRANSLATION_ERROR", message: error.localizedDescription, details: nil))
} }
} }
case "dispose": case "dispose":
os_log("释放AST资源", log: log, type: .info) os_log("[AST-DISPOSE] Releasing all AST resources, provider=%{public}@", log: ctLog, type: .info, currentAstProvider)
azureAstHelper.dispose() azureAstHelperA.dispose()
azureAstHelperB.dispose()
doubaoAstHelperA.dispose() doubaoAstHelperA.dispose()
doubaoAstHelperB.dispose() doubaoAstHelperB.dispose()
bailianAstHelperA.dispose() bailianAstHelperA.dispose()
bailianAstHelperB.dispose() bailianAstHelperB.dispose()
os_log("[AST-DISPOSE] All AST resources released", log: ctLog, type: .info)
result(true) result(true)
case "recognizeCallback": case "recognizeCallback":
@ -849,9 +883,25 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
let alibabaAppId = (args["alibabaAppId"] as? String) ?? "" let alibabaAppId = (args["alibabaAppId"] as? String) ?? ""
let alibabaAppURL = (args["alibabaAppURL"] 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" { 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( let svcA = IntegratedSpeechTranslationService.ServiceConfiguration(
sourceLanguage: lang0, sourceLanguage: lang0,
targetLanguage: lang1, targetLanguage: lang1,
@ -866,18 +916,26 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
translationSourceLanguage: translationLang1, translationSourceLanguage: translationLang1,
translationTargetLanguage: translationLang0 translationTargetLanguage: translationLang0
) )
let callbackA = AstEventCallback(plugin: self) let callbackA = AstEventCallback(plugin: self, serviceId: "A")
let callbackB = AstEventCallback(plugin: self) let callbackB = AstEventCallback(plugin: self, serviceId: "B")
astEventCallback = callbackA astEventCallback = callbackA
let azureConfigI = AzureConfiguration(subscriptionKey: subscriptionKeyI, region: regionI) let azureConfigI = AzureConfiguration(subscriptionKey: subscriptionKeyI, region: regionI)
let translationConfigI = TranslationConfiguration(region: azureTranslationRegion, subscriptionKey: azureTranslationKey, location: azureTranslationRegion) let translationConfigI = TranslationConfiguration(region: azureTranslationRegion, subscriptionKey: azureTranslationKey, location: azureTranslationRegion)
Task { 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" { } else if currentAstProvider == "volcano" {
// 初始化豆包 AST(双路) // 初始化豆包 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) let cfg = DoubaoE2ETranslateHelper.Config(wsUrl: wsUrl, appKey: appKey, accessKey: accessKeyD, resourceId: resourceId, sourceLanguage: translationLang0, targetLanguage: translationLang1, clientWaitMs: 60_000)
astWsConfig = cfg astWsConfig = cfg
let cbA = DoubaoCallbackProxy(plugin: self, serviceId: "A", direction: "\(translationLang0)->\(translationLang1)", targetLanguage: translationLang1) 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 cfgB.targetLanguage = translationLang0
Task { 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) 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" { } 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() var cfgA = AliyunBailianE2EHelper.Config()
cfgA.wsUrl = alibabaAppURL cfgA.wsUrl = alibabaAppURL
cfgA.apiKey = alibabaAppKey cfgA.apiKey = alibabaAppKey
@ -922,8 +986,11 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
bailianCallbackB = cbB bailianCallbackB = cbB
Task { Task {
_ = await bailianAstHelperA.initialize(config: cfgA, cb: cbA) os_log("[AST-INIT] Alibaba: initializing A service...", log: self.ctLog, type: .info)
_ = await bailianAstHelperB.initialize(config: cfgB, cb: cbB) 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) result(true)
@ -935,7 +1002,18 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
/// 发送AST事件到Flutter层 /// 发送AST事件到Flutter层
internal func sendAstEvent(_ event: [String: Any]) { 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 DispatchQueue.main.async { [weak self] in
self?.astEventSink?(event) self?.astEventSink?(event)
} }
@ -949,13 +1027,11 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
*/ */
private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventCallback { private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventCallback {
private weak var plugin: AzureSpeechPlugin? private weak var plugin: AzureSpeechPlugin?
private let serviceId: String
/** init(plugin: AzureSpeechPlugin, serviceId: String = "A") {
* 构造函数
* @param plugin AzureSpeechPlugin 实例的弱引用
*/
init(plugin: AzureSpeechPlugin) {
self.plugin = plugin self.plugin = plugin
self.serviceId = serviceId
} }
/** /**
@ -1136,22 +1212,14 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC
public func onSynthesisAudioGenerated(text: String, audioData: Data) { public func onSynthesisAudioGenerated(text: String, audioData: Data) {
guard let plugin = plugin else { return } guard let plugin = plugin else { return }
os_log("语音合成音频生成,文本=%@,音频数据大小=%d", log: plugin.log, type: .info, text, audioData.count) // 只有 A 通道(己方翻译后的语音)推送到耳机,B 通道不推
guard serviceId == "A" else { return }
// Azure TTS返回的是RIFF WAV格式,需要去除文件头提取PCM数据
let pcmData = plugin.extractPcmFromWav(audioData) let pcmData = plugin.extractPcmFromWav(audioData)
if !pcmData.isEmpty { 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) 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,6 +1253,7 @@ extension AzureSpeechPlugin: BleService.Callback {
azureAsrHelper.audioStream?.saveAudioDataTo(data: data) azureAsrHelper.audioStream?.saveAudioDataTo(data: data)
} }
else if channel == 2 { else if channel == 2 {
bleAudioRecvCount += 1
if (isRecord) { if (isRecord) {
recordfile?.setAudioConfig(sampleRate: 16000, channels: 2) recordfile?.setAudioConfig(sampleRate: 16000, channels: 2)
recordfile?.saveAudioDataToWav(data) recordfile?.saveAudioDataToWav(data)
@ -1219,23 +1288,53 @@ extension AzureSpeechPlugin: BleService.Callback {
} }
} }
// print("分离音频数据 - 左声道: \(leftBuffer.count) bytes, 右声道: \(rightBuffer.count) bytes") // 每200帧打印一次声道音量(与 Android 对齐)
if bleAudioRecvCount % 200 == 1 {
// 安全解包版本 var leftRms: Double = 0
guard let audioStream = azureAsrHelper.audioStream else { var rightRms: Double = 0
// os_log("音频流未初始化", type: .error) for i in stride(from: 0, to: leftBuffer.count - 1, by: 2) {
return 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 提供商路由音频数据 // 根据当前 AST 提供商路由音频数据
// 左声道 -> A(己方语音),右声道 -> B(对方语音)
if currentAstProvider == "volcano" { if currentAstProvider == "volcano" {
doubaoAstHelperA.pushAudioData(leftBuffer) 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" { } else if currentAstProvider == "alibaba" {
bailianAstHelperA.pushAudioData(leftBuffer) 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 { } 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 serviceId: String
private let direction: String private let direction: String
private let targetLanguage: 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..<range.lowerBound])
}
return direction
}
init(plugin: AzureSpeechPlugin, serviceId: String, direction: String, targetLanguage: String) { init(plugin: AzureSpeechPlugin, serviceId: String, direction: String, targetLanguage: String) {
self.plugin = plugin self.plugin = plugin
self.serviceId = serviceId self.serviceId = serviceId
self.direction = direction self.direction = direction
self.targetLanguage = targetLanguage self.targetLanguage = targetLanguage
os_log("[DoubaoCallback-%{public}@] created direction=%{public}@ targetLang=%{public}@",
log: ctLog, type: .info, serviceId, direction, targetLanguage)
} }
func onSessionStarted(sessionId: String) { func onSessionStarted(sessionId: String) {
os_log("[DoubaoCallback-%{public}@] onSessionStarted sessionId=%{public}@",
log: ctLog, type: .info, serviceId, sessionId)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "serviceInitialized", "type": "serviceInitialized",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction "direction": direction,
"sessionId": sessionId
]) ])
} }
func onPartialSourceText(sessionId: String, text: String) { func onPartialSourceText(sessionId: String, text: String) {
os_log("[DoubaoCallback-%{public}@] onPartialSourceText text=%{public}@",
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "recognizing", "type": "recognizing",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"text": text, "text": text,
"language": direction, "language": sourceLanguage,
"utteranceId": sessionId "utteranceId": sessionId
]) ])
} }
func onFinalSourceText(sessionId: String, finalText: String) { func onFinalSourceText(sessionId: String, finalText: String) {
os_log("[DoubaoCallback-%{public}@] onFinalSourceText text=%{public}@",
log: ctLog, type: .info, serviceId, finalText)
if let p = plugin { p.doubaoFinalSourceTextCache["\(serviceId):\(sessionId)"] = finalText } if let p = plugin { p.doubaoFinalSourceTextCache["\(serviceId):\(sessionId)"] = finalText }
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "recognized", "type": "recognized",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"text": finalText, "text": finalText,
"language": direction, "language": sourceLanguage,
"utteranceId": sessionId "utteranceId": sessionId
]) ])
} }
func onPartialText(sessionId: String, text: String) { func onPartialText(sessionId: String, text: String) {
os_log("[DoubaoCallback-%{public}@] onPartialText text=%{public}@",
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "translatedInterim", "type": "translatedInterim",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"translatedText": text, "translatedText": text,
"originalText": "",
"targetLanguage": targetLanguage, "targetLanguage": targetLanguage,
"utteranceId": sessionId "utteranceId": sessionId
]) ])
} }
func onPartialAudio(sessionId: String, data: Data) { func onPartialAudio(sessionId: String, data: Data) {
let isRightChannel = (serviceId == "A") let isServiceA = (serviceId == "A")
if data.count > 0 { if data.count > 0 && isServiceA {
if isRightChannel { os_log("[DoubaoCallback-%{public}@] onPartialAudio writing %d bytes to BLE",
BleService.shared.writeExternalAudioData(data: data) log: ctLog, type: .debug, serviceId, data.count)
} else {
BleService.shared.writeExternalAudioData(data: data) BleService.shared.writeExternalAudioData(data: data)
} }
} }
}
func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) { func onSessionFinished(sessionId: String, finalText: String, finalAudio: Data) {
let key = "\(serviceId):\(sessionId)" let key = "\(serviceId):\(sessionId)"
let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? "" 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([ plugin?.sendAstEvent([
"type": "translated", "type": "translated",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"translatedText": finalText, "translatedText": finalText,
"targetLanguage": targetLanguage, "targetLanguage": targetLanguage,
"utteranceId": sessionId, "utteranceId": sessionId,
@ -1400,6 +1524,8 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback {
} }
func onSessionError(sessionId: String, code: Int, message: String) { 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([ plugin?.sendAstEvent([
"type": "error", "type": "error",
"serviceId": serviceId, "serviceId": serviceId,
@ -1413,9 +1539,12 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback {
func onFinalTranslatedText(sessionId: String, finalText: String) { func onFinalTranslatedText(sessionId: String, finalText: String) {
let key = "\(serviceId):\(sessionId)" let key = "\(serviceId):\(sessionId)"
let original = plugin?.doubaoFinalSourceTextCache.removeValue(forKey: key) ?? "" 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([ plugin?.sendAstEvent([
"type": "translated", "type": "translated",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"translatedText": finalText, "translatedText": finalText,
"targetLanguage": targetLanguage, "targetLanguage": targetLanguage,
"utteranceId": sessionId, "utteranceId": sessionId,
@ -1430,74 +1559,106 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback {
private let serviceId: String private let serviceId: String
private let direction: String private let direction: String
private let targetLanguage: 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..<range.lowerBound])
}
return direction
}
init(plugin: AzureSpeechPlugin, serviceId: String, direction: String, targetLanguage: String) { init(plugin: AzureSpeechPlugin, serviceId: String, direction: String, targetLanguage: String) {
self.plugin = plugin self.plugin = plugin
self.serviceId = serviceId self.serviceId = serviceId
self.direction = direction self.direction = direction
self.targetLanguage = targetLanguage self.targetLanguage = targetLanguage
os_log("[AliyunCallback-%{public}@] created direction=%{public}@ targetLang=%{public}@",
log: ctLog, type: .info, serviceId, direction, targetLanguage)
} }
func onSessionStarted(sessionId: String) { func onSessionStarted(sessionId: String) {
os_log("[AliyunCallback-%{public}@] onSessionStarted sessionId=%{public}@",
log: ctLog, type: .info, serviceId, sessionId)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "serviceInitialized", "type": "serviceInitialized",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction "direction": direction,
"sessionId": sessionId
]) ])
} }
func onPartialSourceText(sessionId: String, text: String) { func onPartialSourceText(sessionId: String, text: String) {
os_log("[AliyunCallback-%{public}@] onPartialSourceText text=%{public}@",
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "recognizing", "type": "recognizing",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"text": text, "text": text,
"language": direction, "language": sourceLanguage,
"utteranceId": sessionId "utteranceId": sessionId
]) ])
} }
func onFinalSourceText(sessionId: String, finalText: String) { func onFinalSourceText(sessionId: String, finalText: String) {
os_log("[AliyunCallback-%{public}@] onFinalSourceText text=%{public}@",
log: ctLog, type: .info, serviceId, finalText)
sourceTextCache["\(serviceId):\(sessionId)"] = finalText
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "recognized", "type": "recognized",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"text": finalText, "text": finalText,
"language": direction, "language": sourceLanguage,
"utteranceId": sessionId "utteranceId": sessionId
]) ])
} }
func onPartialText(sessionId: String, text: String) { func onPartialText(sessionId: String, text: String) {
os_log("[AliyunCallback-%{public}@] onPartialText text=%{public}@",
log: ctLog, type: .info, serviceId, text)
plugin?.sendAstEvent([ plugin?.sendAstEvent([
"type": "translatedInterim", "type": "translatedInterim",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"translatedText": text, "translatedText": text,
"originalText": "",
"targetLanguage": targetLanguage, "targetLanguage": targetLanguage,
"utteranceId": sessionId "utteranceId": sessionId
]) ])
} }
func onPartialAudio(sessionId: String, data: Data) { func onPartialAudio(sessionId: String, data: Data) {
let isRightChannel = (serviceId == "A") let isServiceA = (serviceId == "A")
if data.count > 0 { if data.count > 0 && isServiceA {
if isRightChannel { os_log("[AliyunCallback-%{public}@] onPartialAudio writing %d bytes to BLE",
BleService.shared.writeExternalAudioData(data: data) log: ctLog, type: .debug, serviceId, data.count)
} else {
BleService.shared.writeExternalAudioData(data: data) BleService.shared.writeExternalAudioData(data: data)
} }
} }
}
func onSessionFinished(sessionId: String, finalText: String, finalAudio: 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([ plugin?.sendAstEvent([
"type": "translated", "type": "translated",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"translatedText": finalText, "translatedText": finalText,
"targetLanguage": targetLanguage, "targetLanguage": targetLanguage,
"utteranceId": sessionId "utteranceId": sessionId,
"originalText": original
]) ])
} }
func onSessionError(sessionId: String, code: Int, message: String) { 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([ plugin?.sendAstEvent([
"type": "error", "type": "error",
"serviceId": serviceId, "serviceId": serviceId,
@ -1509,12 +1670,18 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback {
} }
func onFinalTranslatedText(sessionId: String, finalText: String) { 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([ plugin?.sendAstEvent([
"type": "translated", "type": "translated",
"serviceId": serviceId, "serviceId": serviceId,
"direction": direction,
"translatedText": finalText, "translatedText": finalText,
"targetLanguage": targetLanguage, "targetLanguage": targetLanguage,
"utteranceId": sessionId "utteranceId": sessionId,
"originalText": original
]) ])
} }
} }
@ -1660,6 +1827,9 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback {
* @param errorDetails 错误详情 * @param errorDetails 错误详情
*/ */
func onCanceled(sessionid:String,_ reason: String, _ errorDetails: String) { 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([ plugin?.sendAsrEvent([
"type": "canceled", "type": "canceled",
"sessionid": sessionid, "sessionid": sessionid,

105
local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift

@ -92,10 +92,13 @@ public class BleService: NSObject {
private var opusProcessor: SwiftOpusAudioProcessor? private var opusProcessor: SwiftOpusAudioProcessor?
// MARK: - 新增命令队列相关属性 // MARK: - 新增命令队列相关属性
private var commandQueue = [Data]() private var commandQueue = [Data]()
//是否回复 //是否回复(与 Android isWaitingReply 对应,true=可发送,false=等待回复)
private var isReply = true 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 private var sampleRate: Int32 = 16000
@ -483,6 +486,12 @@ private var cmdReplyType: UInt8 = 0
audioDataQueue.removeAll() audioDataQueue.removeAll()
audioQueueLock.unlock() audioQueueLock.unlock()
// 重置命令队列(与 Android reset 对齐)
replyTimeoutTimer?.invalidate()
replyTimeoutTimer = nil
commandQueue.removeAll()
isReply = true
updateConnectionState(BleConst.STATE_DISCONNECTING) updateConnectionState(BleConst.STATE_DISCONNECTING)
centralManager?.cancelPeripheralConnection(peripheral) centralManager?.cancelPeripheralConnection(peripheral)
os_log("🔌 开始断开设备连接", log: logger, type: .info) os_log("🔌 开始断开设备连接", log: logger, type: .info)
@ -706,7 +715,7 @@ private var cmdReplyType: UInt8 = 0
if opusProcessor?.isDecoding() != true { if opusProcessor?.isDecoding() != true {
startOpusDecoding() 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) let result = sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData)
if result { 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) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData)
} }
/// 获取与Flutter getExternalStorageDirectories()一致的存储目录 /// 获取与Flutter getExternalStorageDirectories()一致的存储目录
@ -747,13 +756,14 @@ private var cmdReplyType: UInt8 = 0
os_log("打开A2DP解码并开始录制...", log: logger, type: .info) 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) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData)
} }
/// 打开通话记录解码器并开始录制 /// 打开通话记录解码器并开始录制
func openCallRecordDecoder() -> Bool { func openCallRecordDecoder() -> Bool {
os_log("打开通话记录解码并开始录制...", log: logger, type: .info) 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) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData)
} }
@ -777,14 +787,15 @@ private var cmdReplyType: UInt8 = 0
requestStopOpusRecording() requestStopOpusRecording()
// 不停止Opus解码流,只发送命令通知设备关闭编解码 // 不停止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) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData)
} }
/// 播放A2DP /// 播放A2DP
func playA2DP() -> Bool { func playA2DP() -> Bool {
os_log("切换至A2DP播放模式...", log: logger, type: .info) // 与 Android 保持一致:不发送命令,直接返回 true
return sendCommand(BleConst.CMD_PLAY_A2DP) // 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) 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) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData)
} }
@ -881,25 +892,58 @@ private var cmdReplyType: UInt8 = 0
// 加入队列并触发发送 // 与 Android 对齐:isReply=true 时直接发送,否则入队列
if isReply { if isReply {
peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse) peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse)
isReply = false; isReply = false
startReplyTimeout()
return true return true
} else { } else {
commandQueue.append(cmdPacket) commandQueue.append(cmdPacket)
} os_log("命令已加入队列,当前队列长度: %d", log: logger, type: .debug, commandQueue.count)
// 发送数据(使用无响应写入,与Android端保持一致)
return true return true
}
} catch { } catch {
os_log("liwei---- 发送命令异常: %@", log: logger, type: .error, error.localizedDescription) os_log("liwei---- 发送命令异常: %@", log: logger, type: .error, error.localizedDescription)
return false 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端保持一致) /// 计算CRC8/MAXIM校验值(与Android端保持一致)
private func calculateCRC8(data: Data) -> UInt8 { private func calculateCRC8(data: Data) -> UInt8 {
var crc: Int = 0 var crc: Int = 0
@ -1024,7 +1068,8 @@ private var cmdReplyType: UInt8 = 0
var hexString = data.map { String(format: "0x%02X", $0) }.joined(separator: ", ") var hexString = data.map { String(format: "0x%02X", $0) }.joined(separator: ", ")
// os_log("liwei---- 收到设备响应: %@", log: logger, type: .debug, hexString) // os_log("liwei---- 收到设备响应: %@", log: logger, type: .debug, hexString)
// 检查数据长度(与 Android 统一失败判定保持一致) // 统一失败判定:长度字段与实际长度不匹配时,跳过 CRC 直接上报
// (设备的短帧响应 CRC 可能基于不同帧格式计算,强校验会丢弃有效数据)
if data.count != Int(length) + 4 { // 帧头 + 命令 + 长度 + 数据 + CRC if data.count != Int(length) + 4 { // 帧头 + 命令 + 长度 + 数据 + CRC
os_log("设备响应数据长度不匹配: 期望=%d, 实际=%d", log: logger, type: .error, Int(length) + 4, data.count) 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] = [ let resultMap: [String: Any] = [
"success": true, // 与 Android 对齐(Dart 层按 rawData 判断) "success": true, // 与 Android 对齐(Dart 层按 rawData 判断)
"command": Int(cmd), "command": Int(cmd),
"rawData": Data(raw) "rawData": raw // [UInt8] 数组,确保 Dart 端收到 List<int>
] ]
// 推进命令队列(与后续正常路径一致) // 与 Android 对齐:通过 onCommandResponse 推进队列
isReply = true onCommandResponse(cmd)
cmdReplyType = cmd
if !commandQueue.isEmpty {
let nextCmd = commandQueue.removeFirst()
peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse)
}
// 上报到 Dart 层 // 上报到 Dart 层
notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap)
return return
} }
// 计算CRC校验 - 不包括最后一个字节(CRC) // 长度匹配的正常帧:进行 CRC 校验
let payload = data.prefix(data.count - 1) let payload = data.prefix(data.count - 1)
let calculatedCrc = calculateCRC8(data: payload) let calculatedCrc = calculateCRC8(data: payload)
let receivedCrc = data[data.count - 1] let receivedCrc = data[data.count - 1]
if calculatedCrc != receivedCrc { if calculatedCrc != receivedCrc {
os_log("设备响应CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc) os_log("设备响应CRC校验失败: 计算=0x%02X, 接收=0x%02X", log: logger, type: .error, calculatedCrc, receivedCrc)
return return
@ -1066,12 +1105,8 @@ private var cmdReplyType: UInt8 = 0
// 提取响应数据 // 提取响应数据
let responseData = length > 0 ? data.subdata(in: 3..<(3 + Int(length))) : Data() let responseData = length > 0 ? data.subdata(in: 3..<(3 + Int(length))) : Data()
isReply = true // 与 Android 对齐:通过 onCommandResponse 推进队列
cmdReplyType = cmd onCommandResponse(cmd)
if !commandQueue.isEmpty {
let nextCmd = commandQueue.removeFirst()
peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse)
}
// 根据命令类型处理设备响应 // 根据命令类型处理设备响应
switch cmd { switch cmd {
@ -1163,10 +1198,12 @@ private var cmdReplyType: UInt8 = 0
notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap) notifyDeviceInfoReceived(infoType: Int(cmd), infoData: resultMap)
case BleConst.CMD_CONTROL_CODEC: case BleConst.CMD_CONTROL_CODEC:
// 与 Android 对齐:rawData 使用 [UInt8] 数组,确保 Dart 端收到 List<int>
let rawDataArray = [UInt8](responseData)
var resultMap: [String: Any] = [ var resultMap: [String: Any] = [
"success": true, "success": true,
"command": Int(cmd), "command": Int(cmd),
"rawData": responseData "rawData": rawDataArray
] ]
if !responseData.isEmpty && responseData.count >= 2 { if !responseData.isEmpty && responseData.count >= 2 {

Loading…
Cancel
Save