From bf7522476d44dbd558e9f64b55c272a21cf23217 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Wed, 23 Jul 2025 20:04:35 +0800 Subject: [PATCH 01/23] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=89=88=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/pairing_controller.dart | 28 +++++++++++++------ .../Sources/ble_service/BleService.swift | 7 ++++- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 819ca1d39..336e3b81a 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -660,14 +660,26 @@ class PairingController extends GetxController { String? uuids = deviceInfo["id"]; _verifiedDeviceAddress = uuids; print('找到匹配设备,获取到 uuids: $uuids'); - // 检查是否已经绑定 - // if (User.instance.earphonelicense != "" && - // User.instance.earphonemac == device.address) { - // // 已经绑定 - // return 10; - // } else { - return 1; - // } + //检查是否已经绑定 + final existingDevice = User.instance.devices.firstWhere( + (d) => + d.devicetype == 2 && + d.license != "" && + d.devicemac == device.address, + orElse: () => UserDevice( + id: -1, + uid: '', + devicetype: -1, + devicename: '', + devicemac: '', + license: ''), + ); + if (existingDevice.id != -1) { + // 已经绑定 + return 10; + } else { + return 1; + } } } else if (Platform.isAndroid) { // Android设备,提取15-24字节的授权码 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 c54bacde2..6647a5e4a 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 @@ -229,7 +229,12 @@ private var cmdReplyType: UInt8 = 0 isScanning = true - // 移除服务UUID过滤,扫描所有设备 + // // 移除服务UUID过滤,扫描所有设备 + // manager.scanForPeripherals(withServices: nil, options: [ + // CBCentralManagerScanOptionAllowDuplicatesKey: true // 允许重复以获取更新的广播数据 + // ]) + let serviceUUIDs = [BleConst.SERVICE_UUID, BleConst.SERVICE_UUID1] + manager.scanForPeripherals(withServices: nil, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true // 允许重复以获取更新的广播数据 ]) From a5c3db9465c7c711e39eeb94dcfea72dfb784d24 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Wed, 23 Jul 2025 22:06:46 +0800 Subject: [PATCH 02/23] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/ble_manager.dart | 3 +- .../Sources/azure_speech/AzureAsrHelper.swift | 147 +++++++++++++----- 2 files changed, 109 insertions(+), 41 deletions(-) diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 96bea2fc6..b55927d5a 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -562,7 +562,8 @@ class BleManager extends GetxService { 'volcanoAppId': '2316081933', 'volcanoToken': 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En', 'serverurl': serverurl, - 'usertoken': jsonString + 'usertoken': jsonString, + 'supportedLanguages': ['zh-CN'], }; // 记录日志(不记录敏感信息) 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 9425fa62d..e45b51003 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 @@ -272,7 +272,15 @@ public class AzureAsrHelper: NSObject { // 创建识别器 if isAutoDetectLanguage { - let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) + // 使用更精确的语言配置方式 + var sourceLanguageConfigs: [SPXSourceLanguageConfiguration] = [] + for language in supportedLanguages { + if let config = try? SPXSourceLanguageConfiguration(language) { + sourceLanguageConfigs.append(config) + } + } + + let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(sourceLanguageConfigurations: sourceLanguageConfigs) recognizer = try SPXSpeechRecognizer( speechConfiguration: speechConfig!, autoDetectSourceLanguageConfiguration: autoDetectConfig, @@ -336,10 +344,31 @@ public class AzureAsrHelper: NSObject { recognizer.addRecognizingEventHandler { [weak self] (_, event) in guard let self = self else { return } let result = event.result - let detectedLanguage = self.detectLanguage(from: result) - print("识别中事件: \(detectedLanguage)") - DispatchQueue.main.async { - callback.onRecognizing(result.text ?? "", detectedLanguage) + print("识别中文本: \(result.text)") + // 优化:只处理非空结果 + if !(result.text?.isEmpty ?? true) { + let detectedLanguage: String + if self.isAutoDetectLanguage { + do { + let langResult = try SPXAutoDetectSourceLanguageResult(result) + let rawLanguage = langResult.language + print("[DEBUG] 原始检测语言: \(rawLanguage ?? "nil"), 文本: \(result.text ?? "nil"), 支持的语言: \(self.supportedLanguages)") + + // 应用启发式语言检测纠正 + detectedLanguage = self.correctLanguageDetection(rawLanguage: rawLanguage ?? "", text: result.text ?? "") + print("[DEBUG] 纠正后语言: \(detectedLanguage)") + } catch { + print("[DEBUG] 语言检测异常: \(error.localizedDescription)") + detectedLanguage = "" + } + } else { + detectedLanguage = self.currentLanguage + print("[DEBUG] 使用当前语言: \(detectedLanguage)") + } + print("识别中事件: \(detectedLanguage)") + DispatchQueue.main.async { + callback.onRecognizing(result.text ?? "", detectedLanguage) + } } } @@ -347,8 +376,27 @@ public class AzureAsrHelper: NSObject { recognizer.addRecognizedEventHandler { [weak self] (_, event) in guard let self = self else { return } let result = event.result - if result.reason == .recognizedSpeech { - let detectedLanguage = self.detectLanguage(from: result) + print("识别完成文本: \(result.text)") + if result.reason == .recognizedSpeech && !(result.text?.isEmpty ?? true) { + let detectedLanguage: String + if self.isAutoDetectLanguage { + do { + let langResult = try SPXAutoDetectSourceLanguageResult(result) + let rawLanguage = langResult.language + print("[DEBUG] 识别完成 - 原始检测语言: \(rawLanguage ?? "nil"), 文本: \(result.text ?? "nil"), 支持的语言: \(self.supportedLanguages)") + + // 应用启发式语言检测纠正 + let correctedLanguage = self.correctLanguageDetection(rawLanguage: rawLanguage ?? "", text: result.text ?? "") + detectedLanguage = correctedLanguage.isEmpty ? (self.supportedLanguages.first ?? self.currentLanguage) : correctedLanguage + print("[DEBUG] 识别完成 - 纠正后语言: \(detectedLanguage)") + } catch { + print("[DEBUG] 识别完成 - 语言检测异常: \(error.localizedDescription)") + detectedLanguage = self.supportedLanguages.first ?? self.currentLanguage + } + } else { + detectedLanguage = self.currentLanguage + print("[DEBUG] 识别完成 - 使用当前语言: \(detectedLanguage)") + } print("识别完成事件: \(detectedLanguage)") DispatchQueue.main.async { callback.onResult(result.text ?? "", detectedLanguage) @@ -387,38 +435,6 @@ public class AzureAsrHelper: NSObject { return true } -// 提取的辅助方法 -private func detectLanguage(from result: SPXSpeechRecognitionResult) -> String { - if isAutoDetectLanguage { - let langResult = try SPXAutoDetectSourceLanguageResult(result) - return langResult.language ?? "" - } else { - return currentLanguage - } -} - /** - * 获取检测到的语言 - */ - private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { - if !isAutoDetectLanguage { - return "" - } - - // 尝试从属性中获取语言 - if let properties = result.properties, - let language = properties.getPropertyByName("SpeechServiceResponse_RecognitionLanguage") { - return language - } - - // 尝试另一种方式获取语言 - do { - let langResult = try SPXAutoDetectSourceLanguageResult(result) - print("获取检测到的语言") - return langResult.language ?? "" - } catch { - return "" - } - } /** * 禁用蓝牙音频功能,切换回正常音频模式 @@ -853,6 +869,57 @@ private func detectLanguage(from result: SPXSpeechRecognitionResult) -> String { } } } + + /** + * 启发式语言检测纠正方法 + * 基于文本内容特征来纠正Azure语音服务的语言检测结果 + */ + private func correctLanguageDetection(rawLanguage: String, text: String) -> String { + // 如果原始检测为空,返回空 + if rawLanguage.isEmpty || text.isEmpty { + return rawLanguage + } + + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + // 英文特征检测 + let englishPattern = "^[a-z\\s\\.,!?;:'-]+$" + let englishRegex = try? NSRegularExpression(pattern: englishPattern, options: .caseInsensitive) + let englishMatches = englishRegex?.numberOfMatches(in: trimmedText, options: [], range: NSRange(location: 0, length: trimmedText.count)) ?? 0 + + // 中文特征检测 + let chinesePattern = "[\\u4e00-\\u9fff]" + let chineseRegex = try? NSRegularExpression(pattern: chinesePattern, options: []) + let chineseMatches = chineseRegex?.numberOfMatches(in: trimmedText, options: [], range: NSRange(location: 0, length: trimmedText.count)) ?? 0 + + // 常见英文单词列表 + let commonEnglishWords = ["hello", "hi", "please", "thank", "you", "yes", "no", "good", "morning", "afternoon", "evening", "night", "how", "are", "what", "where", "when", "why", "who", "can", "could", "would", "should", "will", "the", "and", "or", "but", "if", "then", "this", "that", "these", "those"] + + let hasCommonEnglishWords = commonEnglishWords.contains { word in + trimmedText.contains(word) + } + + print("[DEBUG] 语言纠正分析 - 文本: '\(trimmedText)', 英文匹配: \(englishMatches > 0), 中文字符: \(chineseMatches), 常见英文词: \(hasCommonEnglishWords), 原始语言: \(rawLanguage)") + + // 纠正逻辑 + if rawLanguage == "zh-CN" { + // 如果Azure检测为中文,但文本明显是英文特征 + if (englishMatches > 0 && chineseMatches == 0) || hasCommonEnglishWords { + print("[DEBUG] 纠正: \(rawLanguage) -> en-US (检测到英文特征)") + return "en-US" + } + } else if rawLanguage == "en-US" { + // 如果Azure检测为英文,但包含中文字符 + if chineseMatches > 0 { + print("[DEBUG] 纠正: \(rawLanguage) -> zh-CN (检测到中文字符)") + return "zh-CN" + } + } + + // 如果没有明确的纠正理由,保持原始检测结果 + return rawLanguage + } + // MARK: - 回调函数 /** * 连续识别回调接口 @@ -899,4 +966,4 @@ private func detectLanguage(from result: SPXSpeechRecognitionResult) -> String { */ func onError(_ error: String) } -} +} From 0c03005b05c710c4aa4b5b25af0c0b9c7a6e93cc Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Fri, 25 Jul 2025 15:24:38 +0800 Subject: [PATCH 03/23] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=A3=B0=E9=81=93?= =?UTF-8?q?=E5=88=86=E7=A6=BB=E3=80=82=E6=8B=86=E5=88=86=E4=B8=A4=E6=AE=B5?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=96=87=E4=BB=B6=E3=80=82=E7=9B=AE=E5=89=8D?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E5=BD=B1=E5=93=8D=E5=85=B6=E4=BB=96=E5=BD=95?= =?UTF-8?q?=E4=B8=8B=E6=9D=A5=E7=9A=84=E9=9F=B3=E9=A2=91=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/network/api.dart | 4 +- .../speech_impl/azure_asr_service.dart | 24 +- .../controllers/devices_controller.dart | 2 +- .../login/controllers/login_controller.dart | 13 +- lib/modules/login/views/login_view.dart | 78 ++--- .../controllers/opus_test_controller.dart | 2 +- .../azure_speech/AzureAsrHelper.kt | 15 + .../azure_speech/AzureSpeechPlugin.kt | 13 +- .../azure_speech/tools/RecordFile.kt | 299 +++++++++++++++++- .../Sources/azure_speech/AzureAsrHelper.swift | 50 +++ .../azure_speech/AzureSpeechPlugin.swift | 14 +- .../yunqiinnovation/ble_service/BleConst.kt | 2 +- .../yunqiinnovation/ble_service/BleService.kt | 126 ++++++-- 13 files changed, 561 insertions(+), 81 deletions(-) diff --git a/lib/data/services/network/api.dart b/lib/data/services/network/api.dart index 0301daa87..08733ee66 100644 --- a/lib/data/services/network/api.dart +++ b/lib/data/services/network/api.dart @@ -146,10 +146,10 @@ class Api { } //解绑定设备 - static unbindearphone([params]) { + static unbinddevice([params]) { return DioManager().request( NWMethod.post, - '/api/home/user_unbindearphone', + '/api/home/user_unbinddevice', params: params, ); } diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 309dd2ae0..c02347552 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -398,7 +398,29 @@ class AzureAsrService extends GetxService implements AsrService { return result; } catch (e) { - Logger.error('停止录音: ${e.toString()}'); + Logger.error('暂停录音: ${e.toString()}'); + rethrow; + } + } + + /// 设置音频配置 + /// + /// [sampleRate] 采样率,默认16000 + /// [channels] 声道数,默认1(单声道) + Future setAudioConfig({ + int sampleRate = 16000, + int channels = 1, + }) async { + try { + final bool result = await _channel.invokeMethod('setAudioConfig', { + 'sampleRate': sampleRate, + 'channels': channels, + }); + + Logger.info('音频配置设置${result ? '成功' : '失败'}: 采样率=$sampleRate, 声道数=$channels'); + return result; + } catch (e) { + Logger.error('设置音频配置失败: ${e.toString()}'); rethrow; } } diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index 6121ed7f8..1a1fe15a8 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -35,7 +35,7 @@ class DevicesController extends GetxController { } void unbindearphone(UserDevice device) async { - final response = await Api.unbindearphone({ + final response = await Api.unbinddevice({ "id": device.id, }); if (response != null) { diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index fcc7e2f62..0ddfa56a5 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -220,8 +220,17 @@ class LoginController extends GetxController { @override void onClose() { - contactController.dispose(); - verificationCodeController.dispose(); + // Safely dispose controllers + try { + contactController.dispose(); + } catch (e) { + // Controller already disposed + } + try { + verificationCodeController.dispose(); + } catch (e) { + // Controller already disposed + } super.onClose(); } diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index 543ba1827..4b46914e8 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -232,26 +232,28 @@ class LoginView extends GetView { margin: EdgeInsets.symmetric(horizontal: 8.w), ), Expanded( - child: TextField( - controller: controller.contactController, - decoration: InputDecoration( - hintText: controller.selectedCountryFlag == '✉️' - ? 'email'.tr // 邮箱 - : 'phoneNumber'.tr, // 手机号码 - hintStyle: TextStyle( + child: GetBuilder( + builder: (controller) => TextField( + controller: controller.contactController, + decoration: InputDecoration( + hintText: controller.selectedCountryFlag == '✉️' + ? 'email'.tr // 邮箱 + : 'phoneNumber'.tr, // 手机号码 + hintStyle: TextStyle( + fontSize: 14.sp, + color: isDarkMode + ? Colors.grey[500] + : Colors.black38, // 调整提示文字颜色 + ), + border: InputBorder.none, + fillColor: Colors.transparent, + filled: true, + focusedBorder: InputBorder.none, + ), + style: TextStyle( fontSize: 14.sp, - color: isDarkMode - ? Colors.grey[500] - : Colors.black38, // 调整提示文字颜色 + color: isDarkMode ? Colors.white : Colors.black, ), - border: InputBorder.none, - fillColor: Colors.transparent, - filled: true, - focusedBorder: InputBorder.none, - ), - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, ), ), ), @@ -360,27 +362,29 @@ class LoginView extends GetView { ), SizedBox(width: 12.w), Expanded( - child: TextField( - controller: controller.verificationCodeController, - keyboardType: TextInputType.number, - maxLength: 6, - decoration: InputDecoration( - hintText: 'verificationCode'.tr, // 验证码 - hintStyle: TextStyle( + child: GetBuilder( + builder: (controller) => TextField( + controller: controller.verificationCodeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: 'verificationCode'.tr, // 验证码 + hintStyle: TextStyle( + fontSize: 14.sp, + color: isDarkMode + ? Colors.grey[500] + : Colors.black38, // 调整提示文字颜色 + ), + border: InputBorder.none, + counterText: '', + fillColor: Colors.transparent, + filled: true, + focusedBorder: InputBorder.none, + ), + style: TextStyle( fontSize: 14.sp, - color: isDarkMode - ? Colors.grey[500] - : Colors.black38, // 调整提示文字颜色 + color: isDarkMode ? Colors.white : Colors.black, ), - border: InputBorder.none, - counterText: '', - fillColor: Colors.transparent, - filled: true, - focusedBorder: InputBorder.none, - ), - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, ), ), ), diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart index 6297dee3a..75b60b3ad 100644 --- a/lib/modules/opus_test/controllers/opus_test_controller.dart +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -231,7 +231,7 @@ class OpusTestController extends GetxController { isDecoding.value = false; return; } - + print("pcmPath: $pcmPath"); tempPcmPath = pcmPath; // 将PCM文件转换为WAV文件 diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 35fa385de..0d85eda9c 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -154,6 +154,21 @@ class AzureAsrHelper(private val context: Context) { } } + /** + * 设置音频配置 + * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) + * @param channels 声道数 (1=单声道, 2=立体声) + */ + fun setAudioConfig(sampleRate: Int, channels: Int) { + try { + // 设置RecordFile的音频配置 + recordfile?.setAudioConfig(sampleRate, channels) + Log.d(tag, "音频配置已设置: 采样率=${sampleRate}Hz, 声道数=${channels}") + } catch (e: Exception) { + Log.e(tag, "设置音频配置失败: ${e.message}") + } + } + private fun isRecognizerValid(): Boolean { return recognizer != null } 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 b415262e8..30137c898 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 @@ -460,6 +460,17 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } } + "setAudioConfig" -> { + val sampleRate = call.argument("sampleRate") ?: 16000 + val channels = call.argument("channels") ?: 1 + try { + azureAsrHelper.setAudioConfig(sampleRate, channels) + result.success(true) + } catch (e: Exception) { + result.error("SET_AUDIO_CONFIG_ERROR", e.message, null) + } + } + else -> { result.notImplemented() } @@ -624,4 +635,4 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { // 不处理设备信息 } -} \ No newline at end of file +} \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt index c1e1b4602..cddc6a245 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt @@ -22,8 +22,9 @@ object RecordFile { private val dataBuffer = mutableListOf() private var totalBytesWritten = 0 - // 音频配置 - private val sampleRate = 16000 + // 音频配置 - 可配置的采样率和声道数 + private var sampleRate = 16000 + private var channels = 2 // 1=单声道, 2=立体声 // 新增:用于存储最后成功保存的文件 private var lastSavedFile: File? = null @@ -31,6 +32,64 @@ object RecordFile { var isPause = false var fileName = "" + // 双声道分离相关变量 + private var leftChannelFile: File? = null + private var rightChannelFile: File? = null + private var leftChannelFos: FileOutputStream? = null + private var rightChannelFos: FileOutputStream? = null + private val leftChannelQueue = LinkedBlockingQueue() + private val rightChannelQueue = LinkedBlockingQueue() + private var leftChannelWriteThread: Thread? = null + private var rightChannelWriteThread: Thread? = null + private var leftChannelBytesWritten = 0 + private var rightChannelBytesWritten = 0 + + + /** + * 设置音频配置 + * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) + * @param channels 声道数 (1=单声道, 2=立体声) + */ + internal fun setAudioConfig(sampleRate: Int, channels: Int) { + this.sampleRate = sampleRate + this.channels = channels + Log.d("tag", "音频配置已更新: 采样率=${sampleRate}Hz, 声道数=${channels}") + } + + /** + * 获取左声道文件路径 + * @return 左声道文件的绝对路径,如果不存在则返回null + */ + internal fun getLeftChannelFilePath(): String? { + return leftChannelFile?.absolutePath + } + + /** + * 获取右声道文件路径 + * @return 右声道文件的绝对路径,如果不存在则返回null + */ + internal fun getRightChannelFilePath(): String? { + return rightChannelFile?.absolutePath + } + + /** + * 获取当前音频配置信息 + * @return Map包含采样率和声道数信息 + */ + internal fun getAudioConfig(): Map { + return mapOf( + "sampleRate" to sampleRate, + "channels" to channels + ) + } + + /** + * 检查是否为双声道模式 + * @return true表示双声道,false表示单声道 + */ + internal fun isStereoMode(): Boolean { + return channels == 2 + } // 修改:添加 filePath 参数 internal fun creatingFiles(filePath: String) { @@ -54,6 +113,105 @@ object RecordFile { fos = FileOutputStream(currentAudioFile, true) startWriteThread() + + // 如果是双声道,创建左右声道文件 + if (channels == 2) { + createStereoChannelFiles(filePath) + } + } + + /** + * 创建左右声道文件 + */ + private fun createStereoChannelFiles(originalFilePath: String) { + val originalFile = File(originalFilePath) + val parentDir = originalFile.parentFile + val nameWithoutExt = originalFile.nameWithoutExtension + + // 创建左声道文件 + leftChannelFile = File(parentDir, "${nameWithoutExt}_left.wav") + leftChannelFile?.createNewFile() + leftChannelFos = FileOutputStream(leftChannelFile).apply { + write(generateMonoWavHeader(0)) + close() + } + leftChannelFos = FileOutputStream(leftChannelFile, true) + + // 创建右声道文件 + rightChannelFile = File(parentDir, "${nameWithoutExt}_right.wav") + rightChannelFile?.createNewFile() + rightChannelFos = FileOutputStream(rightChannelFile).apply { + write(generateMonoWavHeader(0)) + close() + } + rightChannelFos = FileOutputStream(rightChannelFile, true) + + // 启动左右声道写入线程 + startStereoWriteThreads() + + Log.d("tag", "双声道文件创建完成: ${leftChannelFile?.name}, ${rightChannelFile?.name}") + } + + /** + * 生成单声道WAV文件头 + */ + private fun generateMonoWavHeader(dataLength: Int): ByteArray { + val totalLength = 36 + dataLength + val bytesPerSample = 2 + val monoChannels = 1 + val byteRate = sampleRate * monoChannels * bytesPerSample + val blockAlign = monoChannels * bytesPerSample + + return byteArrayOf( + 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), + (totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(), + ((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(), + 'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(), + 'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), + 16, 0, 0, 0, + 1, 0, + (monoChannels and 0xFF).toByte(), ((monoChannels shr 8) and 0xFF).toByte(), + (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), + ((sampleRate shr 16) and 0xFF).toByte(), ((sampleRate shr 24) and 0xFF).toByte(), + (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), + ((byteRate shr 16) and 0xFF).toByte(), ((byteRate shr 24) and 0xFF).toByte(), + (blockAlign and 0xFF).toByte(), ((blockAlign shr 8) and 0xFF).toByte(), + 16, 0, + 'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), + (dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), + ((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte() + ) + } + + /** + * 启动左右声道写入线程 + */ + private fun startStereoWriteThreads() { + // 左声道写入线程 + leftChannelWriteThread = Thread { + try { + while (isWriting.get() || leftChannelQueue.isNotEmpty()) { + val data = leftChannelQueue.poll() ?: continue + leftChannelFos?.write(data) + } + } catch (e: Exception) { + Log.e("tag", "左声道异步写入失败: ${e.message}") + } + } + leftChannelWriteThread?.start() + + // 右声道写入线程 + rightChannelWriteThread = Thread { + try { + while (isWriting.get() || rightChannelQueue.isNotEmpty()) { + val data = rightChannelQueue.poll() ?: continue + rightChannelFos?.write(data) + } + } catch (e: Exception) { + Log.e("tag", "右声道异步写入失败: ${e.message}") + } + } + rightChannelWriteThread?.start() } @@ -76,19 +234,65 @@ object RecordFile { /** * 保存音频数据到 WAV 文件(异步) + * 如果是双声道,会自动拆分成左右声道分别保存 */ internal fun saveAudioDataToWav(buffer: ByteArray) { if (fos == null || currentAudioFile == null) return - // 放入队列,由写线程写入 - writeQueue.offer(buffer.copyOf()) - totalBytesWritten += buffer.size + + if (channels == 2) { + // 双声道:拆分左右声道 + splitStereoToMono(buffer) + } else { + // 单声道:直接保存 + writeQueue.offer(buffer.copyOf()) + totalBytesWritten += buffer.size + } } + /** + * 将双声道音频数据拆分成左右声道 + * @param stereoBuffer 双声道音频数据(16位PCM,交错格式:L1R1L2R2...) + */ + private fun splitStereoToMono(stereoBuffer: ByteArray) { + if (stereoBuffer.size % 4 != 0) { + Log.w("tag", "双声道音频数据长度不正确: ${stereoBuffer.size}") + return + } + + val sampleCount = stereoBuffer.size / 4 // 每个样本4字节(左右声道各2字节) + val leftBuffer = ByteArray(sampleCount * 2) // 左声道缓冲区 + val rightBuffer = ByteArray(sampleCount * 2) // 右声道缓冲区 + + // 拆分交错的左右声道数据 + for (i in 0 until sampleCount) { + val stereoIndex = i * 4 + val monoIndex = i * 2 + + // 左声道(低位字节在前,高位字节在后) + leftBuffer[monoIndex] = stereoBuffer[stereoIndex] + leftBuffer[monoIndex + 1] = stereoBuffer[stereoIndex + 1] + + // 右声道 + rightBuffer[monoIndex] = stereoBuffer[stereoIndex + 2] + rightBuffer[monoIndex + 1] = stereoBuffer[stereoIndex + 3] + } + + // 将左右声道数据分别放入队列 + leftChannelQueue.offer(leftBuffer) + rightChannelQueue.offer(rightBuffer) + leftChannelBytesWritten += leftBuffer.size + rightChannelBytesWritten += rightBuffer.size + + Log.d("tag", "双声道拆分完成: 左声道${leftBuffer.size}字节, 右声道${rightBuffer.size}字节") + } - // 更新文件头生成(修正RIFF长度计算) + + // 更新文件头生成(支持可配置的采样率和声道数) private fun generateWavHeader(dataLength: Int): ByteArray { val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据 - val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数 + val bytesPerSample = 2 // 16位PCM + val byteRate = sampleRate * channels * bytesPerSample // 采样率 * 声道数 * 字节/样本 + val blockAlign = channels * bytesPerSample // 声道数 * 字节/样本 return byteArrayOf( 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), @@ -98,10 +302,12 @@ object RecordFile { 'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), 16, 0, 0, 0, // PCM头长度 1, 0, // PCM格式 - 1, 0, // 单声道 - (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率 - (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率 - 2, 0, // 块对齐 (通道数 * 样本位数/8) + (channels and 0xFF).toByte(), ((channels shr 8) and 0xFF).toByte(), // 声道数 + (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), + ((sampleRate shr 16) and 0xFF).toByte(), ((sampleRate shr 24) and 0xFF).toByte(), // 采样率 + (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), + ((byteRate shr 16) and 0xFF).toByte(), ((byteRate shr 24) and 0xFF).toByte(), // 字节率 + (blockAlign and 0xFF).toByte(), ((blockAlign shr 8) and 0xFF).toByte(), // 块对齐 16, 0, // 样本位数 'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), (dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), @@ -225,31 +431,92 @@ object RecordFile { writeThread?.join(500) fos?.close() + var mainFileSuccess = false + var leftChannelSuccess = false + var rightChannelSuccess = false + + // 处理主文件 currentAudioFile?.let { file -> if (file.length() <= 44 || isSave == false) { file.delete() Log.d("tag", "音频文件过小已删除: ${file.absolutePath}") - return false } else { RandomAccessFile(file, "rw").use { raf -> raf.seek(0) raf.write(generateWavHeader(totalBytesWritten)) } Log.d("tag", "音频文件保存完成: ${file.absolutePath}") - - // 保存成功文件引用 lastSavedFile = file - return true + mainFileSuccess = true } } + + // 处理左右声道文件(如果是双声道) + if (channels == 2) { + leftChannelSuccess = closeStereoChannelFile(leftChannelFile, leftChannelFos, leftChannelBytesWritten, "左声道", isSave) + rightChannelSuccess = closeStereoChannelFile(rightChannelFile, rightChannelFos, rightChannelBytesWritten, "右声道", isSave) + + // 等待左右声道写入线程结束 + leftChannelWriteThread?.join(500) + rightChannelWriteThread?.join(500) + } + + return if (channels == 2) { + leftChannelSuccess && rightChannelSuccess + } else { + mainFileSuccess + } } catch (e: Exception) { Log.e("tag", "更新WAV文件头失败: ${e.message}") return false } finally { + // 清理资源 fos = null currentAudioFile = null writeThread = null + + // 清理双声道资源 + leftChannelFos = null + rightChannelFos = null + leftChannelFile = null + rightChannelFile = null + leftChannelWriteThread = null + rightChannelWriteThread = null + leftChannelBytesWritten = 0 + rightChannelBytesWritten = 0 + totalBytesWritten = 0 + } + } + + /** + * 关闭单个声道文件 + */ + private fun closeStereoChannelFile( + channelFile: File?, + channelFos: FileOutputStream?, + bytesWritten: Int, + channelName: String, + isSave: Boolean + ): Boolean { + return try { + channelFos?.close() + channelFile?.let { file -> + if (file.length() <= 44 || isSave == false) { + file.delete() + Log.d("tag", "${channelName}文件过小已删除: ${file.absolutePath}") + false + } else { + RandomAccessFile(file, "rw").use { raf -> + raf.seek(0) + raf.write(generateMonoWavHeader(bytesWritten)) + } + Log.d("tag", "${channelName}文件保存完成: ${file.absolutePath}") + true + } + } ?: false + } catch (e: Exception) { + Log.e("tag", "${channelName}文件关闭失败: ${e.message}") + false } - return false } } \ No newline at end of file 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 e45b51003..48208e494 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 @@ -253,6 +253,30 @@ public class AzureAsrHelper: NSObject { } } + /** + * 设置音频配置 + * @param sampleRate 采样率,默认16000 + * @param channels 声道数,默认1 + * @return 设置是否成功 + */ + public func setAudioConfig(sampleRate: Int, channels: Int) -> Bool { + do { + print("设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)") + + // 设置音频流的配置 + audioStream?.setAudioConfig(sampleRate: sampleRate, channels: channels) + + // 设置录音文件的配置 + recordfile?.setAudioConfig(sampleRate: sampleRate, channels: channels) + + print("音频配置设置成功") + return true + } catch { + print("设置音频配置失败: \(error.localizedDescription)") + return false + } + } + /** * 设置识别器 @@ -553,6 +577,32 @@ public class AzureAsrHelper: NSObject { interleaved: true ) } + + /** + * 设置音频配置 + * @param sampleRate 采样率,默认16000 + * @param channels 声道数,默认1 + */ + public func setAudioConfig(sampleRate: Int, channels: Int) { + print("AudioStream设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)") + + // 更新音频格式 + audioFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: Double(sampleRate), + channels: AVAudioChannelCount(channels), + interleaved: true + ) + + // 如果已经有推流,重新创建 + if pushAudioStream != nil { + let audioStreamFormat = SPXAudioStreamFormat() + audioStreamFormat?.initUsingPCM(withSampleRate: UInt(sampleRate), bitsPerSample: 16, channels: UInt(channels)) + pushAudioStream = SPXPushAudioInputStream(audioFormat: audioStreamFormat) + } + + print("AudioStream音频配置设置完成") + } /** * 开始音频输入 */ 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 f07e56855..048575f9a 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 @@ -290,6 +290,18 @@ import os.log azureTtsHelper.stop() result(true) + case "setAudioConfig": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "参数不能为空", details: nil)) + return + } + + let sampleRate = args["sampleRate"] as? Int ?? 16000 + let channels = args["channels"] as? Int ?? 1 + + let success = azureAsrHelper.setAudioConfig(sampleRate: sampleRate, channels: channels) + result(success) + default: result(FlutterMethodNotImplemented) } @@ -549,4 +561,4 @@ extension AzureSpeechPlugin: AudioDataListener { sendTtsEvent(eventMap) } -} +} diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index 9ae9dd608..fe2eefa6e 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -15,7 +15,7 @@ object BleConst { val AUDIO_SERVICE_UUID1: UUID = UUID.fromString("0000ABC0-0001-1111-2222-123456789ABC") /** 接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ - val RECEIVE_AUDIO_CHAR_UUID1: UUID = UUID.fromString("0000ABC2-0001-1111-2222-123456789ABC") + val WRITE_AUDIO_CHAR_UUID1: UUID = UUID.fromString("0000ABC1-0001-1111-2222-123456789ABC") /** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */ val AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb") diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 0426e6409..a883b2452 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -76,9 +76,8 @@ object BleService { private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null - private var audioChar1: BluetoothGattCharacteristic? = null + private var writeChar1: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null - // 扫描相关 private lateinit var scanHandler: Handler private val scanResults = ArrayList() @@ -102,6 +101,18 @@ object BleService { // Opus解码器实例 private var opusManager: OpusManager? = null private val mainHandler = Handler(Looper.getMainLooper()) + + // 音频解码处理线程 + private var audioDecodeThread: HandlerThread? = null + private var audioDecodeHandler: Handler? = null + + // 音频数据队列和处理线程 + private val audioDataQueue = LinkedBlockingQueue() + private var audioQueueProcessorThread: Thread? = null + + // 音频数据缓存 + private val audioDataBuffer = mutableListOf() + private val AUDIO_BUFFER_SIZE = 1280// 1280字节缓存阈值 // 初始化状态 private var isInitialized = false @@ -147,6 +158,16 @@ object BleService { // 初始化Handler scanHandler = Handler(Looper.getMainLooper()) + + // 初始化音频解码线程 + audioDecodeThread = HandlerThread("AudioDecodeThread").apply { + start() + audioDecodeHandler = Handler(looper) + } + Log.d(TAG, "音频解码线程初始化成功") + + // 启动队列处理 + startAudioQueueProcessing() // 初始化OpusManager和OTAManager try { @@ -158,6 +179,7 @@ object BleService { // 根据需要决定是否因为Opus初始化失败而返回false } recordfile = RecordingFile(this.context) + recordfile!!.fileName = "不拆分" isInitialized = true Log.d(TAG, "BLE服务初始化成功") return true @@ -514,7 +536,7 @@ object BleService { notifyChar = null writeChar = null audioChar = null - audioChar1 = null + writeChar1 = null } } @@ -591,14 +613,15 @@ object BleService { // 获取音频服务1特征 val audioSvc1 = g.getService(BleConst.AUDIO_SERVICE_UUID1) - audioChar1 = audioSvc1?.getCharacteristic(BleConst.RECEIVE_AUDIO_CHAR_UUID1) - if (notifyChar == null || writeChar == null) { + writeChar1 = audioSvc1?.getCharacteristic(BleConst.WRITE_AUDIO_CHAR_UUID1) + if (writeChar1 == null) { Log.e(TAG, "未找到主服务所需特征") updateConnectionState(BleConst.STATE_ERROR) return } - + Log.d(TAG, "找到writeChar1服务所需特征") writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + writeChar1?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE //要先设置音频服务的通知,否则接收不到 // 设置音频服务的通知(如果存在) if (audioChar != null) { @@ -607,12 +630,7 @@ object BleService { } else { Log.w(TAG, "音频服务特征未找到") } - if (audioChar1 != null) { - setupNotifications(g, audioChar1) - Log.i(TAG, "音频服务特征找到并设置通知") - } else { - Log.w(TAG, "音频服务特征未找到") - } + // 设置主服务的通知 setupNotifications(g, notifyChar) //开启解码 @@ -711,9 +729,25 @@ object BleService { private fun processAudioData(data: ByteArray) { try { if (opusManager?.isDecodeStream == true) { - recordfile?.saveAudioDataToWav(data) - opusManager?.writeAudioStream(data) + + // 将接收到的数据添加到缓存中 + synchronized(audioDataBuffer) { + audioDataBuffer.addAll(data.toList()) + + // 当缓存达到阈值时,提取完整的帧进行解码 + while (audioDataBuffer.size >= AUDIO_BUFFER_SIZE) { + // 提取一个完整的帧(1280字节) + val frameData = ByteArray(AUDIO_BUFFER_SIZE) + for (i in 0 until AUDIO_BUFFER_SIZE) { + frameData[i] = audioDataBuffer.removeAt(0) + } + + // 将完整的帧加入队列,由专门的线程处理 + audioDataQueue.offer(frameData) + Log.d(TAG, "缓存达到阈值,提取 ${frameData.size} 字节帧进行解码") + } + } } else { // Log.d(TAG, "Opus解码流未启动,忽略音频数据") } @@ -1098,6 +1132,12 @@ object BleService { opusManager?.stopDecodeStream() Log.d(TAG, "已停止正在进行的Opus解码流") } + + // 清理音频数据缓存,确保开始时是干净的状态 + synchronized(audioDataBuffer) { + audioDataBuffer.clear() + Log.d(TAG, "开始解码前已清理音频数据缓存") + } val option = OpusOption() .setHasHead(hasHeader) @@ -1140,6 +1180,13 @@ object BleService { private fun stopOpusStreamDecoding(): Boolean { if (opusManager?.isDecodeStream == true) { opusManager?.stopDecodeStream() + + // 清理音频数据缓存 + synchronized(audioDataBuffer) { + audioDataBuffer.clear() + Log.d(TAG, "已清理音频数据缓存") + } + Log.i(TAG, "已停止Opus数据流解码") return true } @@ -1214,7 +1261,7 @@ object BleService { // ) startOpusStreamDecoding(false, 1, 16000, 40) - Log.i(TAG, "打开解码0xA2") + Log.i(TAG, "打开解码0xA1") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( BleConst.CODEC_CONTROL_DECODE_ON.toByte(), @@ -1228,8 +1275,8 @@ object BleService { * 控制编解码 - 打开解码 */ fun openA2DPDecoder(): Boolean { - Log.i(TAG, "打开编码 0xA1") - startOpusStreamDecoding(false, 1, 16000, 80) + Log.i(TAG, "打开编码 0xA2") + startOpusStreamDecoding(false,2, 16000, 80) // Log.i(TAG, "打开解码...") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( @@ -1320,7 +1367,7 @@ object BleService { * 连接状态检查 */ private fun checkConn(): Boolean = - bluetoothGatt != null && writeChar != null && + bluetoothGatt != null && writeChar != null && writeChar1 != null && connectionState.value == BleConst.STATE_CONNECTED /** @@ -1348,6 +1395,26 @@ object BleService { stopScan() scanHandler.removeCallbacksAndMessages(null) disconnectGatt() + + // 清理音频解码线程和队列 + audioDecodeHandler?.removeCallbacksAndMessages(null) + audioDecodeThread?.quitSafely() + audioDecodeThread = null + audioDecodeHandler = null + + // 停止音频队列处理线程 + audioQueueProcessorThread?.interrupt() + audioQueueProcessorThread = null + audioDataQueue.clear() + + // 清理音频数据缓存 + synchronized(audioDataBuffer) { + audioDataBuffer.clear() + Log.d(TAG, "已清理音频数据缓存") + } + + Log.d(TAG, "音频解码线程和队列处理线程已清理") + // 释放OpusManager opusManager?.let { if (it.isDecodeStream) { @@ -1534,6 +1601,29 @@ object BleService { } } } + + /** + * 启动音频队列处理 + */ + private fun startAudioQueueProcessing() { + audioQueueProcessorThread = Thread { + while (!Thread.currentThread().isInterrupted) { + try { + // 从队列中取出音频数据进行解码 + val audioData = audioDataQueue.take() // 阻塞等待数据 + opusManager?.writeAudioStream(audioData) + } catch (e: InterruptedException) { + Log.d(TAG, "音频队列处理线程被中断") + break + } catch (e: Exception) { + Log.e(TAG, "音频队列处理异常: ${e.message}", e) + } + } + }.apply { + name = "AudioQueueProcessor" + start() + } + } } From 2547ba1758d82a1e20760d338456389beac55b7d Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Fri, 25 Jul 2025 17:40:14 +0800 Subject: [PATCH 04/23] =?UTF-8?q?=E5=8A=A0=E5=85=A5opus=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=EF=BC=8C=E5=9B=9E=E4=BC=A0=E8=AE=BE=E5=A4=87=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunqiinnovation/ble_service/BleService.kt | 89 ++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index a883b2452..d0f52906e 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -12,6 +12,7 @@ import java.util.concurrent.CopyOnWriteArrayList // Import Jieli Opus SDK classes import com.jieli.jl_audio_decode.opus.model.OpusOption import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback +import com.jieli.jl_audio_decode.callback.OnEncodeStreamCallback import com.jieli.jl_audio_decode.opus.OpusManager import com.jieli.jl_audio_decode.exceptions.OpusException @@ -78,6 +79,7 @@ object BleService { private var audioChar: BluetoothGattCharacteristic? = null private var writeChar1: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null + var recordfile1: RecordingFile? = null // 扫描相关 private lateinit var scanHandler: Handler private val scanResults = ArrayList() @@ -173,6 +175,7 @@ object BleService { try { opusManager = OpusManager() startOpusStreamDecoding() + startEncodeStream() Log.d(TAG, "OpusManager初始化成功") } catch (e: OpusException) { Log.e(TAG, "OpusManager初始化失败: ${e.message}", e) @@ -180,6 +183,9 @@ object BleService { } recordfile = RecordingFile(this.context) recordfile!!.fileName = "不拆分" + + recordfile1 = RecordingFile(this.context) + recordfile1!!.fileName = "重新压缩" isInitialized = true Log.d(TAG, "BLE服务初始化成功") return true @@ -953,11 +959,13 @@ object BleService { codecStatus == BleConst.CODEC_CONTROL_A2DP_PLAY || codecStatus == BleConst.CODEC_CONTROL_ENCODE_ON ) { - +recordfile1!!.closeFile() + recordfile1!!.creatingFiles() recordfile!!.closeFile() recordfile!!.creatingFiles() } else if (codecStatus == BleConst.CODEC_CONTROL_CLOSE) { recordfile!!.closeFile() + recordfile1!!.closeFile() } // 声道模式描述 val channelDesc = when (channelMode) { @@ -1152,7 +1160,31 @@ object BleService { override fun onDecodeStream(data: ByteArray?) { if (data != null) { // Log.d(TAG, "Opus解码数据: ${data.size} bytes") - notifyAudioDataReceived(data) + //解码数据再重新编码回去 + val sampleCount = data.size / 4 // 每个样本4字节(左右声道各2字节) + val leftBuffer = ByteArray(sampleCount * 2) // 左声道缓冲区 + val rightBuffer = ByteArray(sampleCount * 2) // 右声道缓冲区 + + // 拆分交错的左右声道数据 + for (i in 0 until sampleCount) { + val stereoIndex = i * 4 + val monoIndex = i * 2 + + // 左声道(低位字节在前,高位字节在后) + leftBuffer[monoIndex] = data[stereoIndex] + leftBuffer[monoIndex + 1] = data[stereoIndex + 1] + + // 右声道 + rightBuffer[monoIndex] = data[stereoIndex + 2] + rightBuffer[monoIndex + 1] = data[stereoIndex + 3] + } + + // // 将左右声道数据分别放入队列 + // leftChannelQueue.offer(leftBuffer) + // rightChannelQueue.offer(rightBuffer) + opusManager?.writeEncodeStream(rightBuffer) + + notifyAudioDataReceived(rightBuffer) } } @@ -1194,7 +1226,60 @@ object BleService { return false } +// ====================================================================================================== + // Opus 编码相关 + // ====================================================================================================== + + private fun startEncodeStream( + hasHeader: Boolean = false, // 通常BLE传输的Opus没有文件头 + channel: Int = 1, + sampleRate: Int = 16000, // 确认设备端Opus编码采样率 + packetSize: Int = 40 // 确认设备端Opus编码帧长,必须与iOS版本frameSize保持一致 + ): Boolean { + if (opusManager == null) { + Log.e(TAG, "OpusManager未初始化,无法开始编码") + return false + } + // 如果已经在解码流,先停止 + if (opusManager?.isEncodeStream == true) { + opusManager?.stopEncodeStream() + Log.d(TAG, "已停止正在进行的Opus编码流") + } + + + var streamStartedSuccessfully = false + opusManager?.startEncodeStream(object : OnEncodeStreamCallback { + override fun onEncodeStream(data: ByteArray?) { + if (data != null) { + // Log.d(TAG, "Opus解码数据: ${data.size} bytes") + //notifyAudioDataReceived(data) + writeChar1!!.value=data + recordfile1!!.saveAudioDataToWav(data) + val isSuccess = bluetoothGatt?.writeCharacteristic(writeChar1) + if (isSuccess == true) { + + Log.d(TAG, "发送opus数据") + } + } + } + override fun onStart() { + streamStartedSuccessfully = true + Log.i(TAG, "Opus数据流编码已开始") + // 可以通过回调通知上层解码已开始 + } + + override fun onComplete(outPath: String?) { + Log.i(TAG, "Opus数据流编码完成: $outPath (通常流式编码不会调用此方法)") + } + + override fun onError(code: Int, message: String?) { + Log.e(TAG, "Opus数据流编码错误: [$code] $message") + // 可以通过回调通知上层解码错误 + } + }) + return true // 暂定为调用即成功 + } // ====================================================================================================== // 公开的命令接口 // ====================================================================================================== From 45e3a73914fae6f59d2f1cf011609cbd3891d65c Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Mon, 28 Jul 2025 20:58:18 +0800 Subject: [PATCH 05/23] =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E8=AF=AD=E9=9F=B3=E5=90=88=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/bindings/initial_binding.dart | 6 +- lib/data/services/asr_service.dart | 6 + lib/data/services/ast_service.dart | 107 ++ .../speech_impl/azure_ast_service.dart | 164 +++ .../speech_impl/volcano_asr_api_service.dart | 6 + .../speech_impl/volcano_asr_service.dart | 6 + .../speech_impl/xunfei_asr_service.dart | 6 + lib/modules/login/views/login_view.dart | 38 +- .../meeting_record_controller.dart | 5 + .../controllers/translation_controller.dart | 148 +- .../yunqiinnovation/agent_service/BleAgent.kt | 4 +- .../agent_service/AgentServiceImpl.swift | 4 + .../azure_speech/android/build.gradle.kts | 1 + .../azure_speech/AzureAsrToAsr.kt | 1223 +++++++++++++++++ .../azure_speech/AzureSpeechPlugin.kt | 338 ++++- .../azure_speech/tools/RecordFile.kt | 2 +- .../yunqiinnovation/ble_service/BleConst.kt | 22 +- .../yunqiinnovation/ble_service/BleService.kt | 454 +++--- .../ble_service/BleServicePlugin.kt | 4 + .../main/kotlin/com/example/ota/OtaPlugin.kt | 3 + 20 files changed, 2336 insertions(+), 211 deletions(-) create mode 100644 lib/data/services/ast_service.dart create mode 100644 lib/data/services/speech_impl/azure_ast_service.dart create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 73826786b..14d6700d5 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -3,6 +3,8 @@ import '../../../data/services/user_portrait.dart'; import '../../../data/services/location_manager.dart'; import '../../../data/services/music_manager.dart'; import '../../../data/services/navigation_manager.dart'; +import '../../../data/services/ast_service.dart'; +import '../../../data/services/speech_impl/azure_ast_service.dart'; import 'package:get/get.dart'; import '../../data/services/meeting/meeting_task_service.dart'; import '../../data/services/meeting/meeting_upload_service.dart'; @@ -22,7 +24,6 @@ class InitialBinding extends Bindings { void dependencies() { Logger.warning('InitialBinding dependencies'); - // 语言管理器(需要最先初始化) // 语言管理器(需要最先初始化) Get.lazyPut(() => LanguageManager(), fenix: true); @@ -30,6 +31,9 @@ class InitialBinding extends Bindings { Get.lazyPut(() => SpeechFactory(), fenix: true); Get.find().initialize(initialType: SpeechServiceType.azure); + // 注册 AST 服务 + Get.lazyPut(() => AzureAstService(), fenix: true); + // 火山翻译服务 Get.lazyPut(() => VolcanoTranslationService(), fenix: true); diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index e6c67563c..62928938d 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -46,6 +46,12 @@ abstract class AsrService { /// 暂停录音 Future pauseRecord(); + /// 设置音频配置 + Future setAudioConfig({ + int sampleRate = 16000, + int channels = 1, + }); + // /// 移动文件到新路径 Future moveFile(String sourcePath, String destPath); diff --git a/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart new file mode 100644 index 000000000..53671ced5 --- /dev/null +++ b/lib/data/services/ast_service.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:typed_data'; + +/// 语音识别服务接口 +abstract class AstService { + /// 支持的语言 + List get supportedLanguages; + + /// 初始化语音识别服务 + Future initialize({required List supportedLanguages}); + + /// 开始录音 + Future enableRecord(String filePath); + + /// 停止录音 + Future stopContinuousTranslation(bool isSave); + + /// 开始录音 + Future path(String filePath); +} + +/// 识别事件类型 +// enum RecognitionEventType { +// /// 最终识别结果 +// finalResult, + +// /// 中间识别结果(实时反馈) +// intermediateResult, + +// /// 音频 +// onAudio, + +// /// 会话开始 +// sessionStarted, + +// /// 会话结束 +// sessionStopped, + +// /// 识别取消 +// canceled, + +// /// 识别错误 +// error, +// } + +/// 识别事件 +// class RecognitionEvent { +// /// 事件类型 +// final RecognitionEventType type; + +// /// 识别文本(仅在 finalResult 和 intermediateResult 类型中有效) +// final String text; + +// /// 检测到的语言 +// final String detectedLanguage; + +// /// 角色 +// final String role; + +// /// 原始音频 +// final Uint8List? audio; + +// /// 错误信息(仅在 error 和 canceled 类型中有效) +// final String error; + +// RecognitionEvent({ +// required this.type, +// this.text = '', +// this.detectedLanguage = '', +// this.role = '', +// this.audio, +// this.error = '', +// }); + +// /// 创建最终结果事件的快捷构造函数 +// factory RecognitionEvent.finalResult({ +// required String text, +// String detectedLanguage = '', +// }) { +// return RecognitionEvent( +// type: RecognitionEventType.finalResult, +// text: text, +// detectedLanguage: detectedLanguage, +// ); +// } + +// /// 创建错误事件的快捷构造函数 +// factory RecognitionEvent.error(String errorMessage) { +// return RecognitionEvent( +// type: RecognitionEventType.error, +// error: errorMessage, +// ); +// } + +// /// 检查是否为最终结果 +// bool get isFinalResult => type == RecognitionEventType.finalResult; + +// /// 检查是否为错误 +// bool get isError => +// type == RecognitionEventType.error || +// type == RecognitionEventType.canceled; + +// @override +// String toString() { +// return 'RecognitionEvent{type: $type, text: $text, detectedLanguage: $detectedLanguage, error: $error}'; +// } +// } diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart new file mode 100644 index 000000000..6e9a4af0a --- /dev/null +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import '../../../data/models/appconfig.dart'; +import 'package:flutter/services.dart'; +import '../../../core/utils/logger.dart'; +import 'package:get/get.dart'; +import '../ast_service.dart'; + +/// 音频源类型 +enum AudioSourceType { + microphone, // 使用设备麦克风 + external // 使用外部提供的音频数据 +} + +/// Azure 语音识别服务 +/// +/// 该服务提供了通过平台通道与原生 Microsoft Speech SDK 交互的接口 +class AzureAstService extends GetxService implements AstService { + static final AzureAstService to = Get.put(AzureAstService()); + static const MethodChannel _channel = MethodChannel('azure_speech/ast'); + static const EventChannel _eventChannel = + EventChannel('azure_speech/ast_events'); + // final GetStorage _storage = GetStorage(); + bool _isInitialized = false; + late final String _subscriptionKey; + late final String _serviceRegion; + late String _baseUrl; + final String _endpoint = '/'; // 修改为根路径 + late final String _accessKey; + late final String _secretKey; + late final String _region; + late final String _service; + + final List _defaultSupportedLanguages = ['zh-CN', 'en-US']; + @override + List get supportedLanguages => _defaultSupportedLanguages; + + // 连续识别相关 + // bool _isContinuousRecognitionActive = false; + // StreamController? _eventStreamController; + // StreamSubscription? _eventSubscription; + + // 最新的识别结果 + String _latestRecognizedText = ''; + String get latestRecognizedText => _latestRecognizedText; + + // 最新检测到的语言 + String _latestDetectedLanguage = ''; + String get latestDetectedLanguage => _latestDetectedLanguage; + + // 当前音频源类型 + AudioSourceType _audioSourceType = AudioSourceType.microphone; + + AzureAstService() { + _loadConfig(); + } + + /// 从环境变量加载配置 + void _loadConfig() { + // final _env = _storage.read("ENV") as Map; + _subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; + _serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; + _accessKey = AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY') ?? ''; + _secretKey = AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY') ?? ''; + _region = AppConfig.env('VOLCANO_TRANSLATION_REGION') ?? 'cn-north-1'; + _service = 'translate'; + _baseUrl = 'https://translate.volcengineapi.com'; + if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { + throw Exception( + '未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); + } + } + + @override + Future enableRecord(String filePath) async { + try { + final bool result = await _channel.invokeMethod('enableRecord', { + 'filePath': filePath, + }); + + return result; + } catch (e) { + Logger.error('开始录音: ${e.toString()}'); + rethrow; + } + } + + @override + Future path(String filePath) async { + try { + final bool result = await _channel.invokeMethod('path', { + 'filePath': filePath, + }); + + return result; + } catch (e) { + Logger.error('开始录音: ${e.toString()}'); + rethrow; + } + } + + @override + Future stopContinuousTranslation(bool isSave) async { + try { + final bool result = + await _channel.invokeMethod('stopContinuousTranslation', { + 'isSave': isSave, + }); + + return result; + } catch (e) { + Logger.error('停止录音: ${e.toString()}'); + rethrow; + } + } + + @override + Future initialize({ + List? supportedLanguages, + bool useExternalAudio = false, + bool useEchoCancellation = false, + }) async { + try { + final List languages = + supportedLanguages ?? _defaultSupportedLanguages; +//底层会初始化前释放 + // // 检查是否需要重新初始化 + // if (_isInitialized) { + // await dispose(); + // } + + // 设置音频源类型 + _audioSourceType = useExternalAudio + ? AudioSourceType.external + : AudioSourceType.microphone; +// Future initialize({ +// required String subscriptionKey, +// required String region, +// required List supportedLanguages, +// required String audioSourceType, +// required String translationAccessKey, +// required String translationSecretKey, +// String translationRegion = 'cn-north-1', +// }); + + final bool result = await _channel.invokeMethod('initialize', { + 'subscriptionKey': _subscriptionKey, + 'region': _serviceRegion, + 'supportedLanguages': languages, + 'audioSourceType': _audioSourceType.toString().split('.').last, + 'translationAccessKey': _accessKey, + 'translationSecretKey': _secretKey, + 'translationRegion': _region, + }); + + _isInitialized = result; + Logger.info('Azure 语音识别服务初始化${result ? '成功' : '失败'}'); + return result; + } catch (e) { + Logger.error('Azure 语音识别服务初始化失败: ${e.toString()}'); + _isInitialized = false; + rethrow; + } + } +} diff --git a/lib/data/services/speech_impl/volcano_asr_api_service.dart b/lib/data/services/speech_impl/volcano_asr_api_service.dart index dabf9e6e0..32844d1bb 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -865,4 +865,10 @@ class VolcanoAsrApiService implements AsrService { // TODO: implement startContinuousRecognition throw UnimplementedError(); } + + @override + Future setAudioConfig({int sampleRate = 16000, int channels = 1}) { + // TODO: implement setAudioConfig + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart index b5e1493ec..412656ddd 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -400,4 +400,10 @@ class VolcanoAsrService extends GetxService implements AsrService { // TODO: implement startContinuousRecognition throw UnimplementedError(); } + + @override + Future setAudioConfig({int sampleRate = 16000, int channels = 1}) { + // TODO: implement setAudioConfig + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index 959f98859..cc94063e8 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -324,4 +324,10 @@ class XunfeiAsrService extends GetxService implements AsrService { // TODO: implement startContinuousRecognition throw UnimplementedError(); } + + @override + Future setAudioConfig({int sampleRate = 16000, int channels = 1}) { + // TODO: implement setAudioConfig + throw UnimplementedError(); + } } diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index 4b46914e8..9bc91fd66 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -232,28 +232,26 @@ class LoginView extends GetView { margin: EdgeInsets.symmetric(horizontal: 8.w), ), Expanded( - child: GetBuilder( - builder: (controller) => TextField( - controller: controller.contactController, - decoration: InputDecoration( - hintText: controller.selectedCountryFlag == '✉️' - ? 'email'.tr // 邮箱 - : 'phoneNumber'.tr, // 手机号码 - hintStyle: TextStyle( - fontSize: 14.sp, - color: isDarkMode - ? Colors.grey[500] - : Colors.black38, // 调整提示文字颜色 - ), - border: InputBorder.none, - fillColor: Colors.transparent, - filled: true, - focusedBorder: InputBorder.none, - ), - style: TextStyle( + child: TextField( + controller: controller.contactController, + decoration: InputDecoration( + hintText: controller.selectedCountryFlag == '✉️' + ? 'email'.tr // 邮箱 + : 'phoneNumber'.tr, // 手机号码 + hintStyle: TextStyle( fontSize: 14.sp, - color: isDarkMode ? Colors.white : Colors.black, + color: isDarkMode + ? Colors.grey[500] + : Colors.black38, // 调整提示文字颜色 ), + border: InputBorder.none, + fillColor: Colors.transparent, + filled: true, + focusedBorder: InputBorder.none, + ), + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black, ), ), ), diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 19ada35c7..26760d3a8 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -256,6 +256,7 @@ class MeetingRecordController extends GetxController // Start audio recording await _asrService.enableRecord("${dir.path}/$fullFileName.wav"); + isRecording.value = true; // Update state fileName.value = fullFileName; @@ -271,12 +272,16 @@ class MeetingRecordController extends GetxController void _startHardwareServices() { switch (audioType.value) { case 0: + // Start audio recording + _asrService.setAudioConfig(sampleRate: 16000, channels: 1); _bleManager.openEncoder(); break; case 1: + _asrService.setAudioConfig(sampleRate: 16000, channels: 1); _bleManager.openDecoder(); break; case 2: + _asrService.setAudioConfig(sampleRate: 16000, channels: 2); _bleManager.openA2DPDecoder(); break; } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index a58ddb678..1627b595a 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -9,6 +9,7 @@ import 'package:get_storage/get_storage.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; +import '../../../data/services/ast_service.dart'; import '../../../data/services/music_manager.dart'; import '../../../data/services/volcano_translation_service.dart'; import '../../../data/services/tts_service.dart'; @@ -34,6 +35,8 @@ class TranslationController extends GetxController { final VolcanoTranslationService _translationService = Get.find(); final TtsService _ttsService = Get.find(); + + final AstService _astService = Get.find(); final LanguageManager _languageManager = Get.find(); final GetStorage _storage = GetStorage(); // 蓝牙服务 @@ -363,6 +366,144 @@ class TranslationController extends GetxController { } } + // 初始化通话模式的语音翻译服务 + Future _initializeCallModeTranslationService() async { + try { + Logger.info('开始初始化通话模式语音翻译服务'); + // 为通话模式配置特殊的ASR设置 + final List callModeLanguages = [ + sourceLanguageCode.value, + targetLanguageCode.value + ]; +// Future initialize({ +// required String subscriptionKey, +// required String region, +// required List supportedLanguages, +// required String audioSourceType, +// required String translationAccessKey, +// required String translationSecretKey, +// String translationRegion = 'cn-north-1', +// }); + // 重新初始化ASR服务以支持通话音频源 + await _astService.initialize(supportedLanguages: callModeLanguages); + await _astService.path("${dir.path}/8_mic.wav"); + // 配置实时翻译参数 + await _configureCallModeTranslation(); + + Logger.info('通话模式语音翻译服务初始化完成'); + return; + } catch (e) { + Logger.error('通话模式语音翻译服务初始化失败: ${e.toString()}'); + } + } + + // 配置通话模式的翻译参数 + Future _configureCallModeTranslation() async { + try { + // 设置通话模式的特殊配置 + // 1. 更短的识别超时时间,适应通话场景 + // 2. 更高的识别敏感度 + // 3. 噪声抑制优化 + + // 这里可以调用ASR服务的特殊配置方法 + // await _asrService.configureForCallMode( + // endSilenceTimeout: 200, // 更短的静音超时 + // noiseReduction: true, // 启用噪声抑制 + // echoCancellation: true, // 启用回声消除 + // ); + + Logger.info('通话模式翻译参数配置完成'); + } catch (e) { + Logger.error('通话模式翻译参数配置失败: ${e.toString()}'); + } + } + + // // 处理通话模式的翻译结果 + // Future _handleCallModeTranslation(String sourceText) async { + // try { + // // 在通话模式下,翻译结果可能需要特殊处理 + // // 例如:发送到蓝牙设备、显示在特定UI等 + + // final translationResult = await _translationService.translateText( + // text: sourceText, + // sourceLanguageCode: sourceLanguageCode.value, + // targetLanguageCode: targetLanguageCode.value, + // ); + + // if (translationResult != null && translationResult.isNotEmpty) { + // // 通话模式下的特殊处理 + // await _processCallModeTranslationResult(sourceText, translationResult); + // } + // } catch (e) { + // Logger.error('通话模式翻译处理失败: ${e.toString()}'); + // } + // } + + // // 处理通话模式的翻译结果 + // Future _processCallModeTranslationResult(String sourceText, String translatedText) async { + // try { + // // 1. 更新UI显示 + // final newItem = TranslationItem( + // sourceText: sourceText, + // translatedText: translatedText, + // sourceLanguageCode: sourceLanguageCode.value, + // targetLanguageCode: targetLanguageCode.value, + // timestamp: DateTime.now(), + // sessionId: currentSessionId ?? DateTime.now().millisecondsSinceEpoch.toString(), + // isFirstInSession: translationHistory.isEmpty, + // isIntermediate: false, + // ); + + // translationHistory.add(newItem); + // translationHistory.refresh(); + // _scrollToBottom(); + // saveTranslationHistory(); + + // // 2. 通话模式下可能需要将翻译结果发送到蓝牙设备 + // // 或者通过其他方式传输给通话对方 + // await _sendTranslationToCallParty(translatedText); + + // // 3. 记录统计信息 + // _recordCallModeUsageStats(sourceText, translatedText); + + // Logger.info('通话模式翻译结果处理完成: $sourceText -> $translatedText'); + // } catch (e) { + // Logger.error('通话模式翻译结果处理失败: ${e.toString()}'); + // } + // } + + // // 将翻译结果发送给通话对方 + // Future _sendTranslationToCallParty(String translatedText) async { + // try { + // // 这里可以实现将翻译结果发送给通话对方的逻辑 + // // 例如:通过蓝牙、网络等方式 + + // // 示例:通过蓝牙发送 + // await bleManager.sendTranslationResult(translatedText); + + // Logger.info('翻译结果已发送给通话对方: $translatedText'); + // } catch (e) { + // Logger.error('发送翻译结果失败: ${e.toString()}'); + // } + // } + + // // 记录通话模式的使用统计 + // void _recordCallModeUsageStats(String sourceText, String translatedText) { + // try { + // _usageService.recordTranslationApiCall( + // sourceText: sourceText, + // targetText: translatedText, + // sourceLanguage: _languageManager.getChineseNameByAsrCode(sourceLanguageCode.value) ?? '未知', + // targetLanguage: _languageManager.getChineseNameByAsrCode(targetLanguageCode.value) ?? '未知', + // mode: 'call', // 明确标记为通话模式 + // ); + + // Logger.info('通话模式使用统计已记录'); + // } catch (e) { + // Logger.error('记录通话模式统计失败: ${e.toString()}'); + // } + // } + @override void onClose() { stopRecognition(); @@ -410,6 +551,8 @@ class TranslationController extends GetxController { final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); await _asrService.enableRecord( "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav"); + await _astService.enableRecord( + "${dir.path}/${currentModeTitle.value.tr}_${formattedTime}_mic.wav"); } Future stopRecording() async { @@ -475,6 +618,9 @@ class TranslationController extends GetxController { _audioSourceType = true; isTtsEnabled.value = false; Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); + + // 初始化语音翻译服务 + await _initializeCallModeTranslationService(); } else { // 开始连续语音识别 _audioSourceType = false; @@ -526,7 +672,7 @@ class TranslationController extends GetxController { try { await _asrService.stopContinuousRecognition(); - + await _astService.stopContinuousTranslation(true); // 停止ASR活跃时长计时 _stopAsrActiveTracking(); diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index a62e67ffd..dc8a689c4 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -135,7 +135,9 @@ Log.d(TAG, "手动启动语音识别: ") AgentService.pushAudioData(data) // 可选:处理音频数据 } - + override fun onAudioDataReceived1(data: ByteArray) { + + } /** * 处理唤醒信号 * 在收到唤醒信号时启动语音识别 diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 753dd87c4..4e1c436bb 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -1166,6 +1166,10 @@ extension AgentServiceImpl: BleService.Callback { pushAudioData(data) } + func onAudioDataReceived1(data: Data) { + + } + func onWakeupSignalReceived() { stopTts() diff --git a/local_plugins/azure_speech/android/build.gradle.kts b/local_plugins/azure_speech/android/build.gradle.kts index 4d6a58288..c77148988 100644 --- a/local_plugins/azure_speech/android/build.gradle.kts +++ b/local_plugins/azure_speech/android/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { // 添加Microsoft语音SDK implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.43.0") implementation(project(":speech")) + implementation("com.squareup.okhttp3:okhttp:4.12.0") add("compileOnly", project(":ble_service")) } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt new file mode 100644 index 000000000..ad89b9409 --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt @@ -0,0 +1,1223 @@ +package com.yunqiinnovation.azure_speech + +import android.content.Context +import android.media.AudioManager +import android.util.Log +import com.microsoft.cognitiveservices.speech.* +import com.microsoft.cognitiveservices.speech.audio.* +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import org.json.JSONObject +import org.json.JSONArray +import java.net.URLEncoder +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.* +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import com.yunqiinnovation.azure_speech.tools.RecordFile +/** + * 整合的语音翻译服务 + * 集成ASR语音识别、翻译服务和TTS语音合成 + * 实现音频输入 -> 语音识别 -> 翻译 -> 语音合成 -> 音频输出的完整流程 + */ +class IntegratedSpeechTranslationService( + private val context: Context +) : CoroutineScope { + + companion object { + private const val TAG = "IntegratedSpeechService" + + // 音频配置常量 + private const val SAMPLE_RATE = 16000 + private const val CHANNELS = 1 + private const val BITS_PER_SAMPLE = 16 + private const val BUFFER_SIZE = 4096 + + // 超时配置 + private const val END_SILENCE_TIMEOUT = "300" + private const val SEGMENTATION_SILENCE_TIMEOUT = "300" + private const val INITIAL_SILENCE_TIMEOUT = "200" + } + + // 协程上下文 + private val job = SupervisorJob() + override val coroutineContext: CoroutineContext = Dispatchers.Main + job + + // Azure服务组件 + private var speechConfig: SpeechConfig? = null + private var recognizer: SpeechRecognizer? = null + private var synthesizer: SpeechSynthesizer? = null + + // 翻译服务 + private var translationService: TranslationServiceInterface? = null + + // 音频处理 + private var audioProcessor: AudioProcessor? = null + private var audioConfig: AudioConfig? = null + // 录音文件处理 + var recordfile: RecordFile? = null + var filePath: String? = null + // 配置管理 + private var serviceConfig = ServiceConfiguration() + + // 状态管理 + private val serviceState = ServiceState() + + // 事件回调 + private var eventCallback: ServiceEventCallback? = null + + // 语音映射缓存 + private val voiceCache = ConcurrentHashMap() + + /** + * 服务配置类 + */ + data class ServiceConfiguration( + var sourceLanguage: String = "zh-CN", + var targetLanguage: String = "en-US", + var currentVoice: String = "en-US-AriaNeural", + var speechRate: String = "0%", + var speechPitch: String = "0%", + var speechVolume: String = "100%", + var enableContinuousRecognition: Boolean = true, + var enableAutoLanguageDetection: Boolean = false, + var maxRetryAttempts: Int = 3, + var translationTimeout: Long = 10000L + ) + + /** + * 服务状态类 + */ + data class ServiceState( + val isInitialized: AtomicBoolean = AtomicBoolean(false), + val isRecognizing: AtomicBoolean = AtomicBoolean(false), + val isSynthesizing: AtomicBoolean = AtomicBoolean(false), + val isTranslating: AtomicBoolean = AtomicBoolean(false) + ) + + /** + * 服务事件回调接口 + */ + interface ServiceEventCallback { + fun onServiceInitialized() + fun onRecognizing(text: String, language: String, confidence: Float) + fun onRecognized(text: String, language: String, confidence: Float) + fun onTranslated(originalText: String, translatedText: String, targetLanguage: String) + fun onTranslationStarted(text: String) + fun onTranslationFailed(text: String, error: String) + fun onSynthesisStarted(text: String) + fun onSynthesisCompleted(text: String) + fun onSynthesisFailed(text: String, error: String) + fun onSynthesisProgress(text: String, progress: Float) + fun onRecognitionStarted() + fun onRecognitionStopped() + fun onStateChanged(component: String, isActive: Boolean) + fun onError(component: String, error: String) + } + + /** + * 翻译服务接口 + */ + interface TranslationServiceInterface { + suspend fun initialize(config: Map): Boolean + suspend fun translateText( + text: String, + sourceLanguage: String, + targetLanguage: String + ): TranslationResult + + fun dispose() + } + + /** + * 翻译结果类 + */ + data class TranslationResult( + val success: Boolean, + val translatedText: String? = null, + val error: String? = null, + val confidence: Float = 0f + ) + + /** + * 初始化整合服务 + */ + suspend fun initialize( + azureConfig: AzureConfiguration, + translationConfig: TranslationConfiguration, + serviceConfig: ServiceConfiguration? = null, + callback: ServiceEventCallback + ): Boolean = withContext(Dispatchers.IO) { + try { + this@IntegratedSpeechTranslationService.eventCallback = callback + serviceConfig?.let { this@IntegratedSpeechTranslationService.serviceConfig = it } + + Log.d(TAG, "开始初始化整合服务,azureConfig:${azureConfig},translationConfig:${translationConfig}") + + // 初始化Azure语音服务 + if (!initializeAzureServices(azureConfig)) { + callback.onError("Initialization", "Azure服务初始化失败") + return@withContext false + } + + // 初始化翻译服务 + if (!initializeTranslationService(translationConfig)) { + callback.onError("Initialization", "翻译服务初始化失败") + return@withContext false + } + + // 初始化音频处理器 + initializeAudioProcessor() + + // 设置语音识别器 + setupSpeechRecognizer() + + // 设置语音合成器 + setupSpeechSynthesizer() + + serviceState.isInitialized.set(true) + Log.d(TAG, "整合服务初始化成功") + + withContext(Dispatchers.Main) { + callback.onServiceInitialized() + } +startContinuousTranslation() + recordfile = RecordFile; + return@withContext true + } catch (e: Exception) { + Log.e(TAG, "初始化失败", e) + withContext(Dispatchers.Main) { + callback.onError( + "Initialization", + "初始化失败: ${e.message}" + ) + } + return@withContext false + } + } + + /** + * 初始化Azure服务 + */ + private suspend fun initializeAzureServices(config: AzureConfiguration): Boolean { + return try { + Log.d(TAG, "开始初始化Azure服务${config.subscriptionKey},${config.region}") + speechConfig = + SpeechConfig.fromSubscription(config.subscriptionKey, config.region).apply { + speechRecognitionLanguage = serviceConfig.sourceLanguage + setSpeechSynthesisVoiceName(getVoiceForLanguage(serviceConfig.targetLanguage)) + setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff16Khz16BitMonoPcm) + + // 优化配置 + setProperty("SpeechServiceConnection_EndSilenceTimeoutMs", END_SILENCE_TIMEOUT) + setProperty("Speech_SegmentationSilenceTimeoutMs", SEGMENTATION_SILENCE_TIMEOUT) + setProperty( + "SpeechServiceConnection_InitialSilenceTimeoutMs", + INITIAL_SILENCE_TIMEOUT + ) + setProperty("SpeechServiceConnection_RecoMode", "INTERACTIVE") + + // 启用详细结果 + setProperty("SpeechServiceResponse_RequestDetailedResultTrueFalse", "true") + + // 自动语言检测 + if (serviceConfig.enableAutoLanguageDetection) { + setProperty("SpeechServiceConnection_LanguageIdMode", "Continuous") + } + } + + Log.d(TAG, "Azure语音服务配置完成") + true + } catch (e: Exception) { + Log.e(TAG, "Azure服务初始化失败", e) + false + } + } + + /** + * 初始化翻译服务 + */ + private suspend fun initializeTranslationService(config: TranslationConfiguration): Boolean { + return try { + translationService = VolcanoTranslationServiceImpl().apply { + initialize(config.toConfigMap()) + } + Log.d(TAG, "翻译服务初始化完成") + true + } catch (e: Exception) { + Log.e(TAG, "翻译服务初始化失败", e) + false + } + } + + /** + * 初始化音频处理器 + */ + private fun initializeAudioProcessor() { + audioProcessor = AudioProcessor().apply { + initialize() + } + audioConfig = AudioConfig.fromStreamInput(audioProcessor?.getAudioStream()) + Log.d(TAG, "音频处理器初始化完成") + } + + /** + * 设置语音识别器 + */ + private fun setupSpeechRecognizer() { + recognizer?.close() + recognizer = SpeechRecognizer(speechConfig, audioConfig).apply { + + // 识别中事件 + recognizing.addEventListener { _, event -> + if (event.result.text.isNotEmpty()) { + val confidence = extractConfidence(event.result) + Log.d(TAG, "识别中事件: ${event.result.text}") + eventCallback?.onRecognizing( + event.result.text, + serviceConfig.sourceLanguage, + confidence + ) + } + } + + // 识别完成事件 + recognized.addEventListener { _, event -> + when (event.result.reason) { + ResultReason.RecognizedSpeech -> { + if (event.result.text.isNotEmpty()) { + val confidence = extractConfidence(event.result) + eventCallback?.onRecognized( + event.result.text, + serviceConfig.sourceLanguage, + confidence + ) + Log.d(TAG, "识别完成事件: ${event.result.text}") + // 触发翻译流程 + launch { + processTranslationAndSynthesis(event.result.text) + } + } + } + + ResultReason.NoMatch -> { + Log.d(TAG, "未识别到语音") + } + + else -> { + Log.w(TAG, "识别结果: ${event.result.reason}") + } + } + } + + // 会话开始事件 + sessionStarted.addEventListener { _, _ -> + Log.d(TAG, "会话开始事件") + serviceState.isRecognizing.set(true) + eventCallback?.onRecognitionStarted() + eventCallback?.onStateChanged("Recognition", true) + } + + // 会话停止事件 + sessionStopped.addEventListener { _, _ -> + Log.d(TAG, "会话停止事件") + serviceState.isRecognizing.set(false) + eventCallback?.onRecognitionStopped() + eventCallback?.onStateChanged("Recognition", false) + } + + // 取消事件 + canceled.addEventListener { _, event -> + Log.d(TAG, "取消事件") + serviceState.isRecognizing.set(false) + val errorDetails = event.errorDetails ?: "未知错误" + eventCallback?.onError("Recognition", "识别被取消: $errorDetails") + eventCallback?.onStateChanged("Recognition", false) + } + } + + Log.d(TAG, "语音识别器设置完成") + } + + /** + * 设置语音合成器 + */ + private fun setupSpeechSynthesizer() { + synthesizer?.close() + synthesizer = + SpeechSynthesizer(speechConfig, AudioConfig.fromDefaultSpeakerOutput()).apply { + + // 合成开始事件 + SynthesisStarted.addEventListener { _, _ -> + Log.d(TAG, "合成开始事件") + serviceState.isSynthesizing.set(true) + eventCallback?.onStateChanged("Synthesis", true) + } + + // 合成进行中事件 + Synthesizing.addEventListener { _, event -> + Log.d(TAG, "合成进行中事件") + // 计算进度(简化版) + val progress = 0.5f // 实际应用中可以根据音频数据计算 + eventCallback?.onSynthesisProgress("", progress) + } + + // 合成完成事件 + SynthesisCompleted.addEventListener { _, event -> + Log.d(TAG, "合成完成事件") + serviceState.isSynthesizing.set(false) + eventCallback?.onSynthesisCompleted("") + eventCallback?.onStateChanged("Synthesis", false) + } + + // 合成取消事件 + SynthesisCanceled.addEventListener { _, event -> + Log.d(TAG, "合成取消事件") + serviceState.isSynthesizing.set(false) + val reason = event.result.reason + eventCallback?.onError("Synthesis", "语音合成取消: $reason") + eventCallback?.onStateChanged("Synthesis", false) + } + } + + Log.d(TAG, "语音合成器设置完成") + } + + /** + * 开始连续语音翻译 + */ + suspend fun startContinuousTranslation(): Boolean { + if (!serviceState.isInitialized.get()) { + eventCallback?.onError("Service", "服务未初始化") + return false + } + + if (serviceState.isRecognizing.get()) { + Log.w(TAG, "语音识别已在进行中") + return true + } + + return try { + + audioProcessor?.startRecording() + + if (serviceConfig.enableContinuousRecognition) { + Log.d(TAG, "startContinuousTranslation") + recognizer?.startContinuousRecognitionAsync() + } + + Log.d(TAG, "开始连续语音翻译") + true + } catch (e: Exception) { + Log.e(TAG, "启动连续翻译失败", e) + eventCallback?.onError("Service", "启动失败: ${e.message}") + false + } + } + fun enableRecord(filePath: String) { + Log.i(TAG, "开启录音:") + + recordfile!!.closeFile(true) + + + recordfile!!.creatingFiles(filePath) + + } + + /** + * 停止连续语音翻译 + */ + fun stopContinuousTranslation() { + try { + recognizer?.stopContinuousRecognitionAsync() + audioProcessor?.stopRecording() + recordfile!!.closeFile(true) + // 等待当前处理完成 + while (serviceState.isTranslating.get() || serviceState.isSynthesizing.get()) { + Thread.sleep(100) + } + + Log.d(TAG, "停止连续语音翻译") + } catch (e: Exception) { + Log.e(TAG, "停止翻译失败", e) + eventCallback?.onError("Service", "停止失败: ${e.message}") + } + } + + /** + * 处理翻译和合成流程 + */ + private suspend fun processTranslationAndSynthesis(text: String) { + if (serviceState.isTranslating.get()) { + Log.w(TAG, "翻译正在进行中,跳过当前请求") + return + } + + serviceState.isTranslating.set(true) + eventCallback?.onTranslationStarted(text) + eventCallback?.onStateChanged("Translation", true) + + try { + val translationResult = withTimeout(serviceConfig.translationTimeout) { + translationService?.translateText( + text = text, + sourceLanguage = serviceConfig.sourceLanguage, + targetLanguage = serviceConfig.targetLanguage + ) + } + + serviceState.isTranslating.set(false) + eventCallback?.onStateChanged("Translation", false) + + when { + translationResult?.success == true && !translationResult.translatedText.isNullOrEmpty() -> { + eventCallback?.onTranslated( + text, + translationResult.translatedText, + serviceConfig.targetLanguage + ) + synthesizeText(translationResult.translatedText) + } + + translationResult?.error != null -> { + eventCallback?.onTranslationFailed(text, translationResult.error) + } + + else -> { + eventCallback?.onTranslationFailed(text, "翻译服务返回空结果") + } + } + + } catch (e: TimeoutCancellationException) { + serviceState.isTranslating.set(false) + eventCallback?.onStateChanged("Translation", false) + eventCallback?.onTranslationFailed(text, "翻译超时") + } catch (e: Exception) { + serviceState.isTranslating.set(false) + eventCallback?.onStateChanged("Translation", false) + eventCallback?.onTranslationFailed(text, "翻译异常: ${e.message}") + } + } + + /** + * 语音合成 + */ + private suspend fun synthesizeText(text: String) { + if (serviceState.isSynthesizing.get()) { + Log.w(TAG, "语音合成正在进行中") + return + } + + try { + Log.d(TAG, "开始语音合成: $text") + serviceState.isSynthesizing.set(true) + eventCallback?.onSynthesisStarted(text) + + val ssml = generateOptimizedSsml(text) + + // 使用同步方法确保合成完成后再播放 + val result = synthesizer?.SpeakSsml(ssml) + + if (result?.reason == ResultReason.SynthesizingAudioCompleted) { + Log.d(TAG, "语音合成成功,音频已播放") + eventCallback?.onSynthesisCompleted(text) + } else { + Log.e(TAG, "语音合成失败: ${result?.reason}") + eventCallback?.onSynthesisFailed(text, "合成失败: ${result?.reason}") + } + + } catch (e: Exception) { + Log.e(TAG, "语音合成失败", e) + eventCallback?.onSynthesisFailed(text, "合成失败: ${e.message}") + } finally { + serviceState.isSynthesizing.set(false) + } + } + + /** + * 生成优化的SSML + */ + private fun generateOptimizedSsml(text: String): String { + val cleanText = cleanTextForSynthesis(text) + val escapedText = escapeXmlText(cleanText) + + return """ + + + + $escapedText + + + + """.trimIndent() + } + + /** + * 清理文本用于合成 + */ + private fun cleanTextForSynthesis(text: String): String { + return text + .replace(Regex("https?://[^\\s]+"), "") // 移除URL + .replace(Regex("[\\p{So}\\p{Sk}]"), "") // 移除表情符号 + .replace(Regex("\\s+"), " ") // 合并多个空格 + .trim() + } + + /** + * XML文本转义 + */ + private fun escapeXmlText(text: String): String { + return text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + + /** + * 根据语言获取对应的语音 + */ + private fun getVoiceForLanguage(language: String): String { + return voiceCache.getOrPut(language) { + when (language) { + "zh-CN" -> "zh-CN-XiaoxiaoNeural" + "zh-TW" -> "zh-TW-HsiaoChenNeural" + "en-US" -> "en-US-AriaNeural" + "en-GB" -> "en-GB-SoniaNeural" + "ja-JP" -> "ja-JP-NanamiNeural" + "ko-KR" -> "ko-KR-SunHiNeural" + "fr-FR" -> "fr-FR-DeniseNeural" + "de-DE" -> "de-DE-KatjaNeural" + "es-ES" -> "es-ES-ElviraNeural" + "ru-RU" -> "ru-RU-SvetlanaNeural" + "ar-SA" -> "ar-SA-ZariyahNeural" + "pt-BR" -> "pt-BR-FranciscaNeural" + "it-IT" -> "it-IT-ElsaNeural" + else -> "en-US-AriaNeural" + }.also { + serviceConfig.currentVoice = it + } + } + } + + /** + * 提取识别置信度 + */ + private fun extractConfidence(result: SpeechRecognitionResult): Float { + return try { + // 从结果中提取置信度信息 + // 这里需要根据实际的Azure SDK API API来实现 + 0.8f // 默认值 + } catch (e: Exception) { + 0.5f + } + } + + /** + * 更新服务配置 + */ + fun updateConfiguration(newConfig: ServiceConfiguration) { + val needsRecognizerUpdate = newConfig.sourceLanguage != serviceConfig.sourceLanguage || + newConfig.enableAutoLanguageDetection != serviceConfig.enableAutoLanguageDetection + + val needsSynthesizerUpdate = newConfig.targetLanguage != serviceConfig.targetLanguage || + newConfig.speechRate != serviceConfig.speechRate || + newConfig.speechPitch != serviceConfig.speechPitch || + newConfig.speechVolume != serviceConfig.speechVolume + + serviceConfig = newConfig + + if (needsRecognizerUpdate) { + speechConfig?.speechRecognitionLanguage = serviceConfig.sourceLanguage + setupSpeechRecognizer() + } + + if (needsSynthesizerUpdate) { + speechConfig?.setSpeechSynthesisVoiceName(getVoiceForLanguage(serviceConfig.targetLanguage)) + setupSpeechSynthesizer() + } + + Log.d(TAG, "服务配置已更新") + } + + /** + * 设置音频输出设备 + */ +fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { + try { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + audioManager?.let { manager -> + when (device) { + com.deep_voice.speech.tts.AudioOutputDevice.SPEAKER -> { + manager.mode = AudioManager.MODE_IN_COMMUNICATION + manager.isSpeakerphoneOn = true + } + + com.deep_voice.speech.tts.AudioOutputDevice.HEADPHONES -> { + manager.mode = AudioManager.MODE_NORMAL + manager.isSpeakerphoneOn = false + } + + com.deep_voice.speech.tts.AudioOutputDevice.DEFAULT -> { + manager.mode = AudioManager.MODE_NORMAL + manager.isSpeakerphoneOn = false + } + } + } + Log.d(TAG, "音频输出设备设置为: $device") + } catch (e: Exception) { + Log.e(TAG, "设置音频输出设备失败", e) + eventCallback?.onError("Audio", "设置音频输出设备失败: ${e.message}") + } +} + + /** + * 切换语言对 + */ + fun switchLanguages() { + val newConfig = serviceConfig.copy( + sourceLanguage = serviceConfig.targetLanguage, + targetLanguage = serviceConfig.sourceLanguage + ) + updateConfiguration(newConfig) + Log.d(TAG, "语言对已切换: ${newConfig.sourceLanguage} <-> ${newConfig.targetLanguage}") + } + + /** + * 推送外部音频数据 + */ + fun pushAudioData(audioData: ByteArray) { + audioProcessor?.pushAudioData(audioData) + } + + /** + * 获取服务状态 + */ + fun getServiceStatus(): Map { + return mapOf( + "initialized" to serviceState.isInitialized.get(), + "recognizing" to serviceState.isRecognizing.get(), + "translating" to serviceState.isTranslating.get(), + "synthesizing" to serviceState.isSynthesizing.get() + ) + } + + /** + * 释放资源 + */ + fun dispose() { + try { + // 停止所有活动 + launch { + stopContinuousTranslation() + } + + // 释放Azure资源 + recognizer?.close() + synthesizer?.close() + speechConfig?.close() + + // 释放音频处理器 + audioProcessor?.dispose() + + // 释放翻译服务 + translationService?.dispose() + + // 取消协程 + job.cancel() + + // 清理缓存 + voiceCache.clear() + + serviceState.isInitialized.set(false) + + Log.d(TAG, "服务资源已释放") + } catch (e: Exception) { + Log.e(TAG, "释放资源失败", e) + } + } + + /** + * 音频处理器类 + */ + inner class AudioProcessor { + private var pushAudioStream: PushAudioInputStream? = null + private val audioQueue = LinkedBlockingQueue() + private val isRunning = AtomicBoolean(false) + private var processingThread: Thread? = null + + fun initialize() { + val format = AudioStreamFormat.getWaveFormatPCM( + SAMPLE_RATE.toLong(), + BITS_PER_SAMPLE.toShort(), + CHANNELS.toShort() + ) + pushAudioStream = AudioInputStream.createPushStream(format) + isRunning.set(true) + startProcessingThread() + } + + private fun startProcessingThread() { + processingThread = Thread { + while (isRunning.get()) { + try { + val audioData = + audioQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS) + audioData?.let { + Log.d(TAG, "音频处理: ${it}") + pushAudioStream?.write(it) + recordfile?.saveAudioDataToWav(it) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + break + } catch (e: Exception) { + Log.e(TAG, "音频处理异常", e) + } + } + }.apply { + name = "AudioProcessingThread" + start() + } + } + + fun getAudioStream(): PushAudioInputStream? = pushAudioStream + + fun startRecording() { + Log.d(TAG, "开始音频录制") + } + + fun stopRecording() { + Log.d(TAG, "停止音频录制") + } + + fun pushAudioData(audioData: ByteArray) { + if (isRunning.get()) { + audioQueue.offer(audioData) + } + } + + fun dispose() { + isRunning.set(false) + processingThread?.interrupt() + pushAudioStream?.close() + audioQueue.clear() + } + } +} + +/** + * Azure配置类 + */ +data class AzureConfiguration( + val subscriptionKey: String, + val region: String +) + +/** + * 翻译配置类 + */ +data class TranslationConfiguration( + val accessKey: String, + val secretKey: String, + val region: String = "cn-north-1" +) { + fun toConfigMap(): Map { + return mapOf( + "accessKey" to accessKey, + "secretKey" to secretKey, + "region" to region + ) + } +} + + + +/** + * 火山翻译服务实现 + */ +class VolcanoTranslationServiceImpl : + IntegratedSpeechTranslationService.TranslationServiceInterface { + companion object { + private const val TAG = "VolcanoTranslationService" + private const val BASE_URL = "https://translate.volcengineapi.com" + private const val ENDPOINT = "/" + private const val SERVICE = "translate" + private const val VERSION = "2020-06-01" + private const val ACTION = "TranslateText" + private const val ALGORITHM = "HMAC-SHA256" + } + + private var isInitialized = false + private var accessKey: String = "" + private var secretKey: String = "" + private var region: String = "cn-north-1" + private var maxRetryAttempts: Int = 3 + private var timeout: Long = 10000L + + // HTTP客户端 + private val httpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + // 语言代码映射 + private val languageCodeMap = mapOf( + "zh-CN" to "zh", + "zh-TW" to "zh-Hant", + "en-US" to "en", + "en-GB" to "en", + "ja-JP" to "ja", + "ko-KR" to "ko", + "fr-FR" to "fr", + "de-DE" to "de", + "es-ES" to "es", + "ru-RU" to "ru", + "ar-SA" to "ar", + "pt-BR" to "pt", + "it-IT" to "it", + "th-TH" to "th", + "vi-VN" to "vi", + "hi-IN" to "hi" + ) + + override suspend fun initialize(config: Map): Boolean { + return try { + accessKey = config["accessKey"] ?: "" + secretKey = config["secretKey"] ?: "" + region = config["region"] ?: "cn-north-1" + maxRetryAttempts = config["maxRetryAttempts"]?.toIntOrNull() ?: 3 + timeout = config["timeout"]?.toLongOrNull() ?: 10000L + + if (accessKey.isEmpty() || secretKey.isEmpty()) { + Log.e(TAG, "缺少必要的API密钥") + return false + } + + isInitialized = true + Log.d(TAG, "火山翻译服务初始化成功") + true + } catch (e: Exception) { + Log.e(TAG, "初始化失败", e) + false + } + } + + override suspend fun translateText( + text: String, + sourceLanguage: String, + targetLanguage: String + ): IntegratedSpeechTranslationService.TranslationResult { + if (!isInitialized) { + return IntegratedSpeechTranslationService.TranslationResult( + success = false, + error = "翻译服务未初始化" + ) + } + + return withContext(Dispatchers.IO) { + performTranslationWithRetry(text, sourceLanguage, targetLanguage) + } + } + + private suspend fun performTranslationWithRetry( + text: String, + sourceLanguage: String, + targetLanguage: String + ): IntegratedSpeechTranslationService.TranslationResult { + var lastException: Exception? = null + + repeat(maxRetryAttempts) { attempt -> + try { + val result = performTranslation(text, sourceLanguage, targetLanguage) + if (result.success) { + return result + } + lastException = Exception(result.error) + } catch (e: Exception) { + lastException = e + Log.w(TAG, "翻译请求失败,尝试 ${attempt + 1}/$maxRetryAttempts", e) + + // 重试延迟 + if (attempt < maxRetryAttempts - 1) { + delay(500L * (attempt + 1)) + } + } + } + + return IntegratedSpeechTranslationService.TranslationResult( + success = false, + error = "翻译失败: ${lastException?.message ?: "未知错误"}" + ) + } + + private suspend fun performTranslation( + text: String, + sourceLanguage: String, + targetLanguage: String + ): IntegratedSpeechTranslationService.TranslationResult { + try { + // 获取语言代码 + val sourceCode = getLanguageCode(sourceLanguage) + val targetCode = getLanguageCode(targetLanguage) + + if (sourceCode.isEmpty() || targetCode.isEmpty()) { + return IntegratedSpeechTranslationService.TranslationResult( + success = false, + error = "不支持的语言代码: $sourceLanguage -> $targetLanguage" + ) + } + + // 构建请求体 + val requestBody = JSONObject().apply { + put("SourceLanguage", sourceCode) + put("TargetLanguage", targetCode) + put("TextList", JSONArray().put(text)) + } + + // 查询参数 + val queryParams = mapOf( + "Action" to ACTION, + "Version" to VERSION, + "Region" to region, + "Service" to SERVICE + ) + + // 生成签名 + val headers = generateSignature("POST", requestBody, queryParams) + + // 构建URL + val urlBuilder = BASE_URL.toHttpUrl().newBuilder() + queryParams.forEach { (key, value) -> + urlBuilder.addQueryParameter(key, value) + } + val url = urlBuilder.build() + + // 构建请求 + val requestBodyObj = RequestBody.create( + "application/json; charset=utf-8".toMediaType(), + requestBody.toString() + ) + + val request = Request.Builder() + .url(url) + .post(requestBodyObj) + .apply { + headers.forEach { (key, value) -> + addHeader(key, value) + } + } + .build() + + // 发送请求 + val response = httpClient.newCall(request).execute() + + if (response.isSuccessful) { + val responseBody = response.body?.string() + if (responseBody != null) { + val jsonResponse = JSONObject(responseBody) + val translation = extractTranslation(jsonResponse) + + if (translation != null) { + return IntegratedSpeechTranslationService.TranslationResult( + success = true, + translatedText = translation, + confidence = 0.9f + ) + } else { + val error = extractError(jsonResponse) + return IntegratedSpeechTranslationService.TranslationResult( + success = false, + error = error ?: "翻译结果为空" + ) + } + } + } + + return IntegratedSpeechTranslationService.TranslationResult( + success = false, + error = "HTTP错误: ${response.code}" + ) + + } catch (e: Exception) { + Log.e(TAG, "翻译请求异常", e) + return IntegratedSpeechTranslationService.TranslationResult( + success = false, + error = "请求异常: ${e.message}" + ) + } + } + + private fun getLanguageCode(languageCode: String): String { + return languageCodeMap[languageCode] ?: "" + } + + private fun extractTranslation(jsonResponse: JSONObject): String? { + try { + // 1. 标准响应结构 + if (jsonResponse.has("TranslationList")) { + val translationList = jsonResponse.getJSONArray("TranslationList") + if (translationList.length() > 0) { + val translationItem = translationList.getJSONObject(0) + if (translationItem.has("Translation")) { + return translationItem.getString("Translation") + } + } + } + + // 2. 其他可能的响应结构 + if (jsonResponse.has("Translation")) { + return jsonResponse.getString("Translation") + } + + if (jsonResponse.has("Result")) { + val result = jsonResponse.getJSONObject("Result") + if (result.has("Translation")) { + return result.getString("Translation") + } + } + + if (jsonResponse.has("Data")) { + val data = jsonResponse.getJSONObject("Data") + if (data.has("Translation")) { + return data.getString("Translation") + } + if (data.has("TranslationList")) { + val translationList = data.getJSONArray("TranslationList") + if (translationList.length() > 0) { + val translationItem = translationList.getJSONObject(0) + if (translationItem.has("Translation")) { + return translationItem.getString("Translation") + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "提取翻译结果失败", e) + } + + return null + } + + private fun extractError(jsonResponse: JSONObject): String? { + try { + if (jsonResponse.has("ResponseMetadata")) { + val metadata = jsonResponse.getJSONObject("ResponseMetadata") + if (metadata.has("Error")) { + val error = metadata.getJSONObject("Error") + return error.optString("Message", error.optString("Code", "未知错误")) + } + } + + if (jsonResponse.has("Error")) { + val error = jsonResponse.getJSONObject("Error") + return error.optString("Message", error.optString("Code", "未知错误")) + } + } catch (e: Exception) { + Log.e(TAG, "提取错误信息失败", e) + } + + return null + } + + private fun generateSignature( + method: String, + requestBody: JSONObject, + queryParams: Map + ): Map { + try { + // 1. 准备时间相关参数 + val now = Date() + val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val timestampFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + val date = dateFormat.format(now) + val timestamp = timestampFormat.format(now) + + // 2. 构建规范查询字符串 + val sortedParams = queryParams.toSortedMap() + val canonicalQueryString = sortedParams.map { (key, value) -> + "${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}" + }.joinToString("&") + + // 3. 创建规范请求 + val contentType = "application/json" + val payloadHash = sha256(requestBody.toString()) + val host = "translate.volcengineapi.com" + + val canonicalHeaders = "host:$host\nx-date:$timestamp\n" + val signedHeaders = "host;x-date" + + val canonicalRequest = + "$method\n$ENDPOINT\n$canonicalQueryString\n$canonicalHeaders\n$signedHeaders\n$payloadHash" + + // 4. 创建待签字符串 + val credentialScope = "$date/$region/$SERVICE/request" + val stringToSign = + "$ALGORITHM\n$timestamp\n$credentialScope\n${sha256(canonicalRequest)}" + + // 5. 计算签名 + val kSecret = secretKey.toByteArray(Charsets.UTF_8) + val kDate = hmacSha256(kSecret, date) + val kRegion = hmacSha256(kDate, region) + val kService = hmacSha256(kRegion, SERVICE) + val kSigning = hmacSha256(kService, "request") + val signature = + hmacSha256(kSigning, stringToSign).joinToString("") { "%02x".format(it) } + + // 6. 构建授权头 + val authorization = + "$ALGORITHM Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature" + + return mapOf( + "Content-Type" to contentType, + "X-Date" to timestamp, + "Authorization" to authorization, + "Host" to host + ) + + } catch (e: Exception) { + Log.e(TAG, "生成签名失败", e) + throw e + } + } + + private fun sha256(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(input.toByteArray(Charsets.UTF_8)) + return hash.joinToString("") { "%02x".format(it) } + } + + private fun hmacSha256(key: ByteArray, data: String): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + val secretKeySpec = SecretKeySpec(key, "HmacSHA256") + mac.init(secretKeySpec) + return mac.doFinal(data.toByteArray(Charsets.UTF_8)) + } + + override fun dispose() { + isInitialized = false + // 清理资源 + try { + httpClient.dispatcher.executorService.shutdown() + httpClient.connectionPool.evictAll() + } catch (e: Exception) { + Log.e(TAG, "清理资源失败", e) + } + } +} \ No newline at end of file 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 30137c898..acd098897 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 @@ -16,6 +16,7 @@ import com.deep_voice.speech.tts.TtsEventListener import com.deep_voice.speech.tts.TtsEventType import com.yunqiinnovation.ble_service.BleService import com.deep_voice.speech.tts.AudioOutputDevice +import kotlinx.coroutines.* /** AzureSpeechPlugin */ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { @@ -35,6 +36,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { private var ttsEventSink: EventChannel.EventSink? = null private lateinit var azureTtsHelper: AzureTtsHelper + // AST相关 + private lateinit var astChannel: MethodChannel + private lateinit var astEventChannel: EventChannel + private var astEventSink: EventChannel.EventSink? = null + private lateinit var azureAstHelper: IntegratedSpeechTranslationService + // 是否已添加TTS事件监听器 private var isTtsListenerAdded = false @@ -74,6 +81,22 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } } + // AST 事件发送方法 + private fun sendAstEvent(event: Map) { + if (astEventSink == null) { + FileLogger.w(tag, "无法发送AST事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + astEventSink?.success(event) + } catch (e: Exception) { + FileLogger.e(tag, "发送AST事件失败: ${e.message}") + } + } + } + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext @@ -84,7 +107,9 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { // 初始化TTS通道 ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/tts") ttsChannel.setMethodCallHandler(TtsMethodHandler()) - + // 初始化AST通道 + astChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/ast") + astChannel.setMethodCallHandler(AsTMethodHandler()) // 初始化ASR事件通道 asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr_events") @@ -111,11 +136,24 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { ttsEventSink = null } }) + // 初始化AST事件通道 + astEventChannel = + EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/ast_events") + astEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + astEventSink = events + //setupTtsEventListener() // 在事件通道准备好时设置TTS事件监听器 + } + + override fun onCancel(arguments: Any?) { + astEventSink = null + } + }) // 初始化Azure语音服务 azureTtsHelper = AzureTtsHelper(context) azureAsrHelper = AzureAsrHelper(context) - + azureAstHelper = IntegratedSpeechTranslationService(context) // 2. 初始化BleService并注册回调 if (BleService.initialize(context)) { @@ -437,7 +475,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { val filePath = call.argument("filePath") ?: "" try { FileLogger.d(tag, "音频文件名称为: ${filePath}") // - + azureAsrHelper.enableRecord(filePath) result.success(true) } catch (e: Exception) { @@ -506,6 +544,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { val outputStream = customPlayer.getAudioOutputStream() if (outputStream != null) { azureTtsHelper.setCustomAudioOutputStream(outputStream) + } else { + FileLogger.w(tag, "无法获取自定义音频输出流") } } @@ -584,6 +624,287 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } } + // ASt方法处理器 + inner class AsTMethodHandler : MethodCallHandler { + private suspend fun streamAudioFile(file: java.io.File) { + try { + val inputStream = file.inputStream() + val buffer = ByteArray(1024) // 每次读取1KB + + // WAV文件参数(假设16kHz, 16bit, 单声道) + val sampleRate = 16000 // 采样率 + val bytesPerSample = 2 // 16bit = 2字节 + val channels = 1 // 单声道 + + // 计算每秒需要的字节数 + val bytesPerSecond = sampleRate * bytesPerSample * channels + + // 计算每个缓冲区对应的播放时间(毫秒) + val bufferDurationMs = (buffer.size * 1000L) / bytesPerSecond + + FileLogger.d("TAG", "开始流式读取音频文件,缓冲区大小: ${buffer.size}, 播放间隔: ${bufferDurationMs}ms") + + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + // 只发送实际读取的字节数 + val audioChunk = if (bytesRead < buffer.size) { + buffer.copyOf(bytesRead) + } else { + buffer + } + + // 推送音频数据块 + withContext(Dispatchers.Main) { + azureAstHelper.pushAudioData(audioChunk) + FileLogger.d("TAG", "推送音频数据块: ${audioChunk.size} 字节") + } + } + + inputStream.close() + FileLogger.d("TAG", "音频文件流式读取完成") + + } catch (e: Exception) { + FileLogger.e("TAG", "流式读取音频文件失败: ${e.message}") + } +} + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "enableRecord" -> { + val filePath = call.argument("filePath") ?: "" + try { + FileLogger.d(tag, "音频文件名称为: ${filePath}") // + + azureAstHelper.enableRecord(filePath) + result.success(true) + } catch (e: Exception) { + result.error("ENABLERECORD_ERROR", e.message, null) + } + } + + "stopContinuousTranslation" -> { + val isSave = call.argument("isSave") ?: false + try { + FileLogger.d(tag, "停止翻译") + azureAstHelper.stopContinuousTranslation() + result.success(true) + } catch (e: Exception) { + result.error("STOP_CONTINUOUS_TRANSLATION_ERROR", e.message, null) + } + } + "path" -> { + val filePath = call.argument("filePath") ?: "" + try { + + // 读取这个wav音频文件,取里面的音频数据进行播放 + val file = java.io.File(filePath) + if (file.exists()) { + // 启动协程来按播放速度读取音频文件 + CoroutineScope(Dispatchers.IO).launch { + streamAudioFile(file) + } + result.success(true) + } else { + result.error("FILE_NOT_FOUND", "音频文件不存在: $filePath", null) + } + + } catch (e: Exception) { + result.error("READ_FILE_ERROR", "读取音频文件失败: ${e.message}", null) + } + } + + "initialize" -> { + val subscriptionKey = call.argument("subscriptionKey") ?: "" + val region = call.argument("region") ?: "" + val supportedLanguages = + call.argument>("supportedLanguages") ?: listOf("zh-CN") + val useExternalAudio = call.argument("useExternalAudio") ?: false + + // 获取翻译服务配置参数(需要从Flutter端传递) + val translationAccessKey = call.argument("translationAccessKey") ?: "" + val translationSecretKey = call.argument("translationSecretKey") ?: "" + val translationRegion = + call.argument("translationRegion") ?: "cn-north-1" + + FileLogger.d(tag, "初始化AST服务") + + // 创建Azure配置 + val azureConfig = AzureConfiguration( + subscriptionKey = subscriptionKey, + region = region + ) + + // 创建翻译配置 + val translationConfig = + TranslationConfiguration( + accessKey = translationAccessKey, + secretKey = translationSecretKey, + region = translationRegion + ) + + // 创建服务配置 + val serviceConfig = IntegratedSpeechTranslationService.ServiceConfiguration( + sourceLanguage = if (supportedLanguages.isNotEmpty()) supportedLanguages[0] else "zh-CN", + targetLanguage = if (supportedLanguages.size > 1) supportedLanguages[1] else "en-US" + ) + + // 创建事件回调 + val callback = object : IntegratedSpeechTranslationService.ServiceEventCallback { + override fun onServiceInitialized() { + // sendAstEvent( + // mapOf( + // "type" to "serviceInitialized" + // ) + // ) + } + + override fun onRecognizing(text: String, language: String, confidence: Float) { + // sendAstEvent( + // mapOf( + // "type" to "recognizing", + // "text" to text, + // "language" to language, + // "confidence" to confidence + // ) + // ) + } + + override fun onRecognized(text: String, language: String, confidence: Float) { + // sendAstEvent( + // mapOf( + // "type" to "recognized", + // "text" to text, + // "language" to language, + // "confidence" to confidence + // ) + // ) + } + + override fun onTranslated( + originalText: String, + translatedText: String, + targetLanguage: String + ) { + // sendAstEvent( + // mapOf( + // "type" to "translated", + // "originalText" to originalText, + // "translatedText" to translatedText, + // "targetLanguage" to targetLanguage + // ) + // ) + } + + override fun onTranslationStarted(text: String) { + // sendAstEvent( + // mapOf( + // "type" to "translationStarted", + // "text" to text + // ) + // ) + } + + override fun onTranslationFailed(text: String, error: String) { + // sendAstEvent( + // mapOf( + // "type" to "translationFailed", + // "text" to text, + // "error" to error + // ) + // ) + } + + override fun onSynthesisStarted(text: String) { + // sendAstEvent( + // mapOf( + // "type" to "synthesisStarted", + // "text" to text + // ) + // ) + } + + override fun onSynthesisCompleted(text: String) { + // sendAstEvent( + // mapOf( + // "type" to "synthesisCompleted", + // "text" to text + // ) + // ) + } + + override fun onSynthesisFailed(text: String, error: String) { + // sendAstEvent( + // mapOf( + // "type" to "synthesisFailed", + // "text" to text, + // "error" to error + // ) + // ) + } + + override fun onSynthesisProgress(text: String, progress: Float) { + // sendAstEvent( + // mapOf( + // "type" to "synthesisProgress", + // "text" to text, + // "progress" to progress + // ) + // ) + } + + override fun onRecognitionStarted() { + // sendAstEvent( + // mapOf( + // "type" to "recognitionStarted" + // ) + // ) + } + + override fun onRecognitionStopped() { + // sendAstEvent( + // mapOf( + // "type" to "recognitionStopped" + // ) + // ) + } + + override fun onStateChanged(component: String, isActive: Boolean) { + // sendAstEvent( + // mapOf( + // "type" to "stateChanged", + // "component" to component, + // "isActive" to isActive + // ) + // ) + } + + override fun onError(component: String, error: String) { + // sendAstEvent( + // mapOf( + // "type" to "error", + // "component" to component, + // "error" to error + // ) + // ) + } + } + + // 使用协程调用异步初始化方法 + GlobalScope.launch(Dispatchers.Main) { + azureAstHelper.initialize( + azureConfig = azureConfig, + translationConfig = translationConfig, + serviceConfig = serviceConfig, + callback = callback + ) + } + result.success(true) + } + } + } + } + + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { asrChannel.setMethodCallHandler(null) ttsChannel.setMethodCallHandler(null) @@ -624,6 +945,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { // 可选:处理音频数据 } + override fun onAudioDataReceived1(data: ByteArray) { + + azureAstHelper.pushAudioData(data) + + // 可选:处理音频数据 + } /** * 处理唤醒信号 * 在收到唤醒信号时启动语音识别 @@ -635,4 +962,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { // 不处理设备信息 } -} \ No newline at end of file +} + + + \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt index cddc6a245..0d0cf6afc 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt @@ -24,7 +24,7 @@ object RecordFile { // 音频配置 - 可配置的采样率和声道数 private var sampleRate = 16000 - private var channels = 2 // 1=单声道, 2=立体声 + private var channels = 1 // 1=单声道, 2=立体声 // 新增:用于存储最后成功保存的文件 private var lastSavedFile: File? = null diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index fe2eefa6e..dd3988a04 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -10,12 +10,17 @@ object BleConst { /** 主服务UUID - 文档中定义为0000ABC0-0000-1111-2222-123456789ABC */ val PRIMARY_SERVICE_UUID: UUID = UUID.fromString("0000abc0-0000-1111-2222-123456789abc") + /** 写入特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */ + val WRITE_CHAR_UUID: UUID = UUID.fromString("0000abc1-0000-1111-2222-123456789abc") - /** 音频服务UUID - 文档中定义为0000ABC0-0001-1111-2222-123456789ABC */ - val AUDIO_SERVICE_UUID1: UUID = UUID.fromString("0000ABC0-0001-1111-2222-123456789ABC") + /** 通知特征UUID - 文档中定义为0000ABC2-0000-1111-2222-123456789ABC */ + val NOTIFY_CHAR_UUID: UUID = UUID.fromString("0000abc2-0000-1111-2222-123456789abc") + + /** 通话音频服务UUID - 文档中定义为0000ABC0-0001-1111-2222-123456789ABC */ + val CALL_AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ABC0-0001-1111-2222-123456789ABC") - /** 接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ - val WRITE_AUDIO_CHAR_UUID1: UUID = UUID.fromString("0000ABC1-0001-1111-2222-123456789ABC") + /** 通话写入音频特征UUID - 文档中定义为0000ABC1-0001-1111-2222-123456789ABC */ + val CALL_WRITE_AUDIO_CHAR_UUID: UUID = UUID.fromString("0000ABC1-0001-1111-2222-123456789ABC") /** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */ val AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb") @@ -23,15 +28,6 @@ object BleConst { /** 接收音频特征UUID - 文档中定义为0000ABC2-0001-1111-2222-123456789ABC */ val RECEIVE_AUDIO_CHAR_UUID: UUID = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb") - /** 写入特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */ - val WRITE_CHAR_UUID: UUID = UUID.fromString("0000abc1-0000-1111-2222-123456789abc") - - /** 音频写入特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */ - val AUDIO_WRITE_CHAR_UUID: UUID = UUID.fromString("0000ABC1-0001-1111-2222-123456789ABC") - - /** 通知特征UUID - 文档中定义为0000ABC2-0000-1111-2222-123456789ABC */ - val NOTIFY_CHAR_UUID: UUID = UUID.fromString("0000abc2-0000-1111-2222-123456789abc") - /** 客户端特征配置描述符UUID */ val CLIENT_CHAR_CONFIG_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index d0f52906e..cda3594a4 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -26,6 +26,7 @@ import android.os.Handler import java.util.concurrent.atomic.AtomicBoolean import android.util.Log import androidx.annotation.RequiresPermission +import java.util.concurrent.TimeUnit /** * BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能 @@ -53,6 +54,10 @@ object BleService { // 数据相关回调 fun onAudioDataReceived(data: ByteArray) + // 数据相关回调 + fun onAudioDataReceived1(data: ByteArray) + + // 唤醒信号相关回调 fun onWakeupSignalReceived() // 设备信息相关回调 - 统一回调接口 @@ -77,9 +82,10 @@ object BleService { private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null - private var writeChar1: BluetoothGattCharacteristic? = null + private var callWriteChar: BluetoothGattCharacteristic? = null var recordfile: RecordingFile? = null var recordfile1: RecordingFile? = null + // 扫描相关 private lateinit var scanHandler: Handler private val scanResults = ArrayList() @@ -102,19 +108,26 @@ object BleService { // Opus解码器实例 private var opusManager: OpusManager? = null + private var option: OpusOption? = null private val mainHandler = Handler(Looper.getMainLooper()) - - // 音频解码处理线程 - private var audioDecodeThread: HandlerThread? = null - private var audioDecodeHandler: Handler? = null - - // 音频数据队列和处理线程 + + // 解码音频数据队列和处理线程 private val audioDataQueue = LinkedBlockingQueue() private var audioQueueProcessorThread: Thread? = null - + // 音频数据缓存 private val audioDataBuffer = mutableListOf() - private val AUDIO_BUFFER_SIZE = 1280// 1280字节缓存阈值 + //private val AUDIO_BUFFER_SIZE = 1280// 1280字节缓存阈值 + + // 音频数据发送相关 + private val audioSendQueue = LinkedBlockingQueue() + private var audioSendThread: Thread? = null + private val audioSendHandler = Handler(Looper.getMainLooper()) + private val isAudioSending = AtomicBoolean(false) + + // 音频数据分块发送的常量 + private val AUDIO_CHUNK_SIZE = 40 // 每次发送40字节 + private val AUDIO_SEND_INTERVAL = 10L // 发送间隔10ms // 初始化状态 private var isInitialized = false @@ -138,20 +151,6 @@ object BleService { try { this.context = appContext.applicationContext - - // otaManager = OTAManager(this.context).apply { - // // 设置数据回调 - // setDataCallback(object : OTAManager.DataCallback { - // override fun onDataReceived(device: BluetoothDevice?, data: ByteArray?) { - // data?.let { - // Log.i("BleService", "收到数据:${it.size} 字节") - // Log.i("BleService", "认证交互数据${it.contentToString()}") - // // 移除次数限制,除非明确需要 - // otaManager?.onReceiveDeviceData(device, it) - // } - // } - // }) - // } // 初始化蓝牙管理器和适配器 bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager @@ -160,22 +159,14 @@ object BleService { // 初始化Handler scanHandler = Handler(Looper.getMainLooper()) - - // 初始化音频解码线程 - audioDecodeThread = HandlerThread("AudioDecodeThread").apply { - start() - audioDecodeHandler = Handler(looper) - } - Log.d(TAG, "音频解码线程初始化成功") - + // 启动队列处理 startAudioQueueProcessing() - // 初始化OpusManager和OTAManager + // 初始化OpusManager和OpusOption try { opusManager = OpusManager() - startOpusStreamDecoding() - startEncodeStream() + option = OpusOption() Log.d(TAG, "OpusManager初始化成功") } catch (e: OpusException) { Log.e(TAG, "OpusManager初始化失败: ${e.message}", e) @@ -184,7 +175,7 @@ object BleService { recordfile = RecordingFile(this.context) recordfile!!.fileName = "不拆分" - recordfile1 = RecordingFile(this.context) + recordfile1 = RecordingFile(this.context) recordfile1!!.fileName = "重新压缩" isInitialized = true Log.d(TAG, "BLE服务初始化成功") @@ -230,6 +221,25 @@ object BleService { Log.d(TAG, "已清除所有BLE回调") } + fun writeExternalAudioData(data: ByteArray) { + // 检查OpusManager是否已初始化 + if (opusManager == null) { + Log.e(TAG, "opusManager 未初始化") + return + } + + // 如果已经在编码流中,直接写入数据 + if (opusManager?.isEncodeStream == true) { + Log.d(TAG, "正在进行Opus编码流,写入音频数据") + // 将外部音频数据写入编码流,每次处理1280字节 + opusManager?.writeEncodeStream(data) + } else { + Log.w(TAG, "Opus编码流未启动,无法写入音频数据") + // 可选:自动启动编码流 + // startOpusEncodeStream() + } + } + // ====================================================================================================== // 扫描功能 // ====================================================================================================== @@ -385,15 +395,16 @@ object BleService { } return null; } + fun getDeviceInfos(deviceName: String): List> { // 更新缓存 val matchedResults = scanResults.filter { it.device.name == deviceName } Log.d(TAG, "找到 ${matchedResults.size} 个名称为 $deviceName 的设备") - + val ret = mutableListOf>() - + // 如果找到匹配设备名的结果,返回匹配设备的信息 if (matchedResults.isNotEmpty()) { for (result in matchedResults) { @@ -411,9 +422,10 @@ object BleService { } } } - + return ret } + /** * 打印设备信息和服务UUID */ @@ -542,7 +554,7 @@ object BleService { notifyChar = null writeChar = null audioChar = null - writeChar1 = null + callWriteChar = null } } @@ -560,37 +572,17 @@ object BleService { */ private val gattCallback = object : BluetoothGattCallback() { - - // override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { - // if (status == BluetoothGatt.GATT_SUCCESS) { - // Log.i(TAG, "MTU 更新成功: $mtu") - // configureOTA() - - // startOTA() - // } else { - // Log.e(TAG, "MTU 更新失败: status=$status") - // } - // } - override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { when { status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED -> { updateConnectionState(BleConst.STATE_CONNECTED) g.discoverServices() - // Log.i(TAG, "连接成功,开始 MTU 协商") - - // otaManager?.onBtDeviceConnection(g.device, StateCode.CONNECTION_OK) - // g.requestMtu(512) // 触发 MTU 修改流程 } newState == BluetoothProfile.STATE_DISCONNECTED -> { updateConnectionState(BleConst.STATE_DISCONNECTED) disconnectGatt() - - // otaManager?.onBtDeviceConnection(g.device, StateCode.CONNECTION_CONNECTING) - // g.close() - // otaManager?.release(); } else -> { @@ -617,17 +609,18 @@ object BleService { audioChar = audioSvc?.getCharacteristic(BleConst.RECEIVE_AUDIO_CHAR_UUID) - // 获取音频服务1特征 - val audioSvc1 = g.getService(BleConst.AUDIO_SERVICE_UUID1) - writeChar1 = audioSvc1?.getCharacteristic(BleConst.WRITE_AUDIO_CHAR_UUID1) - if (writeChar1 == null) { - Log.e(TAG, "未找到主服务所需特征") + // 获取通话音频服务特征 + val callAudioSvc = g.getService(BleConst.CALL_AUDIO_SERVICE_UUID) + callWriteChar = callAudioSvc?.getCharacteristic(BleConst.CALL_WRITE_AUDIO_CHAR_UUID) + if (callWriteChar == null) { + Log.e(TAG, "未找到通话音频主服务所需特征") updateConnectionState(BleConst.STATE_ERROR) return } - Log.d(TAG, "找到writeChar1服务所需特征") + Log.d(TAG, "找到通话音频服务所需特征") + writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - writeChar1?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + callWriteChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE //要先设置音频服务的通知,否则接收不到 // 设置音频服务的通知(如果存在) if (audioChar != null) { @@ -636,10 +629,9 @@ object BleService { } else { Log.w(TAG, "音频服务特征未找到") } - + // 设置主服务的通知 setupNotifications(g, notifyChar) -//开启解码 } @@ -649,12 +641,6 @@ object BleService { // 根据特征UUID区分处理 when (c.uuid) { - // 音频特征数据 - // BleConst.RECEIVE_AUDIO_CHAR_UUID1 -> { - - // Log.i(TAG, "RECEIVE_AUDIO_CHAR_UUID1") - // processAudioData(data) - // } // 音频特征数据 BleConst.RECEIVE_AUDIO_CHAR_UUID -> { @@ -736,26 +722,26 @@ object BleService { try { if (opusManager?.isDecodeStream == true) { recordfile?.saveAudioDataToWav(data) - - // 将接收到的数据添加到缓存中 - synchronized(audioDataBuffer) { - audioDataBuffer.addAll(data.toList()) - - // 当缓存达到阈值时,提取完整的帧进行解码 - while (audioDataBuffer.size >= AUDIO_BUFFER_SIZE) { - // 提取一个完整的帧(1280字节) - val frameData = ByteArray(AUDIO_BUFFER_SIZE) - for (i in 0 until AUDIO_BUFFER_SIZE) { - frameData[i] = audioDataBuffer.removeAt(0) - } - - // 将完整的帧加入队列,由专门的线程处理 - audioDataQueue.offer(frameData) - Log.d(TAG, "缓存达到阈值,提取 ${frameData.size} 字节帧进行解码") - } - } + // 将完整的帧加入队列,由专门的线程处理 + audioDataQueue.offer(data) + // // 将接收到的数据添加到缓存中 + // synchronized(audioDataBuffer) { + // audioDataBuffer.addAll(data.toList()) + + // // 当缓存达到阈值时,提取完整的帧进行解码 + // while (audioDataBuffer.size >= AUDIO_BUFFER_SIZE) { + // // 提取一个完整的帧(1280字节) + // val frameData = ByteArray(AUDIO_BUFFER_SIZE) + // for (i in 0 until AUDIO_BUFFER_SIZE) { + // frameData[i] = audioDataBuffer.removeAt(0) + // } + + + // Log.d(TAG, "缓存达到阈值,提取 ${frameData.size} 字节帧进行解码") + // } + // } } else { - // Log.d(TAG, "Opus解码流未启动,忽略音频数据") + Log.d(TAG, "Opus解码流未启动,忽略音频数据") } } catch (e: Exception) { Log.e(TAG, "处理音频数据异常: ${e.message}", e) @@ -959,8 +945,8 @@ object BleService { codecStatus == BleConst.CODEC_CONTROL_A2DP_PLAY || codecStatus == BleConst.CODEC_CONTROL_ENCODE_ON ) { -recordfile1!!.closeFile() - recordfile1!!.creatingFiles() + recordfile1!!.closeFile() + recordfile1!!.creatingFiles() recordfile!!.closeFile() recordfile!!.creatingFiles() } else if (codecStatus == BleConst.CODEC_CONTROL_CLOSE) { @@ -1140,18 +1126,17 @@ recordfile1!!.closeFile() opusManager?.stopDecodeStream() Log.d(TAG, "已停止正在进行的Opus解码流") } - + // 清理音频数据缓存,确保开始时是干净的状态 synchronized(audioDataBuffer) { audioDataBuffer.clear() Log.d(TAG, "开始解码前已清理音频数据缓存") } - val option = OpusOption() - .setHasHead(hasHeader) - .setChannel(channel) - .setSampleRate(sampleRate) - .setPacketSize(packetSize) + option!!.setHasHead(hasHeader) + option!!.setChannel(channel) + option!!.setSampleRate(sampleRate) + option!!.setPacketSize(packetSize) Log.d(TAG, "准备开始Opus数据流解码, 参数: $option") @@ -1160,31 +1145,40 @@ recordfile1!!.closeFile() override fun onDecodeStream(data: ByteArray?) { if (data != null) { // Log.d(TAG, "Opus解码数据: ${data.size} bytes") - //解码数据再重新编码回去 - val sampleCount = data.size / 4 // 每个样本4字节(左右声道各2字节) - val leftBuffer = ByteArray(sampleCount * 2) // 左声道缓冲区 - val rightBuffer = ByteArray(sampleCount * 2) // 右声道缓冲区 - - // 拆分交错的左右声道数据 - for (i in 0 until sampleCount) { - val stereoIndex = i * 4 - val monoIndex = i * 2 - - // 左声道(低位字节在前,高位字节在后) - leftBuffer[monoIndex] = data[stereoIndex] - leftBuffer[monoIndex + 1] = data[stereoIndex + 1] - - // 右声道 - rightBuffer[monoIndex] = data[stereoIndex + 2] - rightBuffer[monoIndex + 1] = data[stereoIndex + 3] - } + if (option!!.getChannel() == 2) { + + //解码数据再重新编码回去 + + + val sampleCount = data.size / 4 // 每个样本4字节(左右声道各2字节) + val leftBuffer = ByteArray(sampleCount * 2) // 左声道缓冲区 + val rightBuffer = ByteArray(sampleCount * 2) // 右声道缓冲区 + + // 拆分交错的左右声道数据 + for (i in 0 until sampleCount) { + val stereoIndex = i * 4 + val monoIndex = i * 2 - // // 将左右声道数据分别放入队列 - // leftChannelQueue.offer(leftBuffer) - // rightChannelQueue.offer(rightBuffer) - opusManager?.writeEncodeStream(rightBuffer) + // 左声道(低位字节在前,高位字节在后) + leftBuffer[monoIndex] = data[stereoIndex] + leftBuffer[monoIndex + 1] = data[stereoIndex + 1] - notifyAudioDataReceived(rightBuffer) + // 右声道 + rightBuffer[monoIndex] = data[stereoIndex + 2] + rightBuffer[monoIndex + 1] = data[stereoIndex + 3] + } + + // //重新编码 + // opusManager?.writeEncodeStream(rightBuffer) + notifyAudioDataReceived1(leftBuffer) + //回调 + notifyAudioDataReceived(rightBuffer)//对方的 + // notifyAudioDataReceived1(leftBuffer)//自己的 + } else if (option!!.getChannel() == 1) { + notifyAudioDataReceived(data) + } else { + Log.e(TAG, "Opus解码数据错误: ${data.size} bytes") + } } } @@ -1212,13 +1206,13 @@ recordfile1!!.closeFile() private fun stopOpusStreamDecoding(): Boolean { if (opusManager?.isDecodeStream == true) { opusManager?.stopDecodeStream() - + // 清理音频数据缓存 synchronized(audioDataBuffer) { audioDataBuffer.clear() Log.d(TAG, "已清理音频数据缓存") } - + Log.i(TAG, "已停止Opus数据流解码") return true } @@ -1230,55 +1224,167 @@ recordfile1!!.closeFile() // Opus 编码相关 // ====================================================================================================== - private fun startEncodeStream( - hasHeader: Boolean = false, // 通常BLE传输的Opus没有文件头 - channel: Int = 1, - sampleRate: Int = 16000, // 确认设备端Opus编码采样率 - packetSize: Int = 40 // 确认设备端Opus编码帧长,必须与iOS版本frameSize保持一致 - ): Boolean { + private fun startOpusEncodeStream(): Boolean { if (opusManager == null) { Log.e(TAG, "OpusManager未初始化,无法开始编码") return false } - // 如果已经在解码流,先停止 + + // 如果已经在编码流,先停止 if (opusManager?.isEncodeStream == true) { - opusManager?.stopEncodeStream() - Log.d(TAG, "已停止正在进行的Opus编码流") + Log.d(TAG, "Opus编码流已在运行") + return true } - + + // 启动音频发送线程 + startAudioSendThread() var streamStartedSuccessfully = false opusManager?.startEncodeStream(object : OnEncodeStreamCallback { override fun onEncodeStream(data: ByteArray?) { if (data != null) { - // Log.d(TAG, "Opus解码数据: ${data.size} bytes") - //notifyAudioDataReceived(data) - writeChar1!!.value=data - recordfile1!!.saveAudioDataToWav(data) - val isSuccess = bluetoothGatt?.writeCharacteristic(writeChar1) - if (isSuccess == true) { - - Log.d(TAG, "发送opus数据") - } + // 编码完成的数据处理: + // 1. 保存到WAV文件 + recordfile1?.saveAudioDataToWav(data) + // 2. 将编码后的数据加入发送队列进行分块发送 + addAudioDataToSendQueue(data) + } else { + Log.w(TAG, "编码回调收到空数据") } } - + override fun onStart() { streamStartedSuccessfully = true Log.i(TAG, "Opus数据流编码已开始") - // 可以通过回调通知上层解码已开始 } override fun onComplete(outPath: String?) { - Log.i(TAG, "Opus数据流编码完成: $outPath (通常流式编码不会调用此方法)") + Log.i(TAG, "Opus数据流编码完成: $outPath") } override fun onError(code: Int, message: String?) { Log.e(TAG, "Opus数据流编码错误: [$code] $message") - // 可以通过回调通知上层解码错误 } }) - return true // 暂定为调用即成功 + + return true + } + + private fun stopOpusEncodeStream(): Boolean { + if (opusManager?.isEncodeStream == true) { + opusManager?.stopEncodeStream() + stopAudioSendThread() // 停止音频发送线程 + Log.i(TAG, "已停止Opus数据流编码") + return true + } + Log.d(TAG, "Opus数据流未在编码或OpusManager未初始化") + return false + } + + /** + * 将音频数据分块并加入发送队列 + */ + private fun addAudioDataToSendQueue(data: ByteArray) { + try { + // 将大数据分成40字节的小块 + var offset = 0 + while (offset < data.size) { + val chunkSize = minOf(AUDIO_CHUNK_SIZE, data.size - offset) + val chunk = ByteArray(chunkSize) + System.arraycopy(data, offset, chunk, 0, chunkSize) + + // 将分块数据加入队列 + if (!audioSendQueue.offer(chunk)) { + Log.w(TAG, "音频发送队列已满,丢弃数据块") + } + + offset += chunkSize + } + + Log.d(TAG, "音频数据已分块加入队列,原始大小: ${data.size} 字节,分块数: ${(data.size + AUDIO_CHUNK_SIZE - 1) / AUDIO_CHUNK_SIZE}") + } catch (e: Exception) { + Log.e(TAG, "分块音频数据异常: ${e.message}", e) + } + } + + /** + * 启动音频数据发送线程 + */ + private fun startAudioSendThread() { + if (audioSendThread?.isAlive == true) { + Log.d(TAG, "音频发送线程已在运行") + return + } + + isAudioSending.set(true) + audioSendThread = Thread { + Log.i(TAG, "音频发送线程已启动") + + while (isAudioSending.get() && !Thread.currentThread().isInterrupted) { + try { + // 从队列中取出音频数据块 + val audioChunk = audioSendQueue.poll(100, TimeUnit.MILLISECONDS) + + if (audioChunk != null) { + // 发送音频数据块 + sendAudioChunk(audioChunk) + + // 控制发送频率,避免蓝牙缓冲区溢出 + //Thread.sleep(AUDIO_SEND_INTERVAL) + } + } catch (e: InterruptedException) { + Log.d(TAG, "音频发送线程被中断") + break + } catch (e: Exception) { + Log.e(TAG, "音频发送线程异常: ${e.message}", e) + } + } + + Log.i(TAG, "音频发送线程已停止") + }.apply { + name = "AudioSendThread" + start() + } + } + + /** + * 停止音频数据发送线程 + */ + private fun stopAudioSendThread() { + isAudioSending.set(false) + audioSendThread?.interrupt() + audioSendQueue.clear() + Log.i(TAG, "音频发送线程已停止,队列已清空") + } + + /** + * 发送单个音频数据块 + */ + private fun sendAudioChunk(chunk: ByteArray) { + try { + if (callWriteChar == null || bluetoothGatt == null) { + Log.e(TAG, "蓝牙连接或特征值未准备就绪") + return + } + + // 在主线程中执行蓝牙写入操作 + audioSendHandler.post { + try { + callWriteChar?.value = chunk + val isSuccess = bluetoothGatt?.writeCharacteristic(callWriteChar) + + if (isSuccess == true) { + Log.d(TAG, "成功发送音频数据块,大小: ${chunk.size} 字节") + } else { + Log.e(TAG, "发送音频数据块失败,大小: ${chunk.size} 字节") + } + } catch (e: Exception) { + Log.e(TAG, "发送音频数据块异常: ${e.message}", e) + } + } + } catch (e: Exception) { + Log.e(TAG, "发送音频数据块异常: ${e.message}", e) + } } // ====================================================================================================== // 公开的命令接口 @@ -1361,7 +1467,9 @@ recordfile1!!.closeFile() */ fun openA2DPDecoder(): Boolean { Log.i(TAG, "打开编码 0xA2") - startOpusStreamDecoding(false,2, 16000, 80) + startOpusEncodeStream() + //双声道 80字节 + startOpusStreamDecoding(false, 2, 16000, 80) // Log.i(TAG, "打开解码...") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( @@ -1452,7 +1560,7 @@ recordfile1!!.closeFile() * 连接状态检查 */ private fun checkConn(): Boolean = - bluetoothGatt != null && writeChar != null && writeChar1 != null && + bluetoothGatt != null && writeChar != null && callWriteChar != null && connectionState.value == BleConst.STATE_CONNECTED /** @@ -1480,34 +1588,28 @@ recordfile1!!.closeFile() stopScan() scanHandler.removeCallbacksAndMessages(null) disconnectGatt() - - // 清理音频解码线程和队列 - audioDecodeHandler?.removeCallbacksAndMessages(null) - audioDecodeThread?.quitSafely() - audioDecodeThread = null - audioDecodeHandler = null - // 停止音频队列处理线程 audioQueueProcessorThread?.interrupt() audioQueueProcessorThread = null audioDataQueue.clear() + // 停止音频发送线程 + stopAudioSendThread() + // 清理音频数据缓存 synchronized(audioDataBuffer) { audioDataBuffer.clear() Log.d(TAG, "已清理音频数据缓存") } - + Log.d(TAG, "音频解码线程和队列处理线程已清理") - + // 释放OpusManager - opusManager?.let { - if (it.isDecodeStream) { - it.stopDecodeStream() - } - it.release() - } + stopOpusEncodeStream() + stopOpusStreamDecoding() + opusManager?.release() opusManager = null + option = null Log.d(TAG, "OpusManager已释放") isInitialized = false // 标记为未初始化 } @@ -1573,8 +1675,7 @@ recordfile1!!.closeFile() isReply = false replyTimeoutHandler.postDelayed(replyTimeoutRunnable, 1000) // 设置1秒超时 return true - } else - { + } else { commandQueue.poll() return false } @@ -1660,6 +1761,18 @@ recordfile1!!.closeFile() } } } + /** + * 向所有回调监听器分发音频数据 + */ + private fun notifyAudioDataReceived1(data: ByteArray) { + for (callback in callbacks) { + try { + callback.onAudioDataReceived1(data) + } catch (e: Exception) { + Log.e(TAG, "分发音频数据回调异常", e) + } + } + } /** * 向所有回调监听器分发唤醒信号 @@ -1686,7 +1799,7 @@ recordfile1!!.closeFile() } } } - + /** * 启动音频队列处理 */ @@ -1696,6 +1809,7 @@ recordfile1!!.closeFile() try { // 从队列中取出音频数据进行解码 val audioData = audioDataQueue.take() // 阻塞等待数据 + opusManager?.writeAudioStream(audioData) } catch (e: InterruptedException) { Log.d(TAG, "音频队列处理线程被中断") diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index 0e43d9188..e911e41c3 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -354,6 +354,10 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, // sendEvent(dataEventSink, mapOf("type" to "audioData", "data" to data), "发送音频数据异常") } + override fun onAudioDataReceived1(data: ByteArray) { + // sendEvent(dataEventSink, mapOf("type" to "audioData", "data" to data), "发送音频数据异常") + } + override fun onWakeupSignalReceived() { // 将唤醒事件发送到Flutter sendEvent(statusEventSink, mapOf("type" to "wakeup"), "发送唤醒信号异常") diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt index c030cb725..1830237b1 100644 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt +++ b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt @@ -383,6 +383,9 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { // 可选:处理音频数据 } + override fun onAudioDataReceived1(data: ByteArray) { + + } /** * 处理唤醒信号 * 在收到唤醒信号时启动语音识别 From 0e25a145f2729181d19a5682e13888af0adb0192 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Tue, 29 Jul 2025 18:53:15 +0800 Subject: [PATCH 06/23] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E9=80=9A=E8=AF=9D?= =?UTF-8?q?=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/asr_service.dart | 3 + lib/data/services/ast_service.dart | 185 +++++++++--------- .../speech_impl/azure_asr_service.dart | 17 +- .../speech_impl/azure_ast_service.dart | 164 +++++++++++++++- .../controllers/speech_test_controller.dart | 10 +- .../controllers/translation_controller.dart | 28 ++- .../translation/views/translation_view.dart | 171 ++++++++++------ .../azure_speech/AzureAsrHelper.kt | 2 +- .../azure_speech/AzureAsrToAsr.kt | 133 ++++++++++--- .../azure_speech/AzureSpeechPlugin.kt | 45 ++++- .../azure_speech/tools/RecordFile.kt | 15 +- .../yunqiinnovation/ble_service/BleService.kt | 4 +- 12 files changed, 581 insertions(+), 196 deletions(-) diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 62928938d..54b507005 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -70,6 +70,9 @@ enum RecognitionEventType { /// 最终识别结果 finalResult, + /// 麦识别结果 + finalResult1, + /// 中间识别结果(实时反馈) intermediateResult, diff --git a/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart index 53671ced5..69cd23592 100644 --- a/lib/data/services/ast_service.dart +++ b/lib/data/services/ast_service.dart @@ -12,96 +12,105 @@ abstract class AstService { /// 开始录音 Future enableRecord(String filePath); - /// 停止录音 - Future stopContinuousTranslation(bool isSave); + /// + Future startContinuousTranslation(); + + /// + Future stopContinuousTranslation(); + + /// 返回一个包含识别事件的流 + Future> recognizeCallback(); /// 开始录音 Future path(String filePath); + + /// 释放资源 + Future dispose(); +} + +// 识别事件类型 +enum RecognitionEventType1 { + /// 最终识别结果 + finalResult, + + /// 中间识别结果(实时反馈) + intermediateResult, + + /// 音频 + onAudio, + + /// 会话开始 + sessionStarted, + + /// 会话结束 + sessionStopped, + + /// 识别取消 + canceled, + + /// 识别错误 + error, } -/// 识别事件类型 -// enum RecognitionEventType { -// /// 最终识别结果 -// finalResult, - -// /// 中间识别结果(实时反馈) -// intermediateResult, - -// /// 音频 -// onAudio, - -// /// 会话开始 -// sessionStarted, - -// /// 会话结束 -// sessionStopped, - -// /// 识别取消 -// canceled, - -// /// 识别错误 -// error, -// } - -/// 识别事件 -// class RecognitionEvent { -// /// 事件类型 -// final RecognitionEventType type; - -// /// 识别文本(仅在 finalResult 和 intermediateResult 类型中有效) -// final String text; - -// /// 检测到的语言 -// final String detectedLanguage; - -// /// 角色 -// final String role; - -// /// 原始音频 -// final Uint8List? audio; - -// /// 错误信息(仅在 error 和 canceled 类型中有效) -// final String error; - -// RecognitionEvent({ -// required this.type, -// this.text = '', -// this.detectedLanguage = '', -// this.role = '', -// this.audio, -// this.error = '', -// }); - -// /// 创建最终结果事件的快捷构造函数 -// factory RecognitionEvent.finalResult({ -// required String text, -// String detectedLanguage = '', -// }) { -// return RecognitionEvent( -// type: RecognitionEventType.finalResult, -// text: text, -// detectedLanguage: detectedLanguage, -// ); -// } - -// /// 创建错误事件的快捷构造函数 -// factory RecognitionEvent.error(String errorMessage) { -// return RecognitionEvent( -// type: RecognitionEventType.error, -// error: errorMessage, -// ); -// } - -// /// 检查是否为最终结果 -// bool get isFinalResult => type == RecognitionEventType.finalResult; - -// /// 检查是否为错误 -// bool get isError => -// type == RecognitionEventType.error || -// type == RecognitionEventType.canceled; - -// @override -// String toString() { -// return 'RecognitionEvent{type: $type, text: $text, detectedLanguage: $detectedLanguage, error: $error}'; -// } -// } +// 识别事件 +class RecognitionEvent1 { + /// 事件类型 + final RecognitionEventType1 type; + + /// 识别文本(仅在 finalResult 和 intermediateResult 类型中有效) + final String text; + + /// 检测到的语言 + final String detectedLanguage; + + /// 角色 + final String role; + + /// 原始音频 + final Uint8List? audio; + + /// 错误信息(仅在 error 和 canceled 类型中有效) + final String error; + + RecognitionEvent1({ + required this.type, + this.text = '', + this.detectedLanguage = '', + this.role = '', + this.audio, + this.error = '', + }); + + /// 创建最终结果事件的快捷构造函数 + factory RecognitionEvent1.finalResult({ + required String text, + String detectedLanguage = '', + }) { + return RecognitionEvent1( + type: RecognitionEventType1.finalResult, + text: text, + detectedLanguage: detectedLanguage, + ); + } + + /// 创建错误事件的快捷构造函数 + factory RecognitionEvent1.error(String errorMessage) { + return RecognitionEvent1( + type: RecognitionEventType1.error, + error: errorMessage, + ); + } + + /// 检查是否为最终结果 + bool get isFinalResult => type == RecognitionEventType1.finalResult; + + /// 检查是否为错误 + bool get isError => + type == RecognitionEventType1.error || + type == RecognitionEventType1.canceled; + + @override + String toString() { + return 'RecognitionEvent{type: $type, text: $text, detectedLanguage: $detectedLanguage, error: $error}'; + } +} diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index c02347552..832b83b5b 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -242,6 +242,18 @@ class AzureAsrService extends GetxService implements AsrService { detectedLanguage: detectedLanguage, )); break; + case 'result1': + final String text = eventMap['text'] as String? ?? ''; + final String detectedLanguage = + eventMap['detectedLanguage'] as String? ?? ''; + _latestRecognizedText = text; + _latestDetectedLanguage = detectedLanguage; + _eventStreamController?.add(RecognitionEvent( + type: RecognitionEventType.finalResult1, + text: text, + detectedLanguage: detectedLanguage, + )); + break; case 'recognizing': final String text = eventMap['text'] as String? ?? ''; @@ -404,7 +416,7 @@ class AzureAsrService extends GetxService implements AsrService { } /// 设置音频配置 - /// + /// /// [sampleRate] 采样率,默认16000 /// [channels] 声道数,默认1(单声道) Future setAudioConfig({ @@ -417,7 +429,8 @@ class AzureAsrService extends GetxService implements AsrService { 'channels': channels, }); - Logger.info('音频配置设置${result ? '成功' : '失败'}: 采样率=$sampleRate, 声道数=$channels'); + Logger.info( + '音频配置设置${result ? '成功' : '失败'}: 采样率=$sampleRate, 声道数=$channels'); return result; } catch (e) { Logger.error('设置音频配置失败: ${e.toString()}'); diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index 6e9a4af0a..f66945d9f 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -34,10 +34,10 @@ class AzureAstService extends GetxService implements AstService { @override List get supportedLanguages => _defaultSupportedLanguages; - // 连续识别相关 - // bool _isContinuousRecognitionActive = false; - // StreamController? _eventStreamController; - // StreamSubscription? _eventSubscription; + //连续识别相关 + bool _isContinuousRecognitionActive = false; + StreamController? _eventStreamController; + StreamSubscription? _eventSubscription; // 最新的识别结果 String _latestRecognizedText = ''; @@ -54,6 +54,109 @@ class AzureAstService extends GetxService implements AstService { _loadConfig(); } + /// 设置事件通道 + void _setupEventChannel() { + _eventSubscription?.cancel(); + _eventSubscription = _eventChannel.receiveBroadcastStream().listen((event) { + if (event is Map) { + _handleRecognitionEvent(event); + } + }, onError: _handleRecognitionError); + } + + /// 处理来自原生端的识别事件 + void _handleRecognitionEvent(dynamic event) { + if (event is! Map || _eventStreamController == null) return; + + final Map eventMap = event; + final String eventType = eventMap['type'] as String? ?? ''; + + // 添加日志帮助调试 + + switch (eventType) { + case 'result': + final String text = eventMap['text'] as String? ?? ''; + final String detectedLanguage = + eventMap['detectedLanguage'] as String? ?? ''; + _latestRecognizedText = text; + _latestDetectedLanguage = detectedLanguage; + _eventStreamController?.add(RecognitionEvent1( + type: RecognitionEventType1.finalResult, + text: text, + detectedLanguage: detectedLanguage, + )); + break; + + case 'recognizing': + final String text = eventMap['text'] as String? ?? ''; + final String detectedLanguage = + eventMap['detectedLanguage'] as String? ?? ''; + _eventStreamController?.add(RecognitionEvent1( + type: RecognitionEventType1.intermediateResult, + text: text, + detectedLanguage: detectedLanguage, + )); + break; + case 'onAudio': + final Uint8List data = eventMap['data'] as Uint8List? ?? Uint8List(0); + _eventStreamController?.add(RecognitionEvent1( + type: RecognitionEventType1.onAudio, + audio: data, + )); + break; + case 'sessionStarted': + _eventStreamController?.add(RecognitionEvent1( + type: RecognitionEventType1.sessionStarted, + )); + break; + + case 'sessionStopped': + _isContinuousRecognitionActive = false; + _eventStreamController?.add(RecognitionEvent1( + type: RecognitionEventType1.sessionStopped, + )); + break; + + case 'canceled': + _isContinuousRecognitionActive = false; + final String reason = eventMap['reason'] as String? ?? ''; + final String errorDetails = eventMap['errorDetails'] as String? ?? ''; + + if (reason.isNotEmpty || errorDetails.isNotEmpty) { + Logger.error('识别取消: $reason - ${errorDetails.toString()}'); + } + + _eventStreamController?.add(RecognitionEvent1( + type: RecognitionEventType1.canceled, + error: '$reason: $errorDetails', + )); + break; + + case 'error': + final String error = eventMap['message'] as String? ?? ''; + Logger.error('识别错误: ${error.toString()}'); + _eventStreamController?.add(RecognitionEvent1( + type: RecognitionEventType1.error, + error: error, + )); + break; + } + } + + /// 处理识别事件流错误 + void _handleRecognitionError(Object error) { + Logger.error('识别事件流错误: ${error.toString()}'); + _eventStreamController?.addError(error); + _cleanupEventStream(); + } + + /// 清理事件流资源 + void _cleanupEventStream() { + _eventStreamController?.close(); + _eventStreamController = null; + _isContinuousRecognitionActive = false; + } + /// 从环境变量加载配置 void _loadConfig() { // final _env = _storage.read("ENV") as Map; @@ -70,6 +173,27 @@ class AzureAstService extends GetxService implements AstService { } } + @override + Future> recognizeCallback() async { + if (!_isInitialized) { + await initialize(); + } + try { + _eventStreamController = StreamController.broadcast(); + + // 开始连续识别 + final bool result = await _channel.invokeMethod('recognizeCallback'); + if (!result) { + _cleanupEventStream(); + } + + return _eventStreamController!.stream; + } catch (e) { + Logger.error('开始连续语音识别失败: ${e.toString()}'); + rethrow; + } + } + @override Future enableRecord(String filePath) async { try { @@ -99,12 +223,23 @@ class AzureAstService extends GetxService implements AstService { } @override - Future stopContinuousTranslation(bool isSave) async { + Future startContinuousTranslation() async { try { final bool result = - await _channel.invokeMethod('stopContinuousTranslation', { - 'isSave': isSave, - }); + await _channel.invokeMethod('startContinuousTranslation'); + + return result; + } catch (e) { + Logger.error('停止录音: ${e.toString()}'); + rethrow; + } + } + + @override + Future stopContinuousTranslation() async { + try { + final bool result = + await _channel.invokeMethod('stopContinuousTranslation'); return result; } catch (e) { @@ -113,6 +248,18 @@ class AzureAstService extends GetxService implements AstService { } } + @override + Future dispose() async { + try { + final bool result = await _channel.invokeMethod('dispose'); + + return; + } catch (e) { + Logger.error('停止录音: ${e.toString()}'); + rethrow; + } + } + @override Future initialize({ List? supportedLanguages, @@ -153,6 +300,7 @@ class AzureAstService extends GetxService implements AstService { }); _isInitialized = result; + _setupEventChannel(); Logger.info('Azure 语音识别服务初始化${result ? '成功' : '失败'}'); return result; } catch (e) { diff --git a/lib/modules/speech_test/controllers/speech_test_controller.dart b/lib/modules/speech_test/controllers/speech_test_controller.dart index ec1833c90..5bcfc83e0 100644 --- a/lib/modules/speech_test/controllers/speech_test_controller.dart +++ b/lib/modules/speech_test/controllers/speech_test_controller.dart @@ -164,7 +164,15 @@ class SpeechTestController extends GetxController { } recognitionStatus.value = '识别完成'; break; - + case RecognitionEventType.finalResult1: + // 更新最终结果,显示为正常文本 + finalResult.value = event.text; + intermediateResult.value = ''; // 清空中间结果 + if (event.detectedLanguage.isNotEmpty) { + detectedLanguage.value = event.detectedLanguage; + } + recognitionStatus.value = '识别完成'; + break; case RecognitionEventType.error: isListening.value = false; recognitionStatus.value = '识别错误: ${event.error}'; diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 1627b595a..c5894390a 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -107,6 +107,11 @@ class TranslationController extends GetxController { var appDir; var dir; +// 最新的语音识别文本 + final latestRecognitionText = ''.obs; + +// 检测到的语言代码 + final detectedLanguageCode1 = ''.obs; //int num = 0; Future changeTranslationMode(String mode) async { currentMode.value = mode; @@ -182,7 +187,6 @@ class TranslationController extends GetxController { StreamSubscription? _recognitionSubscription; StreamSubscription? _voiceInteractionSubscription; Timer? _translationDebounceTimer; - // 获取支持的语言列表 Map get supportedLanguages => _languageManager.getChineseNameToAsrCodeMap(); @@ -216,7 +220,8 @@ class TranslationController extends GetxController { // 初始化服务 _initServices(); - + // 初始化语音翻译服务 + _initializeCallModeTranslationService(); // 🔑 reverse模式下不需要手动滚动,ListView会自然显示最新内容 // 参考Agent模块的实现模式 } @@ -350,6 +355,7 @@ class TranslationController extends GetxController { _recognitionSubscription?.cancel(); _recognitionSubscription = recognitionStream.listen(_handleRecognitionEvent); + final bool storage = await _requestStoragePermission(); if (!storage) { Logger.d("Permission", "未授予存储权限,无法提供录音功能"); @@ -386,7 +392,7 @@ class TranslationController extends GetxController { // }); // 重新初始化ASR服务以支持通话音频源 await _astService.initialize(supportedLanguages: callModeLanguages); - await _astService.path("${dir.path}/8_mic.wav"); + // 配置实时翻译参数 await _configureCallModeTranslation(); @@ -617,10 +623,9 @@ class TranslationController extends GetxController { bleManager.openA2DPDecoder(); _audioSourceType = true; isTtsEnabled.value = false; + await _astService.startContinuousTranslation(); + // await _astService.path("${dir.path}/8_mic.wav"); Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); - - // 初始化语音翻译服务 - await _initializeCallModeTranslationService(); } else { // 开始连续语音识别 _audioSourceType = false; @@ -650,13 +655,22 @@ class TranslationController extends GetxController { if (event.type == RecognitionEventType.finalResult && event.text.isNotEmpty) { detectedLanguageCode = event.detectedLanguage; + print("处理识别事件:${event.text}"); handleFinalResult(event.text); } else if (event.type == RecognitionEventType.intermediateResult && event.text.isNotEmpty) { if (event.detectedLanguage.isNotEmpty) { detectedLanguageCode = event.detectedLanguage; } + print("处理识别事件:${event.text}"); handleIntermediateResult(event.text); + } else if (event.type == RecognitionEventType.finalResult1 && + event.text.isNotEmpty) { + if (event.detectedLanguage.isNotEmpty) { + detectedLanguageCode1.value = event.detectedLanguage; + } + latestRecognitionText.value = event.text; + print("处理麦:${event.text}"); } else if (event.type == RecognitionEventType.error) { isRecognizing.value = false; Logger.error('语音识别错误: ${event.error}'); @@ -672,7 +686,7 @@ class TranslationController extends GetxController { try { await _asrService.stopContinuousRecognition(); - await _astService.stopContinuousTranslation(true); + await _astService.stopContinuousTranslation(); // 停止ASR活跃时长计时 _stopAsrActiveTracking(); diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index d69278376..47cf1d2e8 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -459,67 +459,128 @@ class TranslationView extends GetView { ], ), child: Obx(() { - // 同时模式显示双按钮 - if (controller.currentMode.value == 'faceToFace') { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 耳机图标按钮 (说话者1) - _buildHoldToTalkButton( - isDarkMode: isDarkMode, - speakerId: 1, - activeSpeaker: controller.activeSpeaker.value, - icon: Icons.headset, // 耳机图标 - label: "headsetUser".tr, // 耳机用户 + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 原有的按钮区域 + if (controller.currentMode.value == 'faceToFace') + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 耳机图标按钮 (说话者1) + _buildHoldToTalkButton( + isDarkMode: isDarkMode, + speakerId: 1, + activeSpeaker: controller.activeSpeaker.value, + icon: Icons.headset, // 耳机图标 + label: "headsetUser".tr, // 耳机用户 + ), + SizedBox(width: 40.w), + // 手机图标按钮 (说话者2) + _buildHoldToTalkButton( + isDarkMode: isDarkMode, + speakerId: 2, + activeSpeaker: controller.activeSpeaker.value, + icon: Icons.phone_android, // 手机图标 + label: "phoneUser".tr, // 手机用户 + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 主要控制按钮 - 开始/暂停语音识别 + Obx(() { + final isRecognizing = controller.isRecognizing.value; + return GestureDetector( + onTap: () { + if (isRecognizing) { + controller.stopAll(); + } else { + controller.startRecognition(); + } + }, + child: Container( + width: 60.w, + height: 60.w, + decoration: BoxDecoration( + color: isRecognizing + ? (isDarkMode ? Colors.red[700] : Colors.red) + : (isDarkMode ? AppColors.primary : Colors.blue), + shape: BoxShape.circle, + ), + child: Icon( + isRecognizing ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 30.sp, + ), + ), + ); + }), + ], ), - SizedBox(width: 40.w), - // 手机图标按钮 (说话者2) - _buildHoldToTalkButton( - isDarkMode: isDarkMode, - speakerId: 2, - activeSpeaker: controller.activeSpeaker.value, - icon: Icons.phone_android, // 手机图标 - label: "phoneUser".tr, // 手机用户 + + // 添加间距 + SizedBox(height: 16.h), + + // 语音识别结果显示框 + Container( + width: double.infinity, + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[100], + borderRadius: BorderRadius.circular(8.r), + border: Border.all( + color: isDarkMode ? Colors.grey[600]! : Colors.grey[300]!, + width: 1, + ), ), - ], - ); - } - // 普通模式显示单按钮 - else { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 主要控制按钮 - 开始/暂停语音识别 - Obx(() { - final isRecognizing = controller.isRecognizing.value; - return GestureDetector( - onTap: () { - if (isRecognizing) { - controller.stopAll(); - } else { - controller.startRecognition(); - } - }, - child: Container( - width: 60.w, - height: 60.w, - decoration: BoxDecoration( - color: isRecognizing - ? (isDarkMode ? Colors.red[700] : Colors.red) - : (isDarkMode ? AppColors.primary : Colors.blue), - shape: BoxShape.circle, + child: Obx(() { + // 显示最新的语音识别结果 + final recognitionText = controller.latestRecognitionText.value; + final detectedLanguage = controller.detectedLanguageCode1.value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + '麦头语音识别结果', + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + fontWeight: FontWeight.w500, + ), ), - child: Icon( - isRecognizing ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 30.sp, + SizedBox(height: 4.h), + // 识别文本 - 这里显示 finalResult1 的内容 + Text( + recognitionText.isEmpty ? '等待语音输入...' : recognitionText, + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white : Colors.black87, + height: 1.3, + ), ), - ), + // 检测到的语言(如果有) + if (detectedLanguage.isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + '检测语言: $detectedLanguage', + style: TextStyle( + fontSize: 10.sp, + color: + isDarkMode ? Colors.grey[500] : Colors.grey[500], + ), + ), + ], + ], ); }), - ], - ); - } + ), + ], + ); }), ); } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 0d85eda9c..17efe005a 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -144,7 +144,7 @@ class AzureAsrHelper(private val context: Context) { setProperty("Speech_SegmentationStrategy", "Time") } // 录音文件类 - recordfile = RecordFile; + recordfile = RecordFile(); return true diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt index ad89b9409..6c8d80041 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt @@ -64,7 +64,7 @@ class IntegratedSpeechTranslationService( private var audioProcessor: AudioProcessor? = null private var audioConfig: AudioConfig? = null // 录音文件处理 - var recordfile: RecordFile? = null + var recordfile1: RecordFile? = null var filePath: String? = null // 配置管理 private var serviceConfig = ServiceConfiguration() @@ -91,7 +91,9 @@ class IntegratedSpeechTranslationService( var enableContinuousRecognition: Boolean = true, var enableAutoLanguageDetection: Boolean = false, var maxRetryAttempts: Int = 3, - var translationTimeout: Long = 10000L + var translationTimeout: Long = 10000L, + // 新增:控制是否播放合成的音频 + var enableAudioPlayback: Boolean = false ) /** @@ -122,6 +124,8 @@ class IntegratedSpeechTranslationService( fun onRecognitionStopped() fun onStateChanged(component: String, isActive: Boolean) fun onError(component: String, error: String) + // 新增:返回合成的音频数据 + fun onSynthesisAudioGenerated(text: String, audioData: ByteArray) } /** @@ -190,8 +194,8 @@ class IntegratedSpeechTranslationService( withContext(Dispatchers.Main) { callback.onServiceInitialized() } -startContinuousTranslation() - recordfile = RecordFile; + //startContinuousTranslation() + recordfile1 = RecordFile(); return@withContext true } catch (e: Exception) { Log.e(TAG, "初始化失败", e) @@ -281,7 +285,7 @@ startContinuousTranslation() recognizing.addEventListener { _, event -> if (event.result.text.isNotEmpty()) { val confidence = extractConfidence(event.result) - Log.d(TAG, "识别中事件: ${event.result.text}") + Log.d(TAG, "识别中事件: ${event.result.text},serviceConfig.sourceLanguage:${serviceConfig.sourceLanguage}") eventCallback?.onRecognizing( event.result.text, serviceConfig.sourceLanguage, @@ -301,7 +305,7 @@ startContinuousTranslation() serviceConfig.sourceLanguage, confidence ) - Log.d(TAG, "识别完成事件: ${event.result.text}") + Log.d(TAG, "识别完成事件: ${event.result.text},serviceConfig.sourceLanguage:${serviceConfig.sourceLanguage}") // 触发翻译流程 launch { processTranslationAndSynthesis(event.result.text) @@ -353,8 +357,16 @@ startContinuousTranslation() */ private fun setupSpeechSynthesizer() { synthesizer?.close() - synthesizer = - SpeechSynthesizer(speechConfig, AudioConfig.fromDefaultSpeakerOutput()).apply { + + // 根据配置决定音频输出方式 + val audioConfig = if (serviceConfig.enableAudioPlayback) { + AudioConfig.fromDefaultSpeakerOutput() + } else { + // 不输出到扬声器,只生成音频数据 + null + } + + synthesizer = SpeechSynthesizer(speechConfig, audioConfig).apply { // 合成开始事件 SynthesisStarted.addEventListener { _, _ -> @@ -374,8 +386,7 @@ startContinuousTranslation() // 合成完成事件 SynthesisCompleted.addEventListener { _, event -> Log.d(TAG, "合成完成事件") - serviceState.isSynthesizing.set(false) - eventCallback?.onSynthesisCompleted("") + // eventCallback?.onSynthesisCompleted("") eventCallback?.onStateChanged("Synthesis", false) } @@ -389,13 +400,13 @@ startContinuousTranslation() } } - Log.d(TAG, "语音合成器设置完成") + Log.d(TAG, "语音合成器设置完成,播放模式: ${serviceConfig.enableAudioPlayback}") } /** * 开始连续语音翻译 */ - suspend fun startContinuousTranslation(): Boolean { + fun startContinuousTranslation(): Boolean { if (!serviceState.isInitialized.get()) { eventCallback?.onError("Service", "服务未初始化") return false @@ -426,10 +437,10 @@ startContinuousTranslation() fun enableRecord(filePath: String) { Log.i(TAG, "开启录音:") - recordfile!!.closeFile(true) + recordfile1!!.closeFile(true) - recordfile!!.creatingFiles(filePath) + recordfile1!!.creatingFiles(filePath) } @@ -440,7 +451,7 @@ startContinuousTranslation() try { recognizer?.stopContinuousRecognitionAsync() audioProcessor?.stopRecording() - recordfile!!.closeFile(true) + recordfile1!!.closeFile(true) // 等待当前处理完成 while (serviceState.isTranslating.get() || serviceState.isSynthesizing.get()) { Thread.sleep(100) @@ -510,11 +521,13 @@ startContinuousTranslation() /** * 语音合成 + * @param text 要合成的文本 + * @return 返回合成的音频数据,如果合成失败则返回null */ - private suspend fun synthesizeText(text: String) { + private suspend fun synthesizeText(text: String): ByteArray? { if (serviceState.isSynthesizing.get()) { Log.w(TAG, "语音合成正在进行中") - return + return null } try { @@ -524,20 +537,32 @@ startContinuousTranslation() val ssml = generateOptimizedSsml(text) - // 使用同步方法确保合成完成后再播放 - val result = synthesizer?.SpeakSsml(ssml) + // 始终使用异步方法获取音频数据 + val result = synthesizer?.SpeakSsmlAsync(ssml)?.get() if (result?.reason == ResultReason.SynthesizingAudioCompleted) { - Log.d(TAG, "语音合成成功,音频已播放") - eventCallback?.onSynthesisCompleted(text) + val playbackStatus = if (serviceConfig.enableAudioPlayback) "音频已播放" else "音频已合成但未播放" + Log.d(TAG, "语音合成成功,$playbackStatus") + + // 获取音频数据 + val audioData = result.audioData + if (audioData != null && audioData.isNotEmpty()) { + // 通过回调返回音频数据 + eventCallback?.onSynthesisAudioGenerated(text, audioData) + Log.d(TAG, "音频数据大小: ${audioData.size} 字节") + } + + return audioData } else { Log.e(TAG, "语音合成失败: ${result?.reason}") eventCallback?.onSynthesisFailed(text, "合成失败: ${result?.reason}") + return null } } catch (e: Exception) { Log.e(TAG, "语音合成失败", e) eventCallback?.onSynthesisFailed(text, "合成失败: ${e.message}") + return null } finally { serviceState.isSynthesizing.set(false) } @@ -693,6 +718,68 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { Log.d(TAG, "语言对已切换: ${newConfig.sourceLanguage} <-> ${newConfig.targetLanguage}") } + /** + * 设置是否启用音频播放 + * @param enabled true表示启用音频播放,false表示只合成不播放 + */ + fun setAudioPlaybackEnabled(enabled: Boolean) { + serviceConfig.enableAudioPlayback = enabled + Log.d(TAG, "音频播放设置更新: $enabled") + + // 重新设置语音合成器以应用新配置 + if (serviceState.isInitialized.get()) { + setupSpeechSynthesizer() + } + + eventCallback?.onStateChanged("AudioPlayback", enabled) + } + + /** + * 获取当前音频播放状态 + * @return true表示启用音频播放,false表示禁用 + */ + fun isAudioPlaybackEnabled(): Boolean { + return serviceConfig.enableAudioPlayback + } + + /** + * 单独的语音合成方法,只合成不播放,返回音频数据 + * @param text 要合成的文本 + * @return 合成的音频数据,失败时返回null + */ + suspend fun synthesizeTextToAudio(text: String): ByteArray? = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "开始纯音频合成: $text") + + val ssml = generateOptimizedSsml(text) + + // 创建一个临时的合成器,输出到内存而不是扬声器 + val audioConfig = AudioConfig.fromStreamOutput(AudioOutputStream.createPullStream()) + val tempSynthesizer = SpeechSynthesizer(speechConfig, audioConfig) + + try { + val result = tempSynthesizer.SpeakSsmlAsync(ssml).get() + + if (result?.reason == ResultReason.SynthesizingAudioCompleted) { + val audioData = result.audioData + if (audioData != null && audioData.isNotEmpty()) { + Log.d(TAG, "纯音频合成成功,大小: ${audioData.size} 字节") + return@withContext audioData + } + } else { + Log.e(TAG, "纯音频合成失败: ${result?.reason}") + } + } finally { + tempSynthesizer.close() + } + + return@withContext null + } catch (e: Exception) { + Log.e(TAG, "纯音频合成异常", e) + return@withContext null + } + } + /** * 推送外部音频数据 */ @@ -774,9 +861,9 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { val audioData = audioQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS) audioData?.let { - Log.d(TAG, "音频处理: ${it}") + Log.d(TAG, "音频处理: ${it.size}") pushAudioStream?.write(it) - recordfile?.saveAudioDataToWav(it) + recordfile1?.saveAudioDataToWav(it) } } catch (e: InterruptedException) { Thread.currentThread().interrupt() 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 acd098897..4263376e1 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 @@ -680,9 +680,16 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { result.error("ENABLERECORD_ERROR", e.message, null) } } - + "startContinuousTranslation" -> { + try { + FileLogger.d(tag, "开启翻译") + azureAstHelper.startContinuousTranslation() + result.success(true) + } catch (e: Exception) { + result.error("STOP_CONTINUOUS_TRANSLATION_ERROR", e.message, null) + } + } "stopContinuousTranslation" -> { - val isSave = call.argument("isSave") ?: false try { FileLogger.d(tag, "停止翻译") azureAstHelper.stopContinuousTranslation() @@ -691,6 +698,23 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { result.error("STOP_CONTINUOUS_TRANSLATION_ERROR", e.message, null) } } + "dispose" -> { + try { + FileLogger.d(tag, "释放AST资源") + azureAstHelper.dispose() + result.success(true) + } catch (e: Exception) { + result.error("STOP_CONTINUOUS_TRANSLATION_ERROR", e.message, null) + } + } + + "recognizeCallback" -> { + + + FileLogger.d(tag, "recognizeCallback:") // + result.success(true) + } + "path" -> { val filePath = call.argument("filePath") ?: "" try { @@ -769,6 +793,14 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } override fun onRecognized(text: String, language: String, confidence: Float) { + FileLogger.d(tag, "识别到文本: $text, 语言: $language, 置信度: $confidence") + sendAsrEvent( + mapOf( + "type" to "result1", + "text" to text, + "detectedLanguage" to language + ) + ) // sendAstEvent( // mapOf( // "type" to "recognized", @@ -823,6 +855,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } override fun onSynthesisCompleted(text: String) { + FileLogger.d(tag, "语音合成完成,文本=${text}") + + + // sendAstEvent( // mapOf( // "type" to "synthesisCompleted", @@ -830,7 +866,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { // ) // ) } - + override fun onSynthesisAudioGenerated(text: String, audioData: ByteArray) { + FileLogger.d(tag, "语音合成音频生成,文本=${text},音频数据大小=${audioData.size}") + BleService.writeExternalAudioData(audioData) + } override fun onSynthesisFailed(text: String, error: String) { // sendAstEvent( // mapOf( diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt index 0d0cf6afc..d0b69ccb1 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt @@ -9,11 +9,15 @@ import java.io.FileInputStream import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.atomic.AtomicBoolean -object RecordFile { +/** + * 音频录制文件管理类 + * 支持单声道和双声道录制,提供WAV文件格式输出 + */ +class RecordFile { private var currentAudioFile: File? = null private var fos: FileOutputStream? = null - // 新增:用于异步写入的队列和线程 + // 用于异步写入的队列和线程 private val writeQueue = LinkedBlockingQueue() private val isWriting = AtomicBoolean(false) private var writeThread: Thread? = null @@ -26,7 +30,7 @@ object RecordFile { private var sampleRate = 16000 private var channels = 1 // 1=单声道, 2=立体声 - // 新增:用于存储最后成功保存的文件 + // 用于存储最后成功保存的文件 private var lastSavedFile: File? = null private var lastFileTimestamp: String = "" var isPause = false @@ -44,16 +48,15 @@ object RecordFile { private var leftChannelBytesWritten = 0 private var rightChannelBytesWritten = 0 - /** * 设置音频配置 * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) * @param channels 声道数 (1=单声道, 2=立体声) */ - internal fun setAudioConfig(sampleRate: Int, channels: Int) { + fun setAudioConfig(sampleRate: Int, channels: Int) { this.sampleRate = sampleRate this.channels = channels - Log.d("tag", "音频配置已更新: 采样率=${sampleRate}Hz, 声道数=${channels}") + Log.d("RecordFile", "音频配置已更新: 采样率=${sampleRate}Hz, 声道数=${channels}") } /** diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index cda3594a4..af59cbd22 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -1170,10 +1170,10 @@ object BleService { // //重新编码 // opusManager?.writeEncodeStream(rightBuffer) - notifyAudioDataReceived1(leftBuffer) + //notifyAudioDataReceived1(leftBuffer) //回调 notifyAudioDataReceived(rightBuffer)//对方的 - // notifyAudioDataReceived1(leftBuffer)//自己的 + notifyAudioDataReceived1(leftBuffer)//自己的 } else if (option!!.getChannel() == 1) { notifyAudioDataReceived(data) } else { From 0e15615318dc6f24ab0575397825905f9908ce16 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Wed, 30 Jul 2025 16:11:02 +0800 Subject: [PATCH 07/23] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E8=AF=81=E5=8F=91=E9=80=81=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/com/yunqiinnovation/ble_service/BleService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index af59cbd22..392b9e177 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -127,7 +127,7 @@ object BleService { // 音频数据分块发送的常量 private val AUDIO_CHUNK_SIZE = 40 // 每次发送40字节 - private val AUDIO_SEND_INTERVAL = 10L // 发送间隔10ms + private val AUDIO_SEND_INTERVAL = 30L // 发送间隔10ms // 初始化状态 private var isInitialized = false @@ -1330,7 +1330,7 @@ object BleService { sendAudioChunk(audioChunk) // 控制发送频率,避免蓝牙缓冲区溢出 - //Thread.sleep(AUDIO_SEND_INTERVAL) + Thread.sleep(AUDIO_SEND_INTERVAL) } } catch (e: InterruptedException) { Log.d(TAG, "音频发送线程被中断") From db2618a20bc086b5ac369e756b64549adb47d8f1 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Wed, 30 Jul 2025 18:05:48 +0800 Subject: [PATCH 08/23] =?UTF-8?q?=E5=8A=A0=E5=85=A5opus=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/opus_test_controller.dart | 161 ++++++++++++++++++ .../opus_test/views/opus_test_view.dart | 4 + lib/modules/settings/views/settings_view.dart | 36 ++-- 3 files changed, 182 insertions(+), 19 deletions(-) diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart index 75b60b3ad..4fad692e9 100644 --- a/lib/modules/opus_test/controllers/opus_test_controller.dart +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:just_audio/just_audio.dart'; import 'package:jl_opus/jl_opus.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:share_plus/share_plus.dart'; // 添加分享插件导入 class OpusTestController extends GetxController { // 选中的文件 @@ -346,6 +347,166 @@ class OpusTestController extends GetxController { // 数据大小 data.setUint32(40, pcmLength, Endian.little); } + + /// 删除外部文件 + Future deleteExternalFile(PlatformFile file) async { + try { + final fileToDelete = File(file.path!); + if (await fileToDelete.exists()) { + await fileToDelete.delete(); + externalFiles.removeWhere((f) => f.path == file.path); + + // 如果删除的是当前选中的文件,清空选择 + if (selectedFiles.isNotEmpty && selectedFiles.first.path == file.path) { + selectedFiles.clear(); + } + + statusMessage.value = '文件已删除: ${file.name}'; + } else { + statusMessage.value = '文件不存在: ${file.name}'; + } + } catch (e) { + statusMessage.value = '删除文件失败: $e'; + } + } + + /// 分享外部文件 + Future shareExternalFile(PlatformFile file) async { + try { + if (file.path != null) { + final fileToShare = File(file.path!); + if (await fileToShare.exists()) { + await Share.shareXFiles( + [XFile(file.path!)], + text: '分享Opus音频文件: ${file.name}', + ); + statusMessage.value = '正在分享文件: ${file.name}'; + } else { + statusMessage.value = '文件不存在: ${file.name}'; + } + } + } catch (e) { + statusMessage.value = '分享文件失败: $e'; + } + } + + /// 显示文件操作底部弹窗 + void showFileActionSheet(BuildContext context, PlatformFile file) { + showModalBottomSheet( + context: context, + backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => Container( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 文件信息 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + Icons.audiotrack, + color: Get.isDarkMode ? Colors.white70 : Colors.black87, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Get.isDarkMode ? Colors.white : Colors.black, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + '${(file.size / 1024).toStringAsFixed(2)} KB', + style: TextStyle( + fontSize: 14, + color: Get.isDarkMode + ? Colors.white60 + : Colors.black54, + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(), + // 分享选项 + ListTile( + leading: const Icon(Icons.share, color: Colors.blue), + title: const Text('分享文件'), + onTap: () { + Navigator.pop(context); + shareExternalFile(file); + }, + ), + // 删除选项 + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: const Text('删除文件', style: TextStyle(color: Colors.red)), + onTap: () { + Navigator.pop(context); + _showDeleteConfirmDialog(context, file); + }, + ), + // 取消选项 + ListTile( + leading: const Icon(Icons.cancel), + title: const Text('取消'), + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } + + /// 显示删除确认对话框 + void _showDeleteConfirmDialog(BuildContext context, PlatformFile file) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white, + title: Text( + '确认删除', + style: TextStyle( + color: Get.isDarkMode ? Colors.white : Colors.black, + ), + ), + content: Text( + '确定要删除文件 "${file.name}" 吗?\n此操作不可撤销。', + style: TextStyle( + color: Get.isDarkMode ? Colors.white70 : Colors.black87, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + deleteExternalFile(file); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('删除'), + ), + ], + ), + ); + } } // PCM音频源 diff --git a/lib/modules/opus_test/views/opus_test_view.dart b/lib/modules/opus_test/views/opus_test_view.dart index adecc15b6..de02e5138 100644 --- a/lib/modules/opus_test/views/opus_test_view.dart +++ b/lib/modules/opus_test/views/opus_test_view.dart @@ -170,6 +170,10 @@ class OpusTestView extends GetView { onTap: () { controller.selectExternalFile(file); }, + onLongPress: () { + // 长按显示操作菜单 + controller.showFileActionSheet(context, file); + }, trailing: Icon( Icons.arrow_forward, color: diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 58d527fdb..f6c2e7892 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -407,8 +407,6 @@ class SettingsView extends GetView { ? Colors.white.withOpacity(0.1) : Colors.grey[200]), - - // 用户反馈 _buildSimpleNavigationSetting( title: 'feedback'.tr, // 用户反馈 @@ -520,23 +518,23 @@ class SettingsView extends GetView { // color: isDarkMode // ? Colors.white.withOpacity(0.1) // : Colors.grey[200]), - // BLE测试 - // _buildSimpleNavigationSetting( - // title: 'opus解码测试', - // subtitle: '测试opus解码', - // icon: Icons.bluetooth_searching, - // iconBgColor: isDarkMode - // ? Colors.green[900]!.withOpacity(0.3) - // : Colors.green[100]!, - // iconColor: - // isDarkMode ? Colors.green[300]! : Colors.green[600]!, - // titleColor: isDarkMode ? Colors.white : null, - // subtitleColor: isDarkMode ? Colors.white70 : null, - // onTap: () { - // Get.toNamed(Routes.opusTest); - // }, - // isDarkMode: isDarkMode, - // ), + //BLE测试 + _buildSimpleNavigationSetting( + title: 'opus解码测试', + subtitle: '测试opus解码', + icon: Icons.bluetooth_searching, + iconBgColor: isDarkMode + ? Colors.green[900]!.withOpacity(0.3) + : Colors.green[100]!, + iconColor: + isDarkMode ? Colors.green[300]! : Colors.green[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + Get.toNamed(Routes.opusTest); + }, + isDarkMode: isDarkMode, + ), // Divider( // height: 1, // color: isDarkMode From 2d25fa8f796c1049e7c2d79e45d69d534bbe1245 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Thu, 31 Jul 2025 10:34:39 +0800 Subject: [PATCH 09/23] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=80=9F=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../azure_speech/AzureAsrHelper.kt | 2 +- .../azure_speech/AzureAsrToAsr.kt | 2 +- .../yunqiinnovation/ble_service/BleService.kt | 45 ++++++++++++++++--- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index ed514914a..5bd03023d 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -721,7 +721,7 @@ class AzureAsrHelper(private val context: Context) { // 情况2:不在录制但队列有数据 -> 从队列获取 else if (writeQueue.isNotEmpty()) { data = writeQueue.poll() - Log.d("tag", "写入数据: ${data?.size}") + //Log.d("tag", "写入数据: ${data?.size}") bytesToWrite = data?.size ?: 0 } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt index 6c8d80041..d75afa7b6 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt @@ -861,7 +861,7 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { val audioData = audioQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS) audioData?.let { - Log.d(TAG, "音频处理: ${it.size}") + //Log.d(TAG, "音频处理: ${it.size}") pushAudioStream?.write(it) recordfile1?.saveAudioDataToWav(it) } diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 392b9e177..649d22f75 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -132,6 +132,23 @@ object BleService { // 初始化状态 private var isInitialized = false + // 音频数据统计相关变量 + private var bytesReceivedInCurrentSecond = 0 + private var lastStatisticsTime = System.currentTimeMillis() + private val statisticsHandler = Handler(Looper.getMainLooper()) + private val statisticsRunnable = object : Runnable { + override fun run() { + val currentTime = System.currentTimeMillis() + val timeDiff = currentTime - lastStatisticsTime + if (timeDiff >= 1000) { // 每秒统计一次 + Log.i(TAG, "每秒接收音频数据: $bytesReceivedInCurrentSecond 字节") + bytesReceivedInCurrentSecond = 0 + lastStatisticsTime = currentTime + } + statisticsHandler.postDelayed(this, 1000) // 每秒执行一次 + } + } + private val replyTimeoutHandler = Handler(Looper.getMainLooper()) private val replyTimeoutRunnable = Runnable { if (!isReply) { @@ -567,6 +584,20 @@ object BleService { notifyConnectionStateChanged(state) } + // 在连接成功时启动统计(可以在onConnectionStateChange的连接成功分支中添加) + private fun startBytesStatistics() { + bytesReceivedInCurrentSecond = 0 + lastStatisticsTime = System.currentTimeMillis() + statisticsHandler.post(statisticsRunnable) + Log.i(TAG, "开始统计每秒接收字节数") + } + + // 在断开连接时停止统计 + private fun stopBytesStatistics() { + statisticsHandler.removeCallbacks(statisticsRunnable) + Log.i(TAG, "停止统计每秒接收字节数") + } + /** * GATT回调 */ @@ -618,7 +649,7 @@ object BleService { return } Log.d(TAG, "找到通话音频服务所需特征") - +startBytesStatistics() writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE callWriteChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE //要先设置音频服务的通知,否则接收不到 @@ -643,7 +674,9 @@ object BleService { when (c.uuid) { // 音频特征数据 BleConst.RECEIVE_AUDIO_CHAR_UUID -> { - + + // 统计接收到的字节数 + bytesReceivedInCurrentSecond += data.size processAudioData(data) } // 通知特征数据(命令和控制) @@ -720,10 +753,12 @@ object BleService { */ private fun processAudioData(data: ByteArray) { try { + + if (opusManager?.isDecodeStream == true) { recordfile?.saveAudioDataToWav(data) - // 将完整的帧加入队列,由专门的线程处理 - audioDataQueue.offer(data) + // 将完整的帧加入队列,由专门的线程处理 + audioDataQueue.offer(data) // // 将接收到的数据添加到缓存中 // synchronized(audioDataBuffer) { // audioDataBuffer.addAll(data.toList()) @@ -741,7 +776,7 @@ object BleService { // } // } } else { - Log.d(TAG, "Opus解码流未启动,忽略音频数据") + // Log.d(TAG, "Opus解码流未启动,忽略音频数据") } } catch (e: Exception) { Log.e(TAG, "处理音频数据异常: ${e.message}", e) From 17e9577b8571a2bacb16e953cd75219e3249492c Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Thu, 7 Aug 2025 12:06:39 +0800 Subject: [PATCH 10/23] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E8=AE=BE=E7=BD=AEphy?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yunqiinnovation/ble_service/BleService.kt | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 649d22f75..299005b5f 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -127,7 +127,7 @@ object BleService { // 音频数据分块发送的常量 private val AUDIO_CHUNK_SIZE = 40 // 每次发送40字节 - private val AUDIO_SEND_INTERVAL = 30L // 发送间隔10ms + private val AUDIO_SEND_INTERVAL = 20L // 发送间隔10ms // 初始化状态 private var isInitialized = false @@ -607,8 +607,28 @@ object BleService { when { status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED -> { updateConnectionState(BleConst.STATE_CONNECTED) + + // 设置PHY值为2M以提高传输速度 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + // 首先尝试读取当前PHY状态 + g.readPhy() + + // 然后请求设置2M PHY + g.setPreferredPhy( + BluetoothDevice.PHY_LE_2M_MASK, // TX PHY: 2M + BluetoothDevice.PHY_LE_2M_MASK, // RX PHY: 2M + BluetoothDevice.PHY_OPTION_NO_PREFERRED + ) + Log.d(TAG, "已请求设置PHY为2M") + } catch (e: Exception) { + Log.w(TAG, "设置PHY失败: ${e.message},将使用默认1M PHY") + } + } else { + Log.w(TAG, "当前Android版本不支持PHY设置(需要API 26+),使用默认1M PHY") + } + g.discoverServices() - } newState == BluetoothProfile.STATE_DISCONNECTED -> { @@ -622,6 +642,45 @@ object BleService { } } } + + /** + * PHY读取回调 - 获取当前PHY状态 + */ + override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val txPhyStr = getPhyString(txPhy) + val rxPhyStr = getPhyString(rxPhy) + Log.i(TAG, "当前PHY状态: TX=$txPhyStr, RX=$rxPhyStr") + } else { + Log.w(TAG, "读取PHY状态失败: status=$status") + } + } + } + + /** + * PHY更新回调 - 监控PHY设置结果 + */ + override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + when (status) { + BluetoothGatt.GATT_SUCCESS -> { + val txPhyStr = getPhyString(txPhy) + val rxPhyStr = getPhyString(rxPhy) + Log.i(TAG, "PHY更新成功: TX=$txPhyStr, RX=$rxPhyStr") + } + 6 -> { // GATT_REQUEST_NOT_SUPPORTED + Log.w(TAG, "设备不支持PHY更新,将继续使用1M PHY进行通信") + } + 133 -> { // GATT_ERROR + Log.w(TAG, "PHY更新出现一般性错误,可能是设备兼容性问题") + } + else -> { + Log.w(TAG, "PHY更新失败: status=$status") + } + } + } + } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { @@ -1835,6 +1894,22 @@ startBytesStatistics() } } + /** + * 获取PHY类型的字符串描述 + */ + private fun getPhyString(phy: Int): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + when (phy) { + BluetoothDevice.PHY_LE_1M -> "1M" + BluetoothDevice.PHY_LE_2M -> "2M" + BluetoothDevice.PHY_LE_CODED -> "Coded" + else -> "Unknown($phy)" + } + } else { + "Unknown($phy)" + } + } + /** * 启动音频队列处理 */ From 261ef5e2b5ab9d7c34ab3f785a842321c9c2c1eb Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Fri, 8 Aug 2025 19:18:30 +0800 Subject: [PATCH 11/23] =?UTF-8?q?=E6=94=B9=E6=88=9080=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/login/bindings/login_binding.dart | 5 +- .../login/controllers/login_controller.dart | 35 ++++-- .../yunqiinnovation/ble_service/BleService.kt | 100 +++++++++++++++--- 3 files changed, 113 insertions(+), 27 deletions(-) diff --git a/lib/modules/login/bindings/login_binding.dart b/lib/modules/login/bindings/login_binding.dart index 6729681f9..28907a22e 100644 --- a/lib/modules/login/bindings/login_binding.dart +++ b/lib/modules/login/bindings/login_binding.dart @@ -4,6 +4,7 @@ import '../controllers/login_controller.dart'; class LoginBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => LoginController()); + // 使用 put 而不是 lazyPut,确保控制器立即创建 + Get.put(LoginController(), permanent: false); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index bb93f65f3..334681334 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -211,15 +211,30 @@ class LoginController extends GetxController { String get displayContact => _displayContact.value; List get verificationCode => _verificationCode; - // 文本控制器 - TextEditingController contactController = TextEditingController(); - TextEditingController verificationCodeController = TextEditingController(); + // 文本控制器 - 延迟初始化 + TextEditingController? _contactController; + TextEditingController? _verificationCodeController; + + // Getter 方法,确保控制器存在 + TextEditingController get contactController { + _contactController ??= TextEditingController(); + return _contactController!; + } + + TextEditingController get verificationCodeController { + _verificationCodeController ??= TextEditingController(); + return _verificationCodeController!; + } @override void onInit() { super.onInit(); _viewState.value = LoginViewState.welcome; + // 初始化控制器 + _contactController = TextEditingController(); + _verificationCodeController = TextEditingController(); + isChinaMainland.value = Get.deviceLocale?.countryCode?.toUpperCase() == 'CN'; @@ -236,17 +251,21 @@ class LoginController extends GetxController { @override void onClose() { - // Safely dispose controllers + // 安全地销毁控制器 try { - contactController.dispose(); + _contactController?.dispose(); + _contactController = null; } catch (e) { - // Controller already disposed + Logger().e('销毁 contactController 失败: $e'); } + try { - verificationCodeController.dispose(); + _verificationCodeController?.dispose(); + _verificationCodeController = null; } catch (e) { - // Controller already disposed + Logger().e('销毁 verificationCodeController 失败: $e'); } + super.onClose(); } diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 299005b5f..b56963757 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -126,14 +126,19 @@ object BleService { private val isAudioSending = AtomicBoolean(false) // 音频数据分块发送的常量 - private val AUDIO_CHUNK_SIZE = 40 // 每次发送40字节 - private val AUDIO_SEND_INTERVAL = 20L // 发送间隔10ms + private val AUDIO_CHUNK_SIZE = 80 // 每次发送80字节 + private val AUDIO_SEND_INTERVAL = 40L // 发送间隔20ms + + // 音频数据缓冲区,用于累积数据到80字节再发送 + private val audioBuffer = mutableListOf() // 初始化状态 private var isInitialized = false // 音频数据统计相关变量 private var bytesReceivedInCurrentSecond = 0 + // 添加发送数据统计变量 + private var bytesSentInCurrentSecond = 0 private var lastStatisticsTime = System.currentTimeMillis() private val statisticsHandler = Handler(Looper.getMainLooper()) private val statisticsRunnable = object : Runnable { @@ -142,7 +147,9 @@ object BleService { val timeDiff = currentTime - lastStatisticsTime if (timeDiff >= 1000) { // 每秒统计一次 Log.i(TAG, "每秒接收音频数据: $bytesReceivedInCurrentSecond 字节") + Log.i(TAG, "每秒发送音频数据: $bytesSentInCurrentSecond 字节") bytesReceivedInCurrentSecond = 0 + bytesSentInCurrentSecond = 0 lastStatisticsTime = currentTime } statisticsHandler.postDelayed(this, 1000) // 每秒执行一次 @@ -1376,28 +1383,33 @@ startBytesStatistics() } /** - * 将音频数据分块并加入发送队列 + * 将音频数据添加到发送队列 + * 只有当缓冲区达到80字节时才发送数据 + * @param data 音频数据字节数组 */ private fun addAudioDataToSendQueue(data: ByteArray) { try { - // 将大数据分成40字节的小块 - var offset = 0 - while (offset < data.size) { - val chunkSize = minOf(AUDIO_CHUNK_SIZE, data.size - offset) - val chunk = ByteArray(chunkSize) - System.arraycopy(data, offset, chunk, 0, chunkSize) + // 将新数据添加到缓冲区 + audioBuffer.addAll(data.toList()) + + // 当缓冲区达到80字节时,发送数据 + while (audioBuffer.size >= 80) { + // 取出80字节数据 + val chunk = ByteArray(80) + for (i in 0 until 80) { + chunk[i] = audioBuffer.removeAt(0) + } - // 将分块数据加入队列 + // 将数据加入发送队列 if (!audioSendQueue.offer(chunk)) { Log.w(TAG, "音频发送队列已满,丢弃数据块") + break } - - offset += chunkSize } - Log.d(TAG, "音频数据已分块加入队列,原始大小: ${data.size} 字节,分块数: ${(data.size + AUDIO_CHUNK_SIZE - 1) / AUDIO_CHUNK_SIZE}") + // Log.d(TAG, "音频数据已加入缓冲区,当前缓冲区大小: ${audioBuffer.size} 字节") } catch (e: Exception) { - Log.e(TAG, "分块音频数据异常: ${e.message}", e) + Log.e(TAG, "处理音频数据异常: ${e.message}", e) } } @@ -1448,11 +1460,13 @@ startBytesStatistics() isAudioSending.set(false) audioSendThread?.interrupt() audioSendQueue.clear() - Log.i(TAG, "音频发送线程已停止,队列已清空") + audioBuffer.clear() // 清空音频缓冲区 + Log.i(TAG, "音频发送线程已停止,队列和缓冲区已清空") } /** - * 发送单个音频数据块 + * 发送音频数据块到设备 + * @param chunk 要发送的音频数据块 */ private fun sendAudioChunk(chunk: ByteArray) { try { @@ -1468,9 +1482,61 @@ startBytesStatistics() val isSuccess = bluetoothGatt?.writeCharacteristic(callWriteChar) if (isSuccess == true) { + // 累加发送成功的字节数 + bytesSentInCurrentSecond += chunk.size Log.d(TAG, "成功发送音频数据块,大小: ${chunk.size} 字节") } else { - Log.e(TAG, "发送音频数据块失败,大小: ${chunk.size} 字节") + // 详细的失败原因分析 + val failureReason = StringBuilder("发送音频数据块失败,大小: ${chunk.size} 字节") + + // 检查蓝牙连接状态 + if (bluetoothGatt == null) { + failureReason.append(" - 原因: BluetoothGatt为空") + } else { + // 检查连接状态 + val connectionState = bluetoothManager.getConnectionState( + bluetoothGatt!!.device, + BluetoothProfile.GATT + ) + when (connectionState) { + BluetoothProfile.STATE_DISCONNECTED -> { + failureReason.append(" - 原因: 设备已断开连接") + } + BluetoothProfile.STATE_CONNECTING -> { + failureReason.append(" - 原因: 设备正在连接中") + } + BluetoothProfile.STATE_DISCONNECTING -> { + failureReason.append(" - 原因: 设备正在断开连接") + } + BluetoothProfile.STATE_CONNECTED -> { + // 连接正常,检查其他原因 + if (callWriteChar == null) { + failureReason.append(" - 原因: 写入特征值为空") + } else { + // 检查特征值属性 + val properties = callWriteChar!!.properties + if ((properties and BluetoothGattCharacteristic.PROPERTY_WRITE) == 0 && + (properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) == 0) { + failureReason.append(" - 原因: 特征值不支持写入操作") + } else if (chunk.size > 512) { // BLE MTU通常限制 + failureReason.append(" - 原因: 数据块过大 (${chunk.size} > 512字节)") + } else { + failureReason.append(" - 原因: 未知错误,可能是设备忙碌或缓冲区满") + } + } + } + else -> { + failureReason.append(" - 原因: 未知连接状态($connectionState)") + } + } + } + + // 检查蓝牙适配器状态 + if (bluetoothAdapter?.isEnabled != true) { + failureReason.append(" - 蓝牙适配器未启用") + } + + Log.e(TAG, failureReason.toString()) } } catch (e: Exception) { Log.e(TAG, "发送音频数据块异常: ${e.message}", e) From 4110407d3dcffc04f2275e449022bcd31ccdf105 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Sat, 9 Aug 2025 11:33:15 +0800 Subject: [PATCH 12/23] =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=BA=90=E5=AF=BC=E8=87=B4ios=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/controllers/agent_controller.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index a6a5730f7..82e7696e1 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -1056,9 +1056,9 @@ class AgentController extends GetxController { try { await AgentService.recognizeCallback(); if (_bluetoothManager.currentDeviceRx.value != null) { - await AgentService.restoreOriginalAudioState(); + // await AgentService.restoreOriginalAudioState(); } else { - await AgentService.disableBluetoothAudio(); + // await AgentService.disableBluetoothAudio(); } } catch (e) { Logger.e(TAG, '启动识别器失败: $e'); From 01fd11a4730edb289ea9ada042de88ede005d1dd Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Sat, 9 Aug 2025 17:35:27 +0800 Subject: [PATCH 13/23] =?UTF-8?q?=E6=94=B9=E6=88=90120=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E5=8F=91=E9=80=81opus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/translation_controller.dart | 14 +++++ .../yunqiinnovation/ble_service/BleService.kt | 55 ++++++++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 69a6ba7d6..3751c3cca 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -1132,6 +1132,9 @@ class TranslationController extends GetxController { // 重新初始化ASR服务以支持新的语言 Future _reinitializeAsrService() async { try { + // 停止当前识别 + await stopRecognition(); + // 更新ASR支持的语言 final List asrSupportedLanguages = [ sourceLanguageCode.value, @@ -1144,6 +1147,17 @@ class TranslationController extends GetxController { await _asrService.initialize( supportedLanguages: asrSupportedLanguages, ); +// Future initialize({ +// required String subscriptionKey, +// required String region, +// required List supportedLanguages, +// required String audioSourceType, +// required String translationAccessKey, +// required String translationSecretKey, +// String translationRegion = 'cn-north-1', +// }); + // 重新初始化ASR服务以支持通话音频源 + await _astService.initialize(supportedLanguages: asrSupportedLanguages); // 获取识别事件流 - 这启动了异步识别过程 var recognitionStream = await _asrService.recognizeCallback(); // 监听识别结果 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index b56963757..296c36d45 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -126,11 +126,15 @@ object BleService { private val isAudioSending = AtomicBoolean(false) // 音频数据分块发送的常量 - private val AUDIO_CHUNK_SIZE = 80 // 每次发送80字节 - private val AUDIO_SEND_INTERVAL = 40L // 发送间隔20ms + private val AUDIO_CHUNK_SIZE = 120 // 每次发送80字节 + private val AUDIO_SEND_INTERVAL = 60L // 发送间隔20ms // 音频数据缓冲区,用于累积数据到80字节再发送 private val audioBuffer = mutableListOf() + + // 重发机制相关常量 + private val MAX_RETRY_COUNT = 1 // 最大重试次数 + private val RETRY_DELAY = 30L // 重试延迟时间(毫秒) // 初始化状态 private var isInitialized = false @@ -1393,10 +1397,10 @@ startBytesStatistics() audioBuffer.addAll(data.toList()) // 当缓冲区达到80字节时,发送数据 - while (audioBuffer.size >= 80) { + while (audioBuffer.size >= AUDIO_CHUNK_SIZE) { // 取出80字节数据 - val chunk = ByteArray(80) - for (i in 0 until 80) { + val chunk = ByteArray(AUDIO_CHUNK_SIZE) + for (i in 0 until AUDIO_CHUNK_SIZE) { chunk[i] = audioBuffer.removeAt(0) } @@ -1465,10 +1469,11 @@ startBytesStatistics() } /** - * 发送音频数据块到设备 + * 发送音频数据块到设备(带重发机制) * @param chunk 要发送的音频数据块 + * @param retryCount 当前重试次数 */ - private fun sendAudioChunk(chunk: ByteArray) { + private fun sendAudioChunk(chunk: ByteArray, retryCount: Int = 0) { try { if (callWriteChar == null || bluetoothGatt == null) { Log.e(TAG, "蓝牙连接或特征值未准备就绪") @@ -1486,6 +1491,7 @@ startBytesStatistics() bytesSentInCurrentSecond += chunk.size Log.d(TAG, "成功发送音频数据块,大小: ${chunk.size} 字节") } else { + Log.e(TAG, "发送音频数据块失败,大小: ${chunk.size} 字节") // 详细的失败原因分析 val failureReason = StringBuilder("发送音频数据块失败,大小: ${chunk.size} 字节") @@ -1537,9 +1543,44 @@ startBytesStatistics() } Log.e(TAG, failureReason.toString()) + + // // 实现重发机制 + // if (retryCount < MAX_RETRY_COUNT) { + // Log.w(TAG, "准备重发音频数据块,重试次数: ${retryCount + 1}/$MAX_RETRY_COUNT") + + // // 延迟后重发 + // audioSendHandler.postDelayed({ + // val isSuccess = bluetoothGatt?.writeCharacteristic(callWriteChar) + + // if (isSuccess == true) { + // Log.d(TAG, "重发音频数据块成功") + // } else { + // Log.e(TAG, "重发音频数据块失败") + // } + // }, RETRY_DELAY) + // } else { + // Log.e(TAG, "音频数据块发送失败,已达到最大重试次数($MAX_RETRY_COUNT),丢弃数据块") + // // 可以选择将失败的数据块记录到日志或进行其他处理 + // } } } catch (e: Exception) { Log.e(TAG, "发送音频数据块异常: ${e.message}", e) + + // // 异常情况下也进行重发 + // if (retryCount < MAX_RETRY_COUNT) { + // Log.w(TAG, "发送异常,准备重发音频数据块,重试次数: ${retryCount + 1}/$MAX_RETRY_COUNT") + // audioSendHandler.postDelayed({ + // val isSuccess = bluetoothGatt?.writeCharacteristic(callWriteChar) + + // if (isSuccess == true) { + // Log.d(TAG, "重发音频数据块成功") + // } else { + // Log.e(TAG, "重发音频数据块失败") + // } + // }, RETRY_DELAY) + // } else { + // Log.e(TAG, "音频数据块发送异常,已达到最大重试次数($MAX_RETRY_COUNT),丢弃数据块") + // } } } } catch (e: Exception) { From fbf15009e293c6838745924d80cf4cef0cedcc31 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Sat, 9 Aug 2025 21:08:56 +0800 Subject: [PATCH 14/23] =?UTF-8?q?=E5=BD=95=E9=9F=B3=E8=A7=A3=E5=81=B6?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/asr_service.dart | 3 + lib/data/services/audio_service.dart | 29 ++ .../speech_impl/azure_asr_service.dart | 12 + .../speech_impl/volcano_asr_api_service.dart | 6 + .../speech_impl/volcano_asr_service.dart | 6 + .../speech_impl/xunfei_asr_service.dart | 6 + .../meeting_record_controller.dart | 4 +- .../azure_speech/AzureAsrHelper.kt | 482 +++--------------- .../azure_speech/AzureSpeechPlugin.kt | 10 +- .../azure_speech/tools/RecordFile.kt | 2 +- .../{ => tools}/SimpleAudioPlayer.kt | 2 +- .../azure_speech/tools/SimpleAudioReceiver.kt | 403 +++++++++++++++ .../azure_speech/AzureSpeechPlugin.swift | 5 +- 13 files changed, 551 insertions(+), 419 deletions(-) create mode 100644 lib/data/services/audio_service.dart rename local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/{ => tools}/SimpleAudioPlayer.kt (99%) create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 54b507005..1fba23f52 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -46,6 +46,9 @@ abstract class AsrService { /// 暂停录音 Future pauseRecord(); + /// 继续录音 + Future resumeRecord(); + /// 设置音频配置 Future setAudioConfig({ int sampleRate = 16000, diff --git a/lib/data/services/audio_service.dart b/lib/data/services/audio_service.dart new file mode 100644 index 000000000..6dbdd781f --- /dev/null +++ b/lib/data/services/audio_service.dart @@ -0,0 +1,29 @@ +import 'dart:async'; +import 'dart:typed_data'; + +/// 语音识别服务接口 +abstract class AudioService { + /// 开始录音 + Future enableRecord(String filePath); + + /// 暂停录音 + Future pauseRecord(); + + /// 继续录音 + Future resumeRecord(); + + /// 设置音频配置 + Future setAudioConfig({ + int sampleRate = 16000, + int channels = 1, + }); + + // /// 移动文件到新路径 + Future moveFile(String sourcePath, String destPath); + + // /// 重命名指定路径的音频文件 + Future renameFile(String filePath, String newName); + + /// 结束录音 + Future stopRecord(bool isSave); +} diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 832b83b5b..0009cd758 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -494,4 +494,16 @@ class AzureAsrService extends GetxService implements AsrService { rethrow; } } + + @override + Future resumeRecord() async { + try { + final bool result = await _channel.invokeMethod('resumeRecord'); + + return result; + } catch (e) { + Logger.error('继续录音: ${e.toString()}'); + rethrow; + } + } } diff --git a/lib/data/services/speech_impl/volcano_asr_api_service.dart b/lib/data/services/speech_impl/volcano_asr_api_service.dart index 32844d1bb..126aea878 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -871,4 +871,10 @@ class VolcanoAsrApiService implements AsrService { // TODO: implement setAudioConfig throw UnimplementedError(); } + + @override + Future resumeRecord() { + // TODO: implement resumeRecord + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart index 412656ddd..95d9c1afc 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -406,4 +406,10 @@ class VolcanoAsrService extends GetxService implements AsrService { // TODO: implement setAudioConfig throw UnimplementedError(); } + + @override + Future resumeRecord() { + // TODO: implement resumeRecord + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index cc94063e8..ea8a52251 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -330,4 +330,10 @@ class XunfeiAsrService extends GetxService implements AsrService { // TODO: implement setAudioConfig throw UnimplementedError(); } + + @override + Future resumeRecord() { + // TODO: implement resumeRecord + throw UnimplementedError(); + } } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index a5b57087b..71ce8796e 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -123,7 +123,7 @@ class MeetingRecordController extends GetxController /// Initializes UI controllers and focus nodes void _initializeComponents() { - tabController = TabController(length: 2, vsync: this); + tabController = TabController(length: 1, vsync: this); titleEditingController = TextEditingController(text: fileName.value); titleFocusNode = FocusNode(); titleFocusNode.addListener(_handleTitleFocusChange); @@ -294,7 +294,7 @@ class MeetingRecordController extends GetxController /// Resumes paused recording session Future _resumeRecording() async { - await _asrService.startContinuousRecognition(_audioSourceType); + await _asrService.resumeRecord(); startTimer(); } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 8ccfc4206..373c2a5d7 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -58,14 +58,11 @@ class AzureAsrHelper(private val context: Context) { var audioSourceType = AudioSourceType.MICROPHONE // 音频处理 - var audioStream: AudioStream? = null + var audioStream: SimpleAudioReceiver? = null + + - // 音频模式管理 - private var audioManager: AudioManager? = null - private var originalAudioMode = AudioManager.MODE_NORMAL - // 录音文件处理 - var recordfile: RecordFile? = null /** * 音频来源类型 @@ -103,10 +100,6 @@ class AzureAsrHelper(private val context: Context) { // 释放之前的资源 dispose() - // 初始化音频管理器 - audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL - // 保存配置 this.subscriptionKey = subscriptionKey @@ -147,9 +140,7 @@ class AzureAsrHelper(private val context: Context) { // 设置分段策略为时间模式 setProperty("Speech_SegmentationStrategy", "Time") } - // 录音文件类 - recordfile = RecordFile(); - + return true } catch (e: Exception) { @@ -158,20 +149,20 @@ class AzureAsrHelper(private val context: Context) { } } - /** - * 设置音频配置 - * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) - * @param channels 声道数 (1=单声道, 2=立体声) - */ - fun setAudioConfig(sampleRate: Int, channels: Int) { - try { - // 设置RecordFile的音频配置 - recordfile?.setAudioConfig(sampleRate, channels) - Log.d(tag, "音频配置已设置: 采样率=${sampleRate}Hz, 声道数=${channels}") - } catch (e: Exception) { - Log.e(tag, "设置音频配置失败: ${e.message}") - } - } + // /** + // * 设置音频配置 + // * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) + // * @param channels 声道数 (1=单声道, 2=立体声) + // */ + // fun setAudioConfig(sampleRate: Int, channels: Int) { + // try { + // // 设置RecordFile的音频配置 + // recordfile?.setAudioConfig(sampleRate, channels) + // Log.d(tag, "音频配置已设置: 采样率=${sampleRate}Hz, 声道数=${channels}") + // } catch (e: Exception) { + // Log.e(tag, "设置音频配置失败: ${e.message}") + // } + // } private fun isRecognizerValid(): Boolean { return recognizer != null @@ -218,7 +209,7 @@ class AzureAsrHelper(private val context: Context) { if (audioStream == null) { // 创建外部音频拉流对象 - audioStream = AudioStream() + audioStream = SimpleAudioReceiver(context) audioStream!!.initAudioRecord() } @@ -256,7 +247,10 @@ class AzureAsrHelper(private val context: Context) { try { // 启动音频处理 - audioStream!!.startAudioRecord() + audioStream!!.startAudioRecord( when(audioSourceType) { + AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE + AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL + }) // 执行同步识别 val result = recognizer?.recognizeOnceAsync()?.get() @@ -311,7 +305,12 @@ class AzureAsrHelper(private val context: Context) { // 开始连续识别 // 启动音频处理 - audioStream!!.startAudioRecord() + audioStream!!.startAudioRecord( + when(audioSourceType) { + AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE + AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL + } + ) recognizer?.startContinuousRecognitionAsync() isContinuousRecognitionActive = true @@ -483,7 +482,9 @@ class AzureAsrHelper(private val context: Context) { // 停止音频处理 stopAudioProcessing() // 停止录音 - recordfile!!.closeFile(true) + if (audioStream!!.recordfile != null) { + audioStream!!.recordfile!!.closeFile(true) + } // 释放recognizer recognizer?.close() recognizer = null @@ -538,40 +539,7 @@ class AzureAsrHelper(private val context: Context) { } - /** - * 禁用蓝牙音频功能,切换回正常音频模式 - */ - fun disableBluetoothAudio() { - try { - // 关闭蓝牙SCO(Synchronous Connection Oriented)音频路由 - audioManager?.isBluetoothScoOn = false - // 停止蓝牙SCO连接 - audioManager?.stopBluetoothSco() - // 切换回正常音频模式(非通话模式) - audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION - - } catch (e: Exception) { - Log.e("AudioConfig", "Failed to disable Bluetooth: ${e.message}") - } - } - - /** - * 恢复原始音频设备状态(通常是重新启用蓝牙) - */ - fun restoreOriginalAudioState() { - try { - // 恢复应用启动时的原始音频模式 - audioManager?.mode = AudioManager.MODE_NORMAL - - // 重新启用蓝牙SCO - audioManager?.isBluetoothScoOn = true - // 启动蓝牙SCO连接(通常在需要蓝牙通话时使用) - audioManager?.startBluetoothSco() - - } catch (e: Exception) { - Log.e("AudioConfig", "Failed to restore audio state: ${e.message}") - } - } + //录音音频文件 @@ -580,11 +548,24 @@ class AzureAsrHelper(private val context: Context) { */ fun enableRecord(filePath: String) { Log.i(tag, "开启录音:") + if (audioStream == null) { + // 创建外部音频拉流对象 + audioStream = SimpleAudioReceiver(context) + audioStream!!.initAudioRecord() + } + if (audioStream!!.recordfile == null) { + audioStream!!.recordfile = RecordFile() + } - recordfile!!.closeFile(true) + // 启动音频处理 + audioStream!!.startAudioRecord( when(audioSourceType) { + AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE + AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL + }) + audioStream!!.recordfile!!.closeFile(true) - recordfile!!.creatingFiles(filePath) + audioStream!!.recordfile!!.creatingFiles(filePath) } @@ -593,8 +574,8 @@ class AzureAsrHelper(private val context: Context) { */ fun moveFile(sourcePath: String, destPath: String): Boolean { Log.i(tag, "移动文件到新路径:") - - recordfile!!.moveFile(sourcePath, destPath) + if (audioStream!!.recordfile == null)return false + audioStream!!.recordfile!!.moveFile(sourcePath, destPath) return true } @@ -605,8 +586,8 @@ class AzureAsrHelper(private val context: Context) { fun renameFile(filePath: String, newName: String): Boolean { Log.i(tag, "重命名指定路径的音频文件:") - - recordfile!!.renameFile(filePath, newName) + if (audioStream!!.recordfile == null)return false + audioStream!!.recordfile!!.renameFile(filePath, newName) return true } @@ -615,308 +596,35 @@ class AzureAsrHelper(private val context: Context) { */ fun pauseRecord() { Log.i(tag, "停止连续录音:") + if (audioStream!!.recordfile == null)return + + audioStream!!.stopMicrophoneCapture() + - recordfile!!.isPause = true } + /** + * 继续录音 + */ + fun resumeRecord() { + Log.i(tag, "继续录音:") + if (audioStream!!.recordfile == null)return + + audioStream!!.resumeRecord() + } /** * 关闭录音 */ fun stopRecord(isSave: Boolean) { Log.i(tag, "关闭录音:") - - recordfile!!.isPause = false - recordfile!!.closeFile(isSave) + if (audioStream!!.recordfile == null)return + audioStream!!.stopMicrophoneCapture() + audioStream!!.recordfile!!.closeFile(isSave) } - /** - * 麦克风流 - 拉流模式 - * 实现PullAudioInputStreamCallback,为Azure SDK提供音频数据 - */ - inner class AudioStream { - private val bufferSize = 4096 // 可根据需要调整 - var audioRecord: AudioRecord? = null - var pushAudioStream: PushAudioInputStream? = null - - // 新增:用于异步写入的队列和线程 - private val writeQueue = LinkedBlockingQueue() - private val isRunning = AtomicBoolean(false)// 控制线程是否继续存在 - private val isWriting = AtomicBoolean(false) // 控制是否应该写入数据 - private var writeThread: Thread? = null - - - // 音频配置 - - private val channelConfig = AudioFormat.CHANNEL_IN_MONO - private val audioFormat = AudioFormat.ENCODING_PCM_16BIT - - /** - * 获取设备支持的最佳音频格式 - * 优先选择16000Hz,若不支持则降级到8000Hz - */ - private fun getOptimalAudioFormat(): AudioStreamFormat { - // 支持的采样率列表(按优先级排序) - val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100) - - // 查找设备支持的最佳采样率 - val sampleRate = supportedSampleRates.firstOrNull { rate -> - val bufferSize = AudioRecord.getMinBufferSize( - rate, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT - ) - bufferSize > 0 // 返回正值表示支持 - } ?: 16000 // 默认回退值 - - Log.i(tag, "使用采样率: ${sampleRate}Hz") - - // 创建对应的音频格式 - return AudioStreamFormat.getWaveFormatPCM(sampleRate.toLong(), 16, 1) - } - - /** - * 初始化 - */ - fun initAudioRecord() { - - val format = getOptimalAudioFormat() - pushAudioStream = AudioInputStream.createPushStream(format) - isRunning.set(true) - startWriteThread() // 再启动数据读取线程 - - - } -/** - * 开启音频写入线程 - */ - private fun startWriteThread() { - writeThread = Thread { - try { - - while (isRunning.get()) { - // 等待录音信号 - if (!isWriting.get()) { - Thread.sleep(10) // 短暂休眠避免空转 - continue - } - var data: ByteArray? = null - var bytesToWrite = 0 - // 情况1:正在录制中 -> 直接从AudioRecord读取 - if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { - data = ByteArray(bufferSize) - val bytesRead = audioRecord?.read(data, 0, bufferSize) ?: -1 - - when { - bytesRead < 0 -> { - Log.e("tag", "读取音频失败,错误码: $bytesRead") - continue - } - - bytesRead == 0 -> continue // 无数据可读 - else -> bytesToWrite = bytesRead // 有效数据 - } - } - // 情况2:不在录制但队列有数据 -> 从队列获取 - else if (writeQueue.isNotEmpty()) { - data = writeQueue.poll() - //Log.d("tag", "写入数据: ${data?.size}") - bytesToWrite = data?.size ?: 0 - } - - // 确保有有效数据再写入 - if (data != null && bytesToWrite > 0) { - // 处理实际读取长度 < bufferSize 的情况 - val finalData = - if (bytesToWrite < data.size) data.copyOf(bytesToWrite) else data - - try { - //continuousCallback?.onAudio(finalData) - pushAudioStream?.write(finalData) - recordfile?.saveAudioDataToWav(finalData) - } catch (e: Exception) { - Log.e("tag", "写入失败: ${e.message}") - } - } else { - Thread.yield() // 避免空转消耗CPU - } - } - } catch (e: Exception) { - Log.e("tag", "写入线程异常: ${e.stackTraceToString()}") - } finally { - Log.d("tag", "音频写入线程退出") - writeQueue.clear() - } - }.apply { - name = "AudioWriteThread" - start() - } - } - - - /** - * 外部音频输入 - */ - fun saveAudioDataTo(buffer: ByteArray) { - - if (audioSourceType == AudioSourceType.MICROPHONE||!isContinuousRecognitionActive) return - // 放入队列,由写线程写入 - writeQueue.offer(buffer.copyOf()) - - } - - /** - * 开始音频输入 - */ - fun startAudioRecord() { - - isWriting.set(true) - - when (audioSourceType) { - - AudioSourceType.MICROPHONE -> runMicrophoneCapture() - AudioSourceType.EXTERNAL -> runExternalCapture() - } - - } - - private fun runMicrophoneCapture() { - try { - // 首先设置通话音频模式 - setupCommunicationAudioMode() - - val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100) - val sampleRate = supportedSampleRates.firstOrNull { rate -> - val bufferSize = AudioRecord.getMinBufferSize(rate, channelConfig, audioFormat) - bufferSize > 0 - } ?: 16000 - - val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) - - // 使用VOICE_COMMUNICATION音频源(专为VoIP优化) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - val format = AudioFormat.Builder() - .setSampleRate(sampleRate) - .setEncoding(audioFormat) - .setChannelMask(AudioFormat.CHANNEL_IN_MONO) - .build() - - audioRecord = AudioRecord.Builder() - .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) - .setAudioFormat(format) - .setBufferSizeInBytes(minBufferSize * 2) - .build() - } else { - audioRecord = AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - sampleRate, - channelConfig, - audioFormat, - minBufferSize * 2 - ) - } - - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - throw IllegalStateException("AudioRecord初始化失败") - } - - audioRecord?.startRecording() - - Log.d("TAG", "通话模式录音开始,采样率: $sampleRate Hz") - - } catch (e: Exception) { - Log.e("TAG", "音频捕获异常: ${e.message}") - // 出错时恢复音频模式 - restoreCommunicationAudioMode() - } - } - - private fun runExternalCapture() { - Log.d("TAG", "外部音频捕获启动") - try { // TODO: 实现外部音频源捕获逻辑 - // 停止录音 - if (audioRecord?.recordingState == AudioRecord.STATE_INITIALIZED) { - Log.d("TAG", "外部音频捕获启动 释放audioRecord") - audioRecord?.stop() - // 释放录音实例 - audioRecord?.release() - audioRecord = null - } - } catch (e: Exception) { - Log.e("TAG", "外部音频捕获异常: ${e.message}") - } finally { - - } - } - - /** - * 停止麦克风捕获并释放所有相关资源 - */ - fun stopMicrophoneCapture() { - try { - if (!isWriting.get()) return - - - isWriting.set(false) - - // 停止录音 - if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { - audioRecord?.stop() - } - // 停止录音并释放AudioRecord资源 - // 释放录音实例 - audioRecord?.release() - audioRecord = null - - - } catch (e: Exception) { - Log.e("AudioConfig", "Error releasing resources: ${e.message}") - } finally { - writeQueue.clear() - // 确保恢复原始音频状态 - // restoreOriginalAudioState() - } - } - - - /** - * 释放音频资源 - */ - fun releaseAudioResources() { - try { - if (!isWriting.get()) return - - - isWriting.set(false) - isRunning.set(false) - // 停止录音 - if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { - audioRecord?.stop() - } - - // 中断并等待捕获线程结束 - writeThread?.interrupt() - writeThread?.join(300) // 最多等待300ms - - // 恢复音频模式 - this@AzureAsrHelper.audioManager?.mode = this@AzureAsrHelper.originalAudioMode - - // 释放录音实例 - audioRecord?.release() - audioRecord = null - } catch (e: Exception) { - Log.e(tag, "释放音频资源失败: ${e.message}") - e.printStackTrace() - } finally { - writeQueue.clear() - writeThread = null - // 确保恢复原始音频状态 - // restoreOriginalAudioState() - } - } - } - + /** * 一次性识别回调接口 */ @@ -990,55 +698,7 @@ class AzureAsrHelper(private val context: Context) { fun onError(error: String) } - /** - * 配置通话音频模式以优化回声消除 - */ - private fun setupCommunicationAudioMode() { - try { - audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - // 保存原始音频模式 - originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL - - // 设置通话模式 - 这是关键! - audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION - - // 启用扬声器(如果需要外放) - audioManager?.isSpeakerphoneOn = true - - // 请求音频焦点 - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .build() - - audioManager?.requestAudioFocus(focusRequest) - } - - Log.d("AudioMode", "通话音频模式已设置: MODE_IN_COMMUNICATION") - - } catch (e: Exception) { - Log.e("AudioMode", "设置通话音频模式失败: ${e.message}") - } - } - - /** - * 恢复原始音频模式 - */ - private fun restoreCommunicationAudioMode() { - try { - audioManager?.mode = originalAudioMode - audioManager?.isSpeakerphoneOn = false - // 释放音频焦点 - Log.d("AudioMode", "音频模式已恢复") - } catch (e: Exception) { - Log.e("AudioMode", "恢复音频模式失败: ${e.message}") - } - } + + } \ No newline at end of file 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 4263376e1..41eb3b938 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 @@ -372,12 +372,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { "disableBluetoothAudio" -> { - azureAsrHelper.disableBluetoothAudio(); + azureAsrHelper.audioStream?.disableBluetoothAudio(); return } "restoreOriginalAudioState" -> { - azureAsrHelper.restoreOriginalAudioState(); + azureAsrHelper.audioStream?.restoreOriginalAudioState(); return } @@ -487,6 +487,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { azureAsrHelper.pauseRecord() result.success(true) } + "resumeRecord" -> { + azureAsrHelper.resumeRecord() + result.success(true) + } "stopRecord" -> { val isSave = call.argument("isSave") ?: false @@ -502,7 +506,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { val sampleRate = call.argument("sampleRate") ?: 16000 val channels = call.argument("channels") ?: 1 try { - azureAsrHelper.setAudioConfig(sampleRate, channels) + azureAsrHelper.audioStream?.recordfile?.setAudioConfig(sampleRate, channels) result.success(true) } catch (e: Exception) { result.error("SET_AUDIO_CONFIG_ERROR", e.message, null) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt index d0b69ccb1..66a24e058 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt @@ -33,7 +33,7 @@ class RecordFile { // 用于存储最后成功保存的文件 private var lastSavedFile: File? = null private var lastFileTimestamp: String = "" - var isPause = false + var fileName = "" // 双声道分离相关变量 diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt similarity index 99% rename from local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt rename to local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt index 84f846967..61870debb 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/SimpleAudioPlayer.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt @@ -49,7 +49,7 @@ class SimpleAudioPlayer(private val context: Context? = null) { // 推送流 private var pushOutputStream: PushAudioOutputStream? = null - + // 自定义推送流回调 private inner class AudioOutputCallback : PushAudioOutputStreamCallback() { override fun write(dataBuffer: ByteArray): Int { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt new file mode 100644 index 000000000..75f76d53a --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt @@ -0,0 +1,403 @@ +package com.yunqiinnovation.azure_speech + +import android.content.Context +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import com.microsoft.cognitiveservices.speech.* +import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat +import java.util.concurrent.LinkedBlockingQueue +import com.microsoft.cognitiveservices.speech.audio.AudioInputStream +import com.microsoft.cognitiveservices.speech.audio.PushAudioInputStream +import java.util.concurrent.atomic.AtomicBoolean +import android.media.AudioManager +// 添加缺失的导入 +import android.media.AudioFocusRequest +import android.media.AudioAttributes +import com.yunqiinnovation.azure_speech.tools.RecordFile + +/** + * 简单音频接收器类,用于处理音频录制和流传输 + * @param azureAsrHelper AzureAsrHelper实例的引用,用于访问共享状态 + */ +class SimpleAudioReceiver(private val context: Context) { + + companion object { + private const val TAG = "SimpleAudioReceiver" + } + + /** + * 音频来源类型(使用 AzureAsrHelper 中定义的枚举) + */ + enum class AudioSourceType { + /** 使用设备麦克风 */ + MICROPHONE, + /** 使用外部提供的音频数据 */ + EXTERNAL + } + + private val bufferSize = 4096 // 可根据需要调整 + var audioRecord: AudioRecord? = null + var pushAudioStream: PushAudioInputStream? = null + // 音频源配置 + var audioSourceType = AudioSourceType.MICROPHONE + // 新增:用于异步写入的队列和线程 + private val writeQueue = LinkedBlockingQueue() + private val isRunning = AtomicBoolean(false)// 控制线程是否继续存在 + private val isWriting = AtomicBoolean(false) // 控制是否应该写入数据 + private var writeThread: Thread? = null + + // 音频配置 + private val channelConfig = AudioFormat.CHANNEL_IN_MONO + private val audioFormat = AudioFormat.ENCODING_PCM_16BIT + // 音频模式管理 + private var audioManager: AudioManager? = null + private var originalAudioMode = AudioManager.MODE_NORMAL + + // 录音文件处理 + var recordfile: RecordFile? = null + /** + * 获取最优音频格式 + */ + private fun getOptimalAudioFormat(): AudioStreamFormat { + // 支持的采样率列表(按优先级排序) + val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100) + + // 查找设备支持的最佳采样率 + val sampleRate = supportedSampleRates.firstOrNull { rate -> + val bufferSize = AudioRecord.getMinBufferSize( + rate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + bufferSize > 0 // 返回正值表示支持 + } ?: 16000 // 默认回退值 + + Log.i(TAG, "使用采样率: ${sampleRate}Hz") + + // 创建对应的音频格式 + return AudioStreamFormat.getWaveFormatPCM(sampleRate.toLong(), 16, 1) + } + + /** + * 初始化音频录制 + */ + fun initAudioRecord() { + val format = getOptimalAudioFormat() + pushAudioStream = AudioInputStream.createPushStream(format) + isRunning.set(true) + Log.d(TAG, "initAudioRecord: ${isRunning}") + startWriteThread() // 再启动数据读取线程 + // 初始化音频管理器 + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL + } + + /** + * 启动写入线程 + */ + private fun startWriteThread() { + + writeThread = Thread { + try { + while (isRunning.get()) { + // Log.d(TAG, "startWriteThread:isWriting= ${isWriting}") + if (!isWriting.get()) { + Thread.sleep(10) // 短暂休眠避免空转 + continue + } + var data: ByteArray? = null + var bytesToWrite = 0 + //Log.d(TAG, "startWriteThread: ${audioRecord?.recordingState}") + // 情况1:正在录制中 -> 直接从AudioRecord读取 + if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + data = ByteArray(bufferSize) + val bytesRead = audioRecord?.read(data, 0, bufferSize) ?: -1 + + when { + bytesRead < 0 -> { + Log.e("tag", "读取音频失败,错误码: $bytesRead") + continue + } + + bytesRead == 0 -> continue // 无数据可读 + else -> bytesToWrite = bytesRead // 有效数据 + } + } + // 情况2:不在录制但队列有数据 -> 从队列获取 + else if (writeQueue.isNotEmpty()) { + data = writeQueue.poll() + Log.d("tag", "写入数据: ${data?.size}") + bytesToWrite = data?.size ?: 0 + } + // 确保有有效数据再写入 + if (data != null && bytesToWrite > 0) { + // 处理实际读取长度 < bufferSize 的情况 + val finalData = + if (bytesToWrite < data.size) data.copyOf(bytesToWrite) else data + + try { + Log.d(TAG, "写入数据大小: ${finalData.size}") + pushAudioStream?.write(finalData) + + + recordfile?.saveAudioDataToWav(finalData) + + } catch (e: Exception) { + Log.e(TAG, "写入失败: ${e.message}") + } + } else { + Thread.yield() // 避免空转消耗CPU + } + + } + } catch (e: Exception) { + Log.e(TAG, "写入线程异常: ${e.stackTraceToString()}") + } finally { + Log.d(TAG, "音频写入线程退出") + writeQueue.clear() + } + }.apply { + name = "AudioWriteThread" + start() + } + } + + /** + * 外部音频输入 + */ + fun saveAudioDataTo(buffer: ByteArray) { + if (audioSourceType == AudioSourceType.MICROPHONE) return + // 放入队列,由写线程写入 + writeQueue.offer(buffer.copyOf()) + } + + /** + * 开始音频输入 + */ + fun startAudioRecord(audioSourceType: AudioSourceType) { + this.audioSourceType = audioSourceType + writeQueue.clear() + isWriting.set(true) + + when (audioSourceType) { + AudioSourceType.MICROPHONE -> runMicrophoneCapture() + AudioSourceType.EXTERNAL -> runExternalCapture() + } + } +/** + * 配置通话音频模式以优化回声消除 + */ + private fun setupCommunicationAudioMode() { + try { + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + // 保存原始音频模式 + originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL + + // 设置通话模式 - 这是关键! + audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION + + // 启用扬声器(如果需要外放) + audioManager?.isSpeakerphoneOn = true + + // 请求音频焦点 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) + .build() + + audioManager?.requestAudioFocus(focusRequest) + } + + Log.d("AudioMode", "通话音频模式已设置: MODE_IN_COMMUNICATION") + + } catch (e: Exception) { + Log.e("AudioMode", "设置通话音频模式失败: ${e.message}") + } + } + + /** + * 运行麦克风捕获 + */ + private fun runMicrophoneCapture() { + try { + // 首先设置通话音频模式 + setupCommunicationAudioMode() + + val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100) + val sampleRate = supportedSampleRates.firstOrNull { rate -> + val bufferSize = AudioRecord.getMinBufferSize(rate, channelConfig, audioFormat) + bufferSize > 0 + } ?: 16000 + + val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) + + // 使用VOICE_COMMUNICATION音频源(专为VoIP优化) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + val format = AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(audioFormat) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .build() + + audioRecord = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) + .setAudioFormat(format) + .setBufferSizeInBytes(minBufferSize * 2) + .build() + } else { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + sampleRate, + channelConfig, + audioFormat, + minBufferSize * 2 + ) + } + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + throw IllegalStateException("AudioRecord初始化失败") + } + + audioRecord?.startRecording() + + Log.d("TAG", "通话模式录音开始,采样率: $sampleRate Hz") + + } catch (e: Exception) { + Log.e("TAG", "麦克风捕获失败: ${e.message}") + } + } + + /** + * 运行外部捕获 + */ + private fun runExternalCapture() { + Log.d("TAG", "外部音频捕获启动") + try { // TODO: 实现外部音频源捕获逻辑 + // 停止录音 + if (audioRecord?.recordingState == AudioRecord.STATE_INITIALIZED) { + Log.d("TAG", "外部音频捕获启动 释放audioRecord") + audioRecord?.stop() + // 释放录音实例 + audioRecord?.release() + audioRecord = null + } + } catch (e: Exception) { + Log.e("TAG", "外部音频捕获异常: ${e.message}") + } finally { + + } + } +/** + * 继续麦克风捕获 + */ + fun resumeRecord() { + try { + isWriting.set(true) + // 首先设置通话音频模式 + setupCommunicationAudioMode() + // 停止录音 + audioRecord?.startRecording() + + + } catch (e: Exception) { + Log.e(TAG, "继续录音失败: ${e.message}") + } + } + + /** + * 停止麦克风捕获 + */ + fun stopMicrophoneCapture() { + try { + isWriting.set(false) + + // 停止录音 + audioRecord?.stop() + + // 恢复音频模式 + restoreCommunicationAudioMode() + + } catch (e: Exception) { + Log.e(TAG, "停止麦克风捕获失败: ${e.message}") + } + } + + /** + * 释放音频资源 + */ + fun releaseAudioResources() { + try { + isRunning.set(false) + isWriting.set(false) + + // 中断并等待捕获线程结束 + writeThread?.interrupt() + writeThread?.join(300) // 最多等待300ms + + // 释放录音实例 + audioRecord?.release() + audioRecord = null + } catch (e: Exception) { + Log.e(TAG, "释放音频资源失败: ${e.message}") + e.printStackTrace() + } finally { + writeQueue.clear() + writeThread = null + } + } + /** + * 禁用蓝牙音频功能,切换回正常音频模式 + */ + fun disableBluetoothAudio() { + try { + // 关闭蓝牙SCO(Synchronous Connection Oriented)音频路由 + audioManager?.isBluetoothScoOn = false + // 停止蓝牙SCO连接 + audioManager?.stopBluetoothSco() + // 切换回正常音频模式(非通话模式) + audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION + + } catch (e: Exception) { + Log.e("AudioConfig", "Failed to disable Bluetooth: ${e.message}") + } + } + + /** + * 恢复原始音频设备状态(通常是重新启用蓝牙) + */ + fun restoreOriginalAudioState() { + try { + // 恢复应用启动时的原始音频模式 + audioManager?.mode = AudioManager.MODE_NORMAL + + // 重新启用蓝牙SCO + audioManager?.isBluetoothScoOn = true + // 启动蓝牙SCO连接(通常在需要蓝牙通话时使用) + audioManager?.startBluetoothSco() + + } catch (e: Exception) { + Log.e("AudioConfig", "Failed to restore audio state: ${e.message}") + } + } + /** + * 恢复原始音频模式 + */ + private fun restoreCommunicationAudioMode() { + try { + audioManager?.mode = originalAudioMode + audioManager?.isSpeakerphoneOn = false + // 释放音频焦点 + Log.d("AudioMode", "音频模式已恢复") + } catch (e: Exception) { + Log.e("AudioMode", "恢复音频模式失败: ${e.message}") + } + } +} 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 8167155ad..4d18c9c71 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 @@ -262,7 +262,7 @@ import os.log return } do { - print("音频文件名称为: (filePath)") + print("音频文件名称为: \(filePath)") try azureAsrHelper.enableRecord(filePath: filePath) result(true) } catch { @@ -272,6 +272,9 @@ import os.log case "pauseRecord": azureAsrHelper.pauseRecord() result(true) + case "resumeRecord": + azureAsrHelper.resumeRecord() + result(true) case "stopRecord": guard let args = call.arguments as? [String: Any], From 4ab2da85cb9327ec3fb1f3a587f5034a215074a2 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 11 Aug 2025 10:34:15 +0800 Subject: [PATCH 15/23] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat_api/SystemFunctionHandler.kt | 1 + .../chat_api/SystemFunctionHandler.swift | 2 + .../location_service/LocationService.swift | 273 +++++++++--------- pubspec.yaml | 2 +- 4 files changed, 145 insertions(+), 133 deletions(-) diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt index 4ecb86ffe..2af198233 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt @@ -1181,6 +1181,7 @@ private class GetCurrentLocationHandler(private val context: Context?) : Functio put("latitude", cachedLocation.latitude) put("longitude", cachedLocation.longitude) put("address", cachedLocation.address) + put("location", "${cachedLocation.latitude},${cachedLocation.longitude}") put("source", "cache") put("timestamp", cachedLocation.timestamp) put("cacheAgeMinutes", cacheAgeMinutes) diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift index c6a35e433..b0c19e4a6 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift @@ -461,6 +461,7 @@ private class GetCurrentLocationHandler: FunctionHandler { // 解析位置数据 guard let latStr = locationInfo["user_lat"] as? String, let lngStr = locationInfo["user_lng"] as? String, + let latitude = Double(latStr), let longitude = Double(lngStr) else { print("[GetCurrentLocationHandler] 位置数据格式错误") @@ -491,6 +492,7 @@ private class GetCurrentLocationHandler: FunctionHandler { "data": [ "latitude": latitude, "longitude": longitude, + "location": "\(latitude),\(longitude)", "timestamp": Date().timeIntervalSince1970 * 1000 ] ] diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift index 906789434..53c23686d 100644 --- a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift +++ b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift @@ -23,37 +23,33 @@ public class LocationService: NSObject { private var authorizationStatus: CLAuthorizationStatus = .notDetermined private var isLocationServicesEnabled: Bool = false + // 高精度定位属性 + private var locationAttempts: Int = 0 + private let maxLocationAttempts: Int = 3 + private let requiredAccuracy: CLLocationAccuracy = 10.0 // 要求精度在10米以内 + private var bestLocation: CLLocation? + private var locationTimer: Timer? + + // MARK: - 权限回调 + private var permissionCallback: ((Result) -> Void)? + private override init() { super.init() setupLocationManager() } + private func setupLocationManager() { DispatchQueue.main.async { [weak self] in self?.locationManager = CLLocationManager() self?.locationManager?.delegate = self - self?.locationManager?.desiredAccuracy = kCLLocationAccuracyBest - self?.locationManager?.distanceFilter = 10 - // 不需要手动获取状态,delegate 方法会自动被调用 + // 高精度定位设置 + self?.locationManager?.desiredAccuracy = kCLLocationAccuracyBestForNavigation // 最高精度 + self?.locationManager?.distanceFilter = 1 // 降低距离过滤器,更敏感 + print("[LocationService] LocationManager 初始化完成,等待权限状态回调") } } - // private func setupLocationManager() { - // DispatchQueue.main.async { [weak self] in - // self?.locationManager = CLLocationManager() - // self?.locationManager?.delegate = self - // self?.locationManager?.desiredAccuracy = kCLLocationAccuracyBest - // self?.locationManager?.distanceFilter = 10 - - // // 初始化时获取当前状态 - // self?.authorizationStatus = CLLocationManager.authorizationStatus() - // self?.isLocationServicesEnabled = CLLocationManager.locationServicesEnabled() - - // print("[LocationService] LocationManager 初始化完成") - // print("[LocationService] 初始权限状态: \(self?.authorizationStatus.rawValue ?? -1)") - // print("[LocationService] 位置服务启用: \(self?.isLocationServicesEnabled ?? false)") - // } - // } /// 获取当前位置 - 主要接口 public func getCurrentLocation(completion: @escaping (Result<[String: Any], Error>) -> Void) { @@ -98,20 +94,14 @@ public class LocationService: NSObject { /// 异步更新位置缓存 private func asyncUpdateLocation() { // 检查权限 - guard checkLocationPermission() else { - print("[LocationService] 异步更新失败:无位置权限") - return - } - - // 异步请求位置更新 - DispatchQueue.global(qos: .background).async { [weak self] in - self?.requestLocationUpdate { result in + if checkLocationPermission() { + requestHighAccuracyLocation { [weak self] result in switch result { case .success(let locationData): - print("[LocationService] 异步更新位置缓存成功") self?.cachedLocationData = locationData + print("[LocationService] 异步更新缓存成功") case .failure(let error): - print("[LocationService] 异步更新位置缓存失败: \(error.localizedDescription)") + print("[LocationService] 异步更新缓存失败: \(error.localizedDescription)") } } } @@ -119,14 +109,22 @@ public class LocationService: NSObject { /// 同步获取位置 private func syncGetLocation(completion: @escaping (Result<[String: Any], Error>) -> Void) { - // 检查权限 - guard checkLocationPermission() else { - print("[LocationService] 无位置权限,尝试请求权限") + if checkLocationPermission() { + requestHighAccuracyLocation { [weak self] result in + switch result { + case .success(let locationData): + self?.cachedLocationData = locationData + let locationInfo = self?.formatLocationData(locationData) ?? [:] + completion(.success(locationInfo)) + case .failure(let error): + completion(.failure(error)) + } + } + } else { requestLocationPermission { [weak self] result in switch result { case .success(let granted): if granted { - print("[LocationService] 权限获取成功,重新获取位置") self?.syncGetLocation(completion: completion) } else { completion(.failure(NSError(domain: "LocationService", code: -1, userInfo: [NSLocalizedDescriptionKey: "位置权限被拒绝"]))) @@ -135,116 +133,76 @@ public class LocationService: NSObject { completion(.failure(error)) } } - return - } - - // 有权限,直接获取位置 - requestLocationUpdate { [weak self] result in - switch result { - case .success(let locationData): - print("[LocationService] 同步获取位置成功") - self?.cachedLocationData = locationData - let locationInfo = self?.formatLocationData(locationData) ?? [:] - completion(.success(locationInfo)) - case .failure(let error): - print("[LocationService] 同步获取位置失败: \(error.localizedDescription)") - completion(.failure(error)) - } } } - /// 检查位置权限 - 使用缓存的状态 + /// 检查位置权限 private func checkLocationPermission() -> Bool { return authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways } - /// 请求位置权限 - 异步处理 + /// 请求位置权限 private func requestLocationPermission(completion: @escaping (Result) -> Void) { - print("[LocationService] 当前缓存的权限状态: \(authorizationStatus.rawValue)") - - switch authorizationStatus { - case .notDetermined: - print("[LocationService] 请求位置权限") - // 保存回调,等待权限结果 - permissionCallback = completion - DispatchQueue.main.async { [weak self] in - self?.locationManager?.requestWhenInUseAuthorization() + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + guard let locationManager = self.locationManager else { + completion(.failure(NSError(domain: "LocationService", code: -3, userInfo: [NSLocalizedDescriptionKey: "LocationManager 未初始化"]))) + return + } + + // 设置权限回调 + self.permissionCallback = completion + + // 请求权限 + switch self.authorizationStatus { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + case .denied, .restricted: + completion(.success(false)) + case .authorizedWhenInUse, .authorizedAlways: + completion(.success(true)) + @unknown default: + completion(.failure(NSError(domain: "LocationService", code: -2, userInfo: [NSLocalizedDescriptionKey: "未知权限状态"]))) } - case .denied, .restricted: - print("[LocationService] 位置权限被拒绝或受限") - completion(.success(false)) - case .authorizedWhenInUse, .authorizedAlways: - print("[LocationService] 已有位置权限") - completion(.success(true)) - @unknown default: - completion(.failure(NSError(domain: "LocationService", code: -2, userInfo: [NSLocalizedDescriptionKey: "未知权限状态"]))) } } - /// 请求位置更新 - 优化版本 - private func requestLocationUpdate(completion: @escaping (Result) -> Void) { + /// 高精度定位方法 + private func requestHighAccuracyLocation(completion: @escaping (Result) -> Void) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } guard let locationManager = self.locationManager else { - print("[LocationService] 错误:LocationManager 未初始化") completion(.failure(NSError(domain: "LocationService", code: -3, userInfo: [NSLocalizedDescriptionKey: "LocationManager 未初始化"]))) return } // 检查是否已有请求在进行 if self.currentLocationCallback != nil { - print("[LocationService] 错误:位置请求正在进行中,拒绝新请求") completion(.failure(NSError(domain: "LocationService", code: -4, userInfo: [NSLocalizedDescriptionKey: "位置请求正在进行中"]))) return } - // 使用缓存的状态信息 - // print("[LocationService] 开始位置请求") - // print("[LocationService] 缓存的权限状态: \(self.authorizationStatus.rawValue)") - // print("[LocationService] 缓存的位置服务状态: \(self.isLocationServicesEnabled)") - // print("[LocationService] 当前线程: \(Thread.current)") - // print("[LocationService] 是否主线程: \(Thread.isMainThread)") - - // 验证权限状态 + // 检查权限 if !self.checkLocationPermission() { - print("[LocationService] 错误:无位置权限") completion(.failure(NSError(domain: "LocationService", code: -5, userInfo: [NSLocalizedDescriptionKey: "无位置权限"]))) return } - if !self.isLocationServicesEnabled { - print("[LocationService] 错误:位置服务未启用") - completion(.failure(NSError(domain: "LocationService", code: -6, userInfo: [NSLocalizedDescriptionKey: "位置服务未启用"]))) - return - } - - // 强制重新设置 delegate - if locationManager.delegate == nil { - print("[LocationService] 警告:Delegate 为空,重新设置") - locationManager.delegate = self - } - - // 验证 delegate - if locationManager.delegate === self { - print("[LocationService] Delegate 验证成功") - } else { - print("[LocationService] 错误:Delegate 设置失败") - completion(.failure(NSError(domain: "LocationService", code: -8, userInfo: [NSLocalizedDescriptionKey: "Delegate 设置失败"]))) - return - } + // 重置状态 + self.locationAttempts = 0 + self.bestLocation = nil // 设置回调 self.currentLocationCallback = { result in switch result { case .success(let locationInfo): - print("[LocationService] 位置回调成功,开始解析数据") if let latStr = locationInfo["user_lat"] as? String, let lngStr = locationInfo["user_lng"] as? String, let latitude = Double(latStr), let longitude = Double(lngStr) { - print("[LocationService] 位置数据解析成功: lat=\(latitude), lng=\(longitude)") let locationData = LocationData( latitude: latitude, longitude: longitude, @@ -252,31 +210,61 @@ public class LocationService: NSObject { ) completion(.success(locationData)) } else { - print("[LocationService] 错误:位置数据格式错误,原始数据: \(locationInfo)") completion(.failure(NSError(domain: "LocationService", code: -7, userInfo: [NSLocalizedDescriptionKey: "位置数据格式错误"]))) } case .failure(let error): - print("[LocationService] 位置回调失败: \(error.localizedDescription)") completion(.failure(error)) } } - print("[LocationService] 调用 locationManager.requestLocation()") - locationManager.requestLocation() - print("[LocationService] requestLocation() 调用完成,等待回调") + print("[LocationService] 开始高精度连续定位") + // 开始连续定位 + locationManager.startUpdatingLocation() - // 30秒超时机制 - DispatchQueue.main.asyncAfter(deadline: .now() + 30) { - if self.currentLocationCallback != nil { - print("[LocationService] 位置请求超时") + // 设置超时定时器(15秒) + self.locationTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { _ in + self.stopLocationUpdates() + if let bestLocation = self.bestLocation { + self.processLocation(bestLocation) + } else { let callback = self.currentLocationCallback self.currentLocationCallback = nil - callback?(.failure(NSError(domain: "LocationService", code: -9, userInfo: [NSLocalizedDescriptionKey: "位置请求超时"]))) + callback?(.failure(NSError(domain: "LocationService", code: -9, userInfo: [NSLocalizedDescriptionKey: "无法获取满足精度要求的位置"]))) } } } } + /// 停止位置更新 + private func stopLocationUpdates() { + locationManager?.stopUpdatingLocation() + locationTimer?.invalidate() + locationTimer = nil + } + + /// 处理位置数据 + private func processLocation(_ location: CLLocation) { + let locationInfo: [String: Any] = [ + "user_lat": String(location.coordinate.latitude), + "user_lng": String(location.coordinate.longitude), + "timestamp": Date().timeIntervalSince1970 * 1000, + "accuracy": location.horizontalAccuracy // 添加精度信息 + ] + + print("[LocationService] 处理最终位置,精度: \(location.horizontalAccuracy)m") + + // 更新缓存 + cachedLocationData = LocationData( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + timestamp: Date().timeIntervalSince1970 * 1000 + ) + + let callback = currentLocationCallback + currentLocationCallback = nil + callback?(.success(locationInfo)) + } + /// 格式化位置数据 private func formatLocationData(_ locationData: LocationData) -> [String: Any] { return [ @@ -285,14 +273,11 @@ public class LocationService: NSObject { "timestamp": locationData.timestamp ] } - - // MARK: - 权限回调 - private var permissionCallback: ((Result) -> Void)? } // MARK: - CLLocationManagerDelegate extension LocationService: CLLocationManagerDelegate { - // 替换 didUpdateLocations 方法中的逆地理编码部分 + public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { print("[LocationService] didUpdateLocations 被调用,位置数量: \(locations.count)") @@ -301,22 +286,47 @@ extension LocationService: CLLocationManagerDelegate { return } - print("[LocationService] 位置更新成功: lat=\(location.coordinate.latitude), lng=\(location.coordinate.longitude)") - print("[LocationService] 位置精度: \(location.horizontalAccuracy)m") + print("[LocationService] 位置更新: lat=\(location.coordinate.latitude), lng=\(location.coordinate.longitude)") + print("[LocationService] 水平精度: \(location.horizontalAccuracy)m, 垂直精度: \(location.verticalAccuracy)m") + print("[LocationService] 位置时间: \(location.timestamp)") - // 直接使用经纬度,不进行逆地理编码 - let locationInfo: [String: Any] = [ - "user_lat": String(location.coordinate.latitude), - "user_lng": String(location.coordinate.longitude), - "timestamp": Date().timeIntervalSince1970 * 1000 - ] + // 过滤无效位置 + guard location.horizontalAccuracy >= 0 else { + print("[LocationService] 位置精度无效,忽略此次更新") + return + } - print("[LocationService] 准备调用位置回调") - // 调用回调 - let callback = currentLocationCallback - currentLocationCallback = nil - callback?(.success(locationInfo)) - print("[LocationService] 位置回调已调用") + // 过滤过旧的位置(超过5秒) + let locationAge = -location.timestamp.timeIntervalSinceNow + guard locationAge < 5.0 else { + print("[LocationService] 位置数据过旧(\(locationAge)秒),忽略") + return + } + + locationAttempts += 1 + + // 检查是否满足精度要求 + if location.horizontalAccuracy <= requiredAccuracy { + print("[LocationService] 获得高精度位置,精度: \(location.horizontalAccuracy)m") + stopLocationUpdates() + processLocation(location) + return + } + + // 保存最佳位置 + if bestLocation == nil || location.horizontalAccuracy < bestLocation!.horizontalAccuracy { + bestLocation = location + print("[LocationService] 更新最佳位置,精度: \(location.horizontalAccuracy)m") + } + + // 达到最大尝试次数,使用最佳位置 + if locationAttempts >= maxLocationAttempts { + print("[LocationService] 达到最大尝试次数,使用最佳位置") + stopLocationUpdates() + if let bestLocation = bestLocation { + processLocation(bestLocation) + } + } } public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { @@ -325,11 +335,10 @@ extension LocationService: CLLocationManagerDelegate { print("[LocationService] 错误代码: \((error as NSError).code)") print("[LocationService] 错误域: \((error as NSError).domain)") + stopLocationUpdates() let callback = currentLocationCallback currentLocationCallback = nil - print("[LocationService] 准备调用错误回调") callback?(.failure(error)) - print("[LocationService] 错误回调已调用") } public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { diff --git a/pubspec.yaml b/pubspec.yaml index 17c0f91a2..615a2346a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: voitrans description: "Voitrans - AI Voice Assistant." publish_to: "none" -version: 1.0.6+20 +version: 1.0.7+20 environment: sdk: ">=3.3.0 <4.0.0" From a42b751ed4a5d1ee0622bedc65ef1bd5b5b9cdff Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Mon, 11 Aug 2025 20:05:23 +0800 Subject: [PATCH 16/23] =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=8A=9B=E5=9B=9Eflutter=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/asr_service.dart | 8 +- .../speech_impl/azure_asr_service.dart | 72 ++++++++++- .../speech_impl/volcano_asr_api_service.dart | 8 +- .../speech_impl/volcano_asr_service.dart | 8 +- .../speech_impl/xunfei_asr_service.dart | 8 +- .../meeting_record_controller.dart | 17 ++- .../controllers/translation_controller.dart | 118 +++++++++++++----- .../translation/views/translation_view.dart | 17 ++- .../azure_speech/AzureAsrHelper.kt | 68 +++++----- .../azure_speech/AzureAsrToAsr.kt | 1 + .../azure_speech/AzureSpeechPlugin.kt | 59 ++++++++- .../azure_speech/AzureTtsHelper.kt | 2 +- .../azure_speech/tools/SimpleAudioPlayer.kt | 2 +- .../azure_speech/tools/SimpleAudioReceiver.kt | 31 ++++- 14 files changed, 338 insertions(+), 81 deletions(-) diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 1fba23f52..827194a1a 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -41,7 +41,13 @@ abstract class AsrService { bool isContinuousRecognitionActive(); /// 开始录音 - Future enableRecord(String filePath); + /// + /// [filePath] 录音文件路径 + /// [acceptAudioData] 是否接受音频数据回调,默认为 false + Future enableRecord(String filePath, {bool acceptAudioData = false}); + + /// 获取音频数据流(如果支持) + Stream? getAudioDataStream() => null; /// 暂停录音 Future pauseRecord(); diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 0009cd758..7c34a204d 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -21,6 +21,10 @@ class AzureAsrService extends GetxService implements AsrService { static const MethodChannel _channel = MethodChannel('azure_speech/asr'); static const EventChannel _eventChannel = EventChannel('azure_speech/asr_events'); + // 音频数据事件通道 + static const EventChannel _audioDataEventChannel = + EventChannel('azure_speech/audio_data_events'); + // final GetStorage _storage = GetStorage(); bool _isInitialized = false; late final String _subscriptionKey; @@ -43,6 +47,11 @@ class AzureAsrService extends GetxService implements AsrService { String _latestDetectedLanguage = ''; String get latestDetectedLanguage => _latestDetectedLanguage; +// 音频数据事件订阅 + StreamSubscription? _audioDataEventSubscription; + // 音频数据流控制器 + StreamController? _audioDataStreamController; + // 当前音频源类型 AudioSourceType _audioSourceType = AudioSourceType.microphone; @@ -72,6 +81,19 @@ class AzureAsrService extends GetxService implements AsrService { }, onError: _handleRecognitionError); } + /// 设置音频数据事件通道 + void _setupAudioDataEventChannel() { + _audioDataEventSubscription?.cancel(); + _audioDataEventSubscription = + _audioDataEventChannel.receiveBroadcastStream().listen((event) { + if (event is Map) { + _handleAudioDataEvent(event); + } + }, onError: (error) { + Logger.error('音频数据事件流错误: ${error.toString()}'); + }); + } + @override Future initialize({ List? supportedLanguages, @@ -220,6 +242,41 @@ class AzureAsrService extends GetxService implements AsrService { return _isContinuousRecognitionActive; } + /// 处理音频数据事件 + void _handleAudioDataEvent(dynamic event) { + if (event is! Map) return; + + final Map eventMap = event; + final String eventType = eventMap['type'] as String? ?? ''; + + switch (eventType) { + case 'audioData': + final Uint8List data = eventMap['data'] as Uint8List? ?? Uint8List(0); + final double timestamp = + (eventMap['timestamp'] as num?)?.toDouble() ?? 0.0; + final int size = eventMap['size'] as int? ?? 0; + print('接收到音频数据: 大小=${size}字节, 时间戳=${timestamp}'); + Logger.debug('接收到音频数据: 大小=${size}字节, 时间戳=${timestamp}'); + + // 发送到音频数据流 + _audioDataStreamController?.add(data); + + // 也可以通过识别事件流发送 + _eventStreamController?.add(RecognitionEvent( + type: RecognitionEventType.onAudio, + audio: data, + )); + break; + } + } + + /// 获取音频数据流 + Stream? getAudioDataStream() { + print("getAudioDataStream"); + _audioDataStreamController ??= StreamController.broadcast(); + return _audioDataStreamController?.stream; + } + /// 处理来自原生端的识别事件 void _handleRecognitionEvent(dynamic event) { if (event is! Map || _eventStreamController == null) return; @@ -336,6 +393,14 @@ class AzureAsrService extends GetxService implements AsrService { await _eventSubscription?.cancel(); _eventSubscription = null; + // 取消音频数据事件订阅 + await _audioDataEventSubscription?.cancel(); + _audioDataEventSubscription = null; + + // 关闭音频数据流 + await _audioDataStreamController?.close(); + _audioDataStreamController = null; + // 通知原生端释放资源 await _channel.invokeMethod('dispose'); _isInitialized = false; @@ -376,11 +441,16 @@ class AzureAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String filePath) async { + Future enableRecord(String filePath, + {bool acceptAudioData = false}) async { try { final bool result = await _channel.invokeMethod('enableRecord', { 'filePath': filePath, + 'acceptAudioData': acceptAudioData, // 新增参数 }); + if (acceptAudioData) { + _setupAudioDataEventChannel(); + } return result; } catch (e) { diff --git a/lib/data/services/speech_impl/volcano_asr_api_service.dart b/lib/data/services/speech_impl/volcano_asr_api_service.dart index 126aea878..87e0863d8 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -813,7 +813,7 @@ class VolcanoAsrApiService implements AsrService { } @override - Future enableRecord(String filePath) { + Future enableRecord(String filePath, {bool acceptAudioData = false}) { // TODO: implement enableRecord throw UnimplementedError(); } @@ -877,4 +877,10 @@ class VolcanoAsrApiService implements AsrService { // TODO: implement resumeRecord throw UnimplementedError(); } + + @override + Stream? getAudioDataStream() { + // TODO: implement getAudioDataStream + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart index 95d9c1afc..6d090d642 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -348,7 +348,7 @@ class VolcanoAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String filePath) { + Future enableRecord(String filePath, {bool acceptAudioData = false}) { // TODO: implement enableRecord throw UnimplementedError(); } @@ -412,4 +412,10 @@ class VolcanoAsrService extends GetxService implements AsrService { // TODO: implement resumeRecord throw UnimplementedError(); } + + @override + Stream? getAudioDataStream() { + // TODO: implement getAudioDataStream + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index ea8a52251..29daf7015 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -272,7 +272,7 @@ class XunfeiAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String filePath) { + Future enableRecord(String filePath, {bool acceptAudioData = false}) { // TODO: implement enableRecord throw UnimplementedError(); } @@ -336,4 +336,10 @@ class XunfeiAsrService extends GetxService implements AsrService { // TODO: implement resumeRecord throw UnimplementedError(); } + + @override + Stream? getAudioDataStream() { + // TODO: implement getAudioDataStream + throw UnimplementedError(); + } } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 71ce8796e..eb177d910 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -60,6 +60,7 @@ class MeetingRecordController extends GetxController bool _audioSourceType = false; late TabController tabController; + StreamSubscription? _audioDataSubscription; // 添加波形放大倍数变量 final double waveAmplifyFactor = 100.0; // 增加波动幅度 final RxDouble ursorPosition = 0.0.obs; // 当前播放位置 @@ -255,7 +256,21 @@ class MeetingRecordController extends GetxController final fullFileName = "${fileName.value}_$formattedTime"; // Start audio recording - await _asrService.enableRecord("${dir.path}/$fullFileName.wav"); + await _asrService.enableRecord("${dir.path}/$fullFileName.wav", + acceptAudioData: true); + + // 监听音频数据流 + _audioDataSubscription = + _asrService.getAudioDataStream()?.listen((audioData) { + // 处理音频数据,例如: + // 1. 实时音频可视化 + // 2. 音频质量检测 + // 3. 发送到其他服务 + print('接收到音频数据: ${audioData.length} 字节'); + + // 处理音频数据的逻辑 + _processAudioData(audioData); + }); isRecording.value = true; // Update state diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index f4c81e9fd..52a2ad2c2 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -65,6 +65,8 @@ class TranslationController extends GetxController { Timer? _recordTimer; //秒 int _seconds = 0; + //暂停状态记录计时器是否暂停 + bool _isTimerPaused = false; // 存储相关配置 static const String _historyKey = 'translation_history'; static const String _sourceLanguageKey = 'translation_source_language'; @@ -92,6 +94,8 @@ class TranslationController extends GetxController { final isTranslating = false.obs; final isTtsEnabled = true.obs; final isRecording = false.obs; + final lasyIsRecording = false.obs; + final hasRecordPermission = false.obs; // 语言相关 final sourceLanguage = '中文(简体)'.obs; final targetLanguage = '英语'.obs; @@ -119,7 +123,6 @@ class TranslationController extends GetxController { Future changeTranslationMode(String mode) async { currentMode.value = mode; currentModeTitle.value = _getModeTitle(mode); - stopAll(); } @@ -137,12 +140,57 @@ class TranslationController extends GetxController { } } + /// 暂停计时器 + void pauseRecordTimer() { + if (_recordTimer != null && !_isTimerPaused) { + _recordTimer?.cancel(); + _recordTimer = null; + _isTimerPaused = true; + Logger.info('录音计时器已暂停'); + } + } + + /// 恢复计时器 + void resumeRecordTimer() { + if (_isTimerPaused) { + _recordTimer = Timer.periodic(Duration(seconds: 1), (_) { + _seconds++; + final minutes = (_seconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (_seconds % 60).toString().padLeft(2, '0'); + recordDurationText.value = '$minutes:$seconds'; + }); + _isTimerPaused = false; + Logger.info('录音计时器已恢复'); + } + } + + /// 重置计时器 + void resetRecordTimer() { + _recordTimer?.cancel(); + _recordTimer = null; + _seconds = 0; + _isTimerPaused = false; + recordDurationText.value = '00:00'; + Logger.info('录音计时器已重置'); + } + Future setActiveSpeaker(int id) async { activeSpeaker.value = id; + await _asrService.startContinuousRecognition(_audioSourceType); // 监听识别结果 if (activeSpeaker.value != 0) { isPlayback = false; - await _asrService.startContinuousRecognition(_audioSourceType); + isRecognizing.value = true; + if (isRecording.value && isRecognizing.value) { + if (lasyIsRecording.value != isRecording.value) { + lasyIsRecording.value = isRecording.value; + await startRecording(); + } else { + await _asrService.resumeRecord(); + // 恢复计时器 + resumeRecordTimer(); + } + } // 开始ASR活跃时长跟踪(面对面模式) _startAsrActiveTracking(); @@ -151,7 +199,11 @@ class TranslationController extends GetxController { } else { // 停止ASR活跃时长跟踪(面对面模式) _stopAsrActiveTracking(); - + if (isRecording.value && isRecognizing.value) { + // 暂停计时器 + pauseRecordTimer(); + await _asrService.pauseRecord(); + } if (fTFTranslationResult != '' && !isPlayback) { isPlayback = true; await _ttsService.setAudioOutputDevice(lastActiveSpeaker.value); @@ -198,6 +250,9 @@ class TranslationController extends GetxController { super.onInit(); //_setupEventListeners(); + // 预先检查权限,避免在用户交互时阻塞 + _preCheckPermissions(); + // 初始化统计服务 _initUsageService(); @@ -228,6 +283,15 @@ class TranslationController extends GetxController { // 参考Agent模块的实现模式 } + /// 预先检查权限 + Future _preCheckPermissions() async { + try { + await requestRecordPermission(); + } catch (e) { + Logger.e("Permission", "预检查权限失败: $e"); + } + } + // 初始化统计服务 void _initUsageService() { try { @@ -264,16 +328,17 @@ class TranslationController extends GetxController { _storage.write(_targetLanguageKey, targetLanguage.value); } - Future _requestRecordPermission() async { + Future requestRecordPermission() async { try { - final bool granted = await PermissionUtil.instance.requestPermission( + hasRecordPermission.value = + await PermissionUtil.instance.requestPermission( permissionType: Permission.microphone, permissionName: 'microphonePermission'.tr, explanationText: 'microphonePermissionExplanation'.tr, permanentDenialText: 'microphonePermissionPermanentDenial'.tr, ); - if (granted) { + if (hasRecordPermission.value) { Logger.d("Permission", "用户授予了录音权限"); return true; } else { @@ -539,11 +604,6 @@ class TranslationController extends GetxController { } Future startRecording() async { - final bool storage = await _requestStoragePermission(); - if (!storage) { - Logger.d("Permission", "未授予存储权限,无法提供录音功能"); - return; - } //isRecording.value = true; _seconds = 0; recordDurationText.value = '00:00'; @@ -559,25 +619,29 @@ class TranslationController extends GetxController { final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); await _asrService.enableRecord( "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav"); - await _astService.enableRecord( - "${dir.path}/${currentModeTitle.value.tr}_${formattedTime}_mic.wav"); + // await _astService.enableRecord( + // "${dir.path}/${currentModeTitle.value.tr}_${formattedTime}_mic.wav"); + return; } Future stopRecording() async { _recordTimer?.cancel(); _recordTimer = null; isRecording.value = false; + _isTimerPaused = false; recordDurationText.value = '00:00'; - await _asrService.stopRecord(true); - // 停止实际录音逻辑 - // ... + _seconds = 0; + + _asrService.stopRecord(true); + lasyIsRecording.value = false; + Logger.info('录音已停止,计时器已重置'); } // 开始语音识别 Future startRecognition() async { Logger.info('开始语音识别'); // 等待权限请求完成并获取结果 - final bool record = await _requestRecordPermission(); + final bool record = await requestRecordPermission(); // 如果权限被拒绝,直接返回 if (!record) { @@ -634,18 +698,14 @@ class TranslationController extends GetxController { isTtsEnabled.value = true; } - if (currentMode.value == 'faceToFace') { - _asrService.stopContinuousRecognition(); + if (_bluetoothManager.currentDeviceRx.value != null) { + restoreOriginalAudioState(); } else { - if (_bluetoothManager.currentDeviceRx.value != null) { - restoreOriginalAudioState(); - } else { - disableBluetoothAudio(); - } - await _asrService.startContinuousRecognition(_audioSourceType); - // 开始ASR活跃时长计时 - _startAsrActiveTracking(); + disableBluetoothAudio(); } + await _asrService.startContinuousRecognition(_audioSourceType); + // 开始ASR活跃时长计时 + _startAsrActiveTracking(); isRecognizing.value = true; if (isRecording.value && isRecognizing.value) { startRecording(); @@ -691,6 +751,7 @@ class TranslationController extends GetxController { if (!isRecognizing.value) return; try { + await stopRecording(); await _asrService.stopContinuousRecognition(); await _astService.stopContinuousTranslation(); // 停止ASR活跃时长计时 @@ -715,7 +776,7 @@ class TranslationController extends GetxController { saveTranslationHistory(); } - stopRecording(); + currentSessionId = null; } catch (e) { Logger.error('停止语音识别失败: ${e.toString()}'); @@ -993,7 +1054,6 @@ class TranslationController extends GetxController { } // 确保停止ASR活跃时长跟踪 _stopAsrActiveTracking(); - stopRecording(); _translationDebounceTimer?.cancel(); await _ttsService.stop(); } catch (e) { diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index ea8a2f9e1..2998bc6ab 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -472,8 +472,8 @@ class TranslationView extends GetView { isDarkMode: isDarkMode, speakerId: 1, activeSpeaker: controller.activeSpeaker.value, - icon: Icons.headset, // 耳机图标 - label: "headsetUser".tr, // 耳机用户 + icon: Icons.headset, + label: "headsetUser".tr, ), SizedBox(width: 40.w), // 手机图标按钮 (说话者2) @@ -481,8 +481,8 @@ class TranslationView extends GetView { isDarkMode: isDarkMode, speakerId: 2, activeSpeaker: controller.activeSpeaker.value, - icon: Icons.phone_android, // 手机图标 - label: "phoneUser".tr, // 手机用户 + icon: Icons.phone_android, + label: "phoneUser".tr, ), ], ) @@ -648,8 +648,13 @@ class TranslationView extends GetView { bool isDisabled = activeSpeaker != 0 && activeSpeaker != speakerId; Timer? _holdTimer; // 增加延迟触发的计时器 return Listener( - onPointerDown: (_) { - // 设置300ms延迟防止误触 + onPointerDown: (_) async { + // 同步检查权限状态,不使用 await + if (!controller.hasRecordPermission.value) { + Get.snackbar('权限提示', '需要麦克风权限才能使用录音功能'); + return; + } + _holdTimer = Timer(const Duration(milliseconds: 200), () { if (activeSpeaker == 0) { controller.setActiveSpeaker(speakerId); diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 373c2a5d7..048d6d74c 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -11,7 +11,7 @@ import android.media.audiofx.AutomaticGainControl import android.util.Log import com.microsoft.cognitiveservices.speech.* import com.microsoft.cognitiveservices.speech.util.EventHandler - +import com.yunqiinnovation.azure_speech.tools.SimpleAudioReceiver import android.media.AudioManager import android.media.AudioAttributes import android.media.AudioFocusRequest @@ -243,14 +243,19 @@ class AzureAsrHelper(private val context: Context) { stopContinuousRecognition() } - - try { - // 启动音频处理 - audioStream!!.startAudioRecord( when(audioSourceType) { - AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE - AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL - }) + // 启动音频处理(若已启动则跳过) + if (!audioStream!!.isWriting()) { + audioStream!!.startAudioRecord( + when (audioSourceType) { + AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE + AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL + }, + null + ) + } else { + Log.d(tag, "音频已启动,跳过重复启动") + } // 执行同步识别 val result = recognizer?.recognizeOnceAsync()?.get() @@ -273,13 +278,13 @@ class AzureAsrHelper(private val context: Context) { /** * 开始连续语音识别 - * - * @param callback 连续识别结果回调 - * @return 是否成功开始识别 + * @param audioSourceType 音频源类型 + * @param audioDataCallback 音频数据回调接口 + * @return 是否成功启动 */ fun startContinuousRecognition( - - audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE + audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE, + audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = null ): Boolean { Log.d(tag, "startContinuousRecognition:$isContinuousRecognitionActive ") @@ -295,31 +300,29 @@ class AzureAsrHelper(private val context: Context) { } this.audioSourceType = audioSourceType - try { Log.d(tag, "startContinuousRecognition: ") - // 启动音频处理 - //startAudioProcessing() - // 开始连续识别 + // 启动音频处理(若已启动则跳过) + if (!audioStream!!.isWriting()) { + audioStream!!.startAudioRecord( + when (audioSourceType) { + AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE + AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL + }, + audioDataCallback + ) + } else { + Log.d(tag, "音频已启动,跳过重复启动") + } - // 启动音频处理 - audioStream!!.startAudioRecord( - when(audioSourceType) { - AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE - AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL - } - ) recognizer?.startContinuousRecognitionAsync() isContinuousRecognitionActive = true - - return true } catch (e: Exception) { audioStream!!.stopMicrophoneCapture() isContinuousRecognitionActive = false - return false } } @@ -546,7 +549,7 @@ class AzureAsrHelper(private val context: Context) { /** * 开启录音 */ - fun enableRecord(filePath: String) { + fun enableRecord(filePath: String, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = null) { Log.i(tag, "开启录音:") if (audioStream == null) { // 创建外部音频拉流对象 @@ -555,13 +558,13 @@ class AzureAsrHelper(private val context: Context) { } if (audioStream!!.recordfile == null) { audioStream!!.recordfile = RecordFile() - } + } // 启动音频处理 audioStream!!.startAudioRecord( when(audioSourceType) { AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL - }) + },audioDataCallback) audioStream!!.recordfile!!.closeFile(true) @@ -618,13 +621,16 @@ class AzureAsrHelper(private val context: Context) { */ fun stopRecord(isSave: Boolean) { Log.i(tag, "关闭录音:") + if (audioStream == null) { + return + } if (audioStream!!.recordfile == null)return audioStream!!.stopMicrophoneCapture() audioStream!!.recordfile!!.closeFile(isSave) } - + /** * 一次性识别回调接口 */ diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt index d75afa7b6..38c374238 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt @@ -24,6 +24,7 @@ import java.util.* import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import com.yunqiinnovation.azure_speech.tools.RecordFile +import com.yunqiinnovation.azure_speech.tools.SimpleAudioPlayer /** * 整合的语音翻译服务 * 集成ASR语音识别、翻译服务和TTS语音合成 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 41eb3b938..c936703af 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 @@ -5,6 +5,8 @@ import android.os.Handler import android.os.Looper import androidx.annotation.NonNull import com.yunqiinnovation.azure_speech.utils.FileLogger +import com.yunqiinnovation.azure_speech.tools.SimpleAudioReceiver +import com.yunqiinnovation.azure_speech.tools.SimpleAudioPlayer import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -42,6 +44,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { private var astEventSink: EventChannel.EventSink? = null private lateinit var azureAstHelper: IntegratedSpeechTranslationService + // 音频数据相关 + private lateinit var audioDataChannel: MethodChannel + private lateinit var audioDataEventChannel: EventChannel + private var audioDataEventSink: EventChannel.EventSink? = null + + // 是否已添加TTS事件监听器 private var isTtsListenerAdded = false @@ -96,7 +104,21 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } } } + // 音频数据 事件发送方法 + private fun sendAudioDataEvent(event: Map) { + if (audioDataEventSink == null) { + FileLogger.w(tag, "无法发送音频数据事件:事件通道未准备好") + return + } + mainHandler.post { + try { + audioDataEventSink?.success(event) + } catch (e: Exception) { + FileLogger.e(tag, "发送音频数据事件失败: ${e.message}") + } + } + } override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext @@ -149,7 +171,18 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { astEventSink = null } }) + // 初始化音频数据事件通道 + audioDataEventChannel = + EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/audio_data_events") + audioDataEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + audioDataEventSink = events + } + override fun onCancel(arguments: Any?) { + audioDataEventSink = null + } + }) // 初始化Azure语音服务 azureTtsHelper = AzureTtsHelper(context) azureAsrHelper = AzureAsrHelper(context) @@ -473,10 +506,30 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { "enableRecord" -> { val filePath = call.argument("filePath") ?: "" + // 是否接受音频数据 + val acceptAudioData = call.argument("acceptAudioData") ?: false try { - FileLogger.d(tag, "音频文件名称为: ${filePath}") // - - azureAsrHelper.enableRecord(filePath) + FileLogger.d(tag, "音频文件名称为: ${filePath}") + + // 启用录音,并根据 acceptAudioData 参数决定是否设置音频数据回调 + azureAsrHelper.enableRecord(filePath, if(acceptAudioData) { + // 创建音频数据回调,将音频数据发送到 Flutter 层 + object : SimpleAudioReceiver.AudioDataCallback { + override fun onAudio(audioData: ByteArray) { + // 构建音频数据事件映射 + val audioEvent = mapOf( + "type" to "audioData", + "data" to audioData, + "timestamp" to System.currentTimeMillis(), + "size" to audioData.size + ) + // 发送音频数据事件到 Flutter 层 + sendAudioDataEvent(audioEvent) + } + } + } else { + null + }) result.success(true) } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt index 709b99e66..0c0600aba 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt @@ -14,7 +14,7 @@ import com.deep_voice.speech.tts.TtsEventType import kotlinx.coroutines.* import java.io.ByteArrayInputStream import java.io.InputStream - +import com.yunqiinnovation.azure_speech.tools.SimpleAudioPlayer /** * Azure TTS Helper * diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt index 61870debb..65727a25d 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioPlayer.kt @@ -1,4 +1,4 @@ -package com.yunqiinnovation.azure_speech +package com.yunqiinnovation.azure_speech.tools import android.content.Context import android.media.AudioAttributes diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt index 75f76d53a..c9d50f07e 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt @@ -1,4 +1,4 @@ -package com.yunqiinnovation.azure_speech +package com.yunqiinnovation.azure_speech.tools import android.content.Context import android.media.AudioFormat @@ -38,6 +38,7 @@ class SimpleAudioReceiver(private val context: Context) { } private val bufferSize = 4096 // 可根据需要调整 + private var audioDataCallback: AudioDataCallback? = null var audioRecord: AudioRecord? = null var pushAudioStream: PushAudioInputStream? = null // 音频源配置 @@ -98,7 +99,7 @@ class SimpleAudioReceiver(private val context: Context) { * 启动写入线程 */ private fun startWriteThread() { - + writeThread = Thread { try { while (isRunning.get()) { @@ -139,6 +140,9 @@ class SimpleAudioReceiver(private val context: Context) { try { Log.d(TAG, "写入数据大小: ${finalData.size}") + if(audioDataCallback!=null){ + audioDataCallback?.onAudio(finalData) + } pushAudioStream?.write(finalData) @@ -175,12 +179,15 @@ class SimpleAudioReceiver(private val context: Context) { /** * 开始音频输入 + * @param audioSourceType 音频源类型 + * @param callback 音频数据回调,可为空 */ - fun startAudioRecord(audioSourceType: AudioSourceType) { + fun startAudioRecord(audioSourceType: AudioSourceType, callback: AudioDataCallback?) { this.audioSourceType = audioSourceType + this.audioDataCallback = callback writeQueue.clear() isWriting.set(true) - + when (audioSourceType) { AudioSourceType.MICROPHONE -> runMicrophoneCapture() AudioSourceType.EXTERNAL -> runExternalCapture() @@ -400,4 +407,20 @@ class SimpleAudioReceiver(private val context: Context) { Log.e("AudioMode", "恢复音频模式失败: ${e.message}") } } + + fun isWriting(): Boolean { + return isWriting.get() + } + + /** + * 连续识别回调接口 + */ + interface AudioDataCallback { + /** + * 音频数据回调 + * @param data 音频数据字节数组 + */ + fun onAudio(data: ByteArray) + } } + From a5020092422cb7bc12fc69c99265debb2603c631 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Mon, 11 Aug 2025 21:00:16 +0800 Subject: [PATCH 17/23] =?UTF-8?q?=E5=8E=BB=E5=A4=84=E6=B2=A1=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/asr_service.dart | 3 --- lib/data/services/ast_service.dart | 3 --- lib/data/services/speech_impl/azure_asr_service.dart | 12 ------------ lib/data/services/speech_impl/azure_ast_service.dart | 7 ------- .../controllers/meeting_record_controller.dart | 4 ---- .../controllers/speech_test_controller.dart | 3 --- .../yunqiinnovation/agent_service/AgentService.kt | 4 +--- .../yunqiinnovation/azure_speech/AzureAsrHelper.kt | 8 +------- .../azure_speech/AzureSpeechPlugin.kt | 8 -------- 9 files changed, 2 insertions(+), 50 deletions(-) diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 827194a1a..4a8b34c2d 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -85,9 +85,6 @@ enum RecognitionEventType { /// 中间识别结果(实时反馈) intermediateResult, - /// 音频 - onAudio, - /// 会话开始 sessionStarted, diff --git a/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart index 69cd23592..244a7fb37 100644 --- a/lib/data/services/ast_service.dart +++ b/lib/data/services/ast_service.dart @@ -36,9 +36,6 @@ enum RecognitionEventType1 { /// 中间识别结果(实时反馈) intermediateResult, - /// 音频 - onAudio, - /// 会话开始 sessionStarted, diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 7c34a204d..5137faee5 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -261,11 +261,6 @@ class AzureAsrService extends GetxService implements AsrService { // 发送到音频数据流 _audioDataStreamController?.add(data); - // 也可以通过识别事件流发送 - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.onAudio, - audio: data, - )); break; } } @@ -322,13 +317,6 @@ class AzureAsrService extends GetxService implements AsrService { detectedLanguage: detectedLanguage, )); break; - case 'onAudio': - final Uint8List data = eventMap['data'] as Uint8List? ?? Uint8List(0); - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.onAudio, - audio: data, - )); - break; case 'sessionStarted': _eventStreamController?.add(RecognitionEvent( type: RecognitionEventType.sessionStarted, diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index f66945d9f..5b5dc35da 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -97,13 +97,6 @@ class AzureAstService extends GetxService implements AstService { detectedLanguage: detectedLanguage, )); break; - case 'onAudio': - final Uint8List data = eventMap['data'] as Uint8List? ?? Uint8List(0); - _eventStreamController?.add(RecognitionEvent1( - type: RecognitionEventType1.onAudio, - audio: data, - )); - break; case 'sessionStarted': _eventStreamController?.add(RecognitionEvent1( type: RecognitionEventType1.sessionStarted, diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index eb177d910..50736fe52 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -320,10 +320,6 @@ class MeetingRecordController extends GetxController case RecognitionEventType.intermediateResult: intermediateContent.value = event.text; break; - case RecognitionEventType.onAudio: - // 添加新音频数据到波形 - _processAudioData(event.audio!); - break; case RecognitionEventType.finalResult: finalContent.value += _formatTranscript(event); intermediateContent.value = ''; diff --git a/lib/modules/speech_test/controllers/speech_test_controller.dart b/lib/modules/speech_test/controllers/speech_test_controller.dart index 5bcfc83e0..57d54bcad 100644 --- a/lib/modules/speech_test/controllers/speech_test_controller.dart +++ b/lib/modules/speech_test/controllers/speech_test_controller.dart @@ -184,9 +184,6 @@ class SpeechTestController extends GetxController { // 清空中间结果 intermediateResult.value = ''; break; - case RecognitionEventType.onAudio: - // TODO: Handle this case. - break; } }, onError: (error) { isListening.value = false; diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 6bccc7960..0acee79b3 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -591,9 +591,7 @@ object AgentService : CoroutineScope { } - override fun onAudio(data: ByteArray) { - - } + override fun onSessionStopped() { sendEvent("recognition_stopped", mapOf("status" to "stopped")) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 048d6d74c..afc9e0409 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -681,13 +681,7 @@ class AzureAsrHelper(private val context: Context) { */ fun onSessionStopped() - /** - * 返回音频 - * - * @param data 识别的音频 - */ - fun onAudio(data: ByteArray) - + /** * 识别取消时调用 * 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 c936703af..cad5b8251 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 @@ -242,14 +242,6 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { sendAsrEvent(mapOf("type" to "sessionStopped")) } - override fun onAudio(data: ByteArray) { - sendAsrEvent( - mapOf( - "type" to "onAudio", - "data" to data - ) - ) - } override fun onCanceled(reason: String, errorDetails: String) { From 7b7bcd472c0c7f2eb3c5d3cfc144ee2a07cd34e9 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Mon, 11 Aug 2025 21:56:41 +0800 Subject: [PATCH 18/23] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E8=BE=B9=E6=A1=86=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/intro_overlay_builder.dart | 156 ++++++++++++++++++-- 1 file changed, 141 insertions(+), 15 deletions(-) diff --git a/lib/core/widgets/intro_overlay_builder.dart b/lib/core/widgets/intro_overlay_builder.dart index bab8412b0..920737ae2 100644 --- a/lib/core/widgets/intro_overlay_builder.dart +++ b/lib/core/widgets/intro_overlay_builder.dart @@ -89,22 +89,9 @@ class IntroOverlayBuilder { } static Widget _button(String text, Function() onTap) { - return GestureDetector( + return _AnimatedBorderButton( + text: text, onTap: onTap, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20.r), - border: Border.all(color: Colors.white), - ), - child: Text( - text, - style: TextStyle( - fontSize: 14.sp, - color: Colors.white, - ), - ), - ), ); } } @@ -166,3 +153,142 @@ class _AnimatedLottieWidgetState extends State<_AnimatedLottieWidget> ); } } + +// 新增的带边框流动动画的按钮组件 +class _AnimatedBorderButton extends StatefulWidget { + final String text; + final Function() onTap; + + const _AnimatedBorderButton({ + required this.text, + required this.onTap, + }); + + @override + _AnimatedBorderButtonState createState() => _AnimatedBorderButtonState(); +} + +class _AnimatedBorderButtonState extends State<_AnimatedBorderButton> + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); + _animation = Tween( + begin: 0.0, + end: 1.0, + ).animate(_animationController); + + _animationController.repeat(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onTap, + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return CustomPaint( + painter: _FlowingBorderPainter( + progress: _animation.value, + borderRadius: 20.r, + ), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: Colors.transparent), + ), + child: Text( + widget.text, + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + ), + ), + ), + ); + }, + ), + ); + } +} + +// 自定义绘制器,用于绘制流动的边框 +class _FlowingBorderPainter extends CustomPainter { + final double progress; + final double borderRadius; + + _FlowingBorderPainter({ + required this.progress, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromLTWH(0, 0, size.width, size.height); + final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius)); + + // 计算路径总长度 + final path = Path()..addRRect(rrect); + final pathMetrics = path.computeMetrics().first; + final totalLength = pathMetrics.length; + + // 流动效果:创建一个渐变的虚线效果 + final dashLength = totalLength * 0.3; // 虚线长度 + final startOffset = (progress * totalLength) % totalLength; + + // 绘制流动的边框段 + for (int i = 0; i < 3; i++) { + final offset = (startOffset + i * totalLength / 3) % totalLength; + final endOffset = (offset + dashLength) % totalLength; + + Path extractedPath; + if (endOffset > offset) { + extractedPath = pathMetrics.extractPath(offset, endOffset); + } else { + // 处理跨越路径起点的情况 + final path1 = pathMetrics.extractPath(offset, totalLength); + final path2 = pathMetrics.extractPath(0, endOffset); + extractedPath = Path() + ..addPath(path1, Offset.zero) + ..addPath(path2, Offset.zero); + } + + // 创建渐变效果 + const gradient = LinearGradient( + colors: [ + Colors.green, + Colors.yellow, + Colors.red, + ], + stops: [0.0, 0.5, 1.0], + ); + + final gradientPaint = Paint() + ..shader = gradient.createShader(rect) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + canvas.drawPath(extractedPath, gradientPaint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} From c96e38ea2719696607de225b42f41ede1b6ee868 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Tue, 12 Aug 2025 10:46:03 +0800 Subject: [PATCH 19/23] =?UTF-8?q?=20=E4=B8=8A=E4=BC=A0=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ios/agent_service/Package.swift | 2 + .../agent_service/AgentServiceImpl.swift | 35 +- .../chat_api/ChatApiService.kt | 2 +- .../chat_api/SystemFunctionHandler.kt | 67 +-- .../chat_api/SystemFunctionHandler.swift | 4 +- .../android/src/main/AndroidManifest.xml | 7 +- .../location_service/LocationService.kt | 418 ++++++--------- .../location_service/LocationServicePlugin.kt | 38 +- .../location_service/NativeLocationService.kt | 499 ++++++++++++++++++ .../location_service/LocationData.swift | 10 +- .../location_service/LocationService.swift | 9 +- 11 files changed, 707 insertions(+), 384 deletions(-) create mode 100644 local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/NativeLocationService.kt diff --git a/local_plugins/agent_service/ios/agent_service/Package.swift b/local_plugins/agent_service/ios/agent_service/Package.swift index cc30a9ad6..83f572fff 100644 --- a/local_plugins/agent_service/ios/agent_service/Package.swift +++ b/local_plugins/agent_service/ios/agent_service/Package.swift @@ -14,6 +14,7 @@ let package = Package( .package(path: "../../../azure_speech/ios/azure_speech"), .package(path: "../../../chat_api/ios/chat_api"), .package(path: "../../../chat_storage/ios/chat_storage"), + .package(path: "../../../location_service/ios/location_service"), .package(path: "../../../music_service/ios/music_service"), .package(path: "../../../music_service/ios/user_portrait") @@ -28,6 +29,7 @@ let package = Package( .product(name: "azure-speech", package: "azure_speech"), .product(name: "chat-api", package: "chat_api"), .product(name: "chat-storage", package: "chat_storage"), + .product(name: "location-service", package: "location_service"), .product(name: "music-service", package: "music_service"), .product(name: "user-portrait", package: "user_portrait"), ], diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index e5ce57159..c05148c00 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -1,6 +1,7 @@ import Foundation import AVFoundation import ble_service +import location_service import speech import azure_speech import os @@ -65,7 +66,7 @@ class AgentServiceImpl: NSObject { private let maxIdleSeconds: TimeInterval = 10 internal var audioPlayer: AudioPlayer? - private var locationInfo:[String: String]? + // private var locationInfo:[String: String]? internal let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AgentServiceImpl") @@ -182,7 +183,7 @@ class AgentServiceImpl: NSObject { } //启动定位服务 NotificationCenter.default.post(name: .locationstartEvent, object: nil) - addlocationmonitor() + // addlocationmonitor() if let mcpServer = config["mcpServer"] as? String { self.mcpServer = mcpServer } @@ -204,7 +205,9 @@ class AgentServiceImpl: NSObject { if isInitialized { loadChatHistory() } - + LocationService.shared.getCurrentLocation() { location in + // self.locationInfo = location + } return isInitialized } @@ -841,19 +844,19 @@ private func jsonToString(_ json: [String: Any]) -> String? { } // 添加定位监听 - private func addlocationmonitor(){ - // 通常放在启动后或模块初始化时 - NotificationCenter.default.addObserver( - forName: .updatelocationEvent, - object: nil, - queue: .main - ) { notification in - if let info = notification.userInfo as? [String: String] { - print("📤 地址信息:\(info)") - self.locationInfo = info - } - } - } + // private func addlocationmonitor(){ + // // 通常放在启动后或模块初始化时 + // NotificationCenter.default.addObserver( + // forName: .updatelocationEvent, + // object: nil, + // queue: .main + // ) { notification in + // if let info = notification.userInfo as? [String: String] { + // print("📤 地址信息:\(info)") + // self.locationInfo = info + // } + // } + // } /** * 填充模板字符串,使用正则表达式匹配 {key} 格式的占位符 * 对应 Kotlin 版本的 fillTemplate 方法 diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt index 7d7d50bbe..7d344240f 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt @@ -403,7 +403,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor Log.e("ChatApiService", "openAI对象为null") throw IllegalStateException("OpenAI客户端未初始化") } -// Log.e("ChatApiService", "liwei---------,发送AI请求 model:${currentModel} messages:${chatMessages}") + Log.e("ChatApiService", "liwei---------,发送AI请求 model:${currentModel} messages:${chatMessages}") val flow = openAI!!.chatCompletions(chatCompletionRequest) // Log.d(TAG, "liwei-------------------------开始AI 对话 6") flow diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt index 2af198233..1e4f969d0 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt @@ -19,7 +19,6 @@ import android.telephony.SmsManager import android.util.Log import androidx.core.content.ContextCompat import com.yunqiinnovation.location_service.LocationService -import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* @@ -1134,7 +1133,7 @@ private class GetCurrentTimeHandler : FunctionHandler { * 获取当前位置处理器 */ private class GetCurrentLocationHandler(private val context: Context?) : FunctionHandler { - + override suspend fun handle(arguments: Map): String { if (context == null) { return JSONObject().apply { @@ -1144,76 +1143,28 @@ private class GetCurrentLocationHandler(private val context: Context?) : Functio }) }.toString() } - - val handler = context.let { SystemFunctionHandler(it) } - - // 检查位置权限 - if (!handler.checkPermissionAndGuide(Manifest.permission.ACCESS_FINE_LOCATION, "位置信息", "获取位置")) { - return JSONObject().apply { - put("error", JSONObject().apply { - put("code", "permission_denied") - put("message", "需要位置权限才能获取当前位置,请在权限管理中开启位置权限") - }) - }.toString() - } - - // 获取缓存的位置数据 - val cachedLocation = LocationService.getCachedLocationData() - - // 异步触发最新定位获取(不等待结果) - LocationService.requestLocationInBackground(object : LocationService.LocationCallback { - override fun onLocationSuccess(latitude: Double, longitude: Double, address: String) { - Log.d(SystemFunctionHandler.TAG, "后台定位成功,缓存已更新: $latitude, $longitude, $address") - } - - override fun onLocationError(errorCode: Int, errorMessage: String) { - Log.w(SystemFunctionHandler.TAG, "后台定位失败: $errorCode - $errorMessage") - } - }) - - // 立即返回缓存数据(如果存在) - if (cachedLocation != null) { - val cacheAge = System.currentTimeMillis() - cachedLocation.timestamp - val cacheAgeMinutes = cacheAge / (60 * 1000) - - return JSONObject().apply { - put("result", JSONObject().apply { - put("latitude", cachedLocation.latitude) - put("longitude", cachedLocation.longitude) - put("address", cachedLocation.address) - put("location", "${cachedLocation.latitude},${cachedLocation.longitude}") - put("source", "cache") - put("timestamp", cachedLocation.timestamp) - put("cacheAgeMinutes", cacheAgeMinutes) - put("note", "正在后台更新位置信息") - }) - }.toString() - } - // 如果没有缓存,则同步获取一次 return try { kotlinx.coroutines.suspendCancellableCoroutine { continuation -> LocationService.requestLocation(object : LocationService.LocationCallback { - override fun onLocationSuccess(latitude: Double, longitude: Double, address: String) { - Log.d(SystemFunctionHandler.TAG, "首次定位成功: $latitude, $longitude, $address") - + override fun onLocationSuccess(latitude: Double, longitude: Double) { + Log.d(SystemFunctionHandler.TAG, "首次定位成功: $latitude, $longitude") + val result = JSONObject().apply { put("result", JSONObject().apply { - put("latitude", latitude) put("longitude", longitude) - put("address", address) - put("source", "gps") + put("latitude", latitude) put("timestamp", System.currentTimeMillis()) - put("note", "首次获取位置信息") + }) }.toString() - + continuation.resumeWith(Result.success(result)) } override fun onLocationError(errorCode: Int, errorMessage: String) { Log.e(SystemFunctionHandler.TAG, "首次定位失败: $errorCode - $errorMessage") - + val result = JSONObject().apply { put("error", JSONObject().apply { put("code", "location_error") @@ -1221,7 +1172,7 @@ private class GetCurrentLocationHandler(private val context: Context?) : Functio put("errorCode", errorCode) }) }.toString() - + continuation.resumeWith(Result.success(result)) } }) diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift index b0c19e4a6..35d553698 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift @@ -490,9 +490,9 @@ private class GetCurrentLocationHandler: FunctionHandler { let result: [String: Any] = [ "success": true, "data": [ - "latitude": latitude, "longitude": longitude, - "location": "\(latitude),\(longitude)", + "latitude": latitude, + "location": "\(longitude),\(latitude)", "timestamp": Date().timeIntervalSince1970 * 1000 ] ] diff --git a/local_plugins/location_service/android/src/main/AndroidManifest.xml b/local_plugins/location_service/android/src/main/AndroidManifest.xml index 3cbeb31e4..48fb949c7 100644 --- a/local_plugins/location_service/android/src/main/AndroidManifest.xml +++ b/local_plugins/location_service/android/src/main/AndroidManifest.xml @@ -1,7 +1,12 @@ + + + - + + + diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt index 73788b194..fcdc8ce17 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt @@ -17,29 +17,32 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch + object LocationService { private const val TAG = "LocationService" private const val PERMISSION_REQUEST_CODE = 1001 + // 只接受高精度定位 + private const val HIGH_ACCURACY_THRESHOLD = 50.0 // 只接受50米以内的高精度定位 + private const val LOCATION_TIMEOUT_MS = 60000L // 增加到60秒超时,给更多时间获取高精度定位 + private const val CACHE_PREFS_NAME = "location_cache" + private var appcontext: Context? = null private var locationClient: AMapLocationClient? = null private var initialized = false private var permissionRetryHandler: Handler? = null private var permissionRetryRunnable: Runnable? = null - - // 存储当前的定位请求回调(Flutter用) private var currentLocationCallback: MethodChannel.Result? = null - // 存储权限请求回调(Flutter用) private var permissionRequestCallback: MethodChannel.Result? = null - - // 存储服务间调用的回调 private val serviceCallbacks = mutableListOf() + private var isHighAccuracyMode = true + private var cachedLocationData: LocationData? = null + private var locationTimeoutHandler: Handler? = null + private var locationStartTime: Long = 0 - /** - * 定位回调接口,供其他Android服务使用 - */ + // 修正接口,包含address参数 interface LocationCallback { - fun onLocationSuccess(latitude: Double, longitude: Double, address: String) + fun onLocationSuccess(latitude: Double, longitude: Double) fun onLocationError(errorCode: Int, errorMessage: String) } @@ -65,147 +68,164 @@ object LocationService { } fun initialize(context: Context) { - if (initialized) return - appcontext = context - - if (!checkPermissions(context)) { - Log.w(TAG, "没有定位权限,等待用户授权中...") - startPermissionMonitor() + if (initialized) { + Log.d(TAG, "定位服务已初始化") return } - + + appcontext = context.applicationContext + try { AMapLocationClient.updatePrivacyShow(context, true, true) AMapLocationClient.updatePrivacyAgree(context, true) - locationClient = AMapLocationClient(context.applicationContext) - val option = AMapLocationClientOption().apply { - locationMode = AMapLocationClientOption.AMapLocationMode.Hight_Accuracy - isOnceLocation = true // 单次定位 - isNeedAddress = true // 需要地址信息 - httpTimeOut = 20000 // 设置超时时间 - } - + + // 优化后的定位参数配置 + val option = AMapLocationClientOption() + + // 高精度定位模式(融合GPS、网络、传感器) + option.setLocationMode(AMapLocationClientOption.AMapLocationMode.Hight_Accuracy) + + // 设置定位场景 + option.setLocationPurpose(AMapLocationClientOption.AMapLocationPurpose.SignIn) // 改为签到模式,更适合高精度定位 + + // 定位间隔(首次定位建议设置较短间隔) + option.setInterval(2000) // 改为2秒,更快响应 + + // 超时时间 + option.setHttpTimeOut(30000) // 30秒足够 + + // 启用所有辅助定位技术 + option.setGpsFirst(false) // 不强制GPS优先,让SDK智能选择 + option.setWifiScan(true) // 启用WiFi辅助定位 + option.setSensorEnable(true) // 启用传感器辅助 + option.setLocationCacheEnable(true) // 启用位置缓存 + + // 单次定位设置 + option.setOnceLocation(true) // 改为单次定位,避免持续定位的干扰 + option.setOnceLocationLatest(true) // 获取最新的定位结果 + + // 其他设置 + option.setMockEnable(false) // 禁用模拟定位 + option.setNeedAddress(true) // 启用地址解析,有助于定位精度 + locationClient?.setLocationOption(option) - - // 设置定位监听器 locationClient?.setLocationListener { location -> handleLocationResult(location) } - + initialized = true - Log.d(TAG, "定位服务初始化完成") + Log.d(TAG, "定位服务初始化完成 - 优化高精度模式") } catch (e: Exception) { Log.e(TAG, "高德定位初始化失败: ${e.message}", e) } } - // 在LocationService object中添加缓存相关的属性和方法 - private var cachedLocationData: LocationData? = null - private const val CACHE_PREFS_NAME = "location_cache" - /** * 后台异步获取位置信息(不阻塞调用者) * @param callback 定位结果回调 */ - fun requestLocationInBackground(callback: LocationCallback) { + fun requestLocation(callback: LocationCallback) { val ctx = appcontext if (ctx == null) { callback.onLocationError(-1, "定位服务未初始化") return } - + if (!checkPermissions(ctx)) { callback.onLocationError(-2, "没有定位权限") return } - + if (!initialized) { callback.onLocationError(-3, "定位服务未初始化") return } - + + // 优先返回内存中的缓存 + if (cachedLocationData != null) { + callback.onLocationSuccess(cachedLocationData!!.latitude,cachedLocationData!!.longitude) + }else{ + // 添加到回调列表 + serviceCallbacks.add(callback) + } + // 记录定位开始时间 + locationStartTime = System.currentTimeMillis() + // 启动超时计时器 + startLocationTimeout() // 使用协程在后台执行 CoroutineScope(Dispatchers.IO).launch { try { - // 添加到回调列表 - serviceCallbacks.add(callback) - // 如果没有正在进行的定位请求,开始定位 - if (currentLocationCallback == null && serviceCallbacks.size == 1) { - locationClient?.startLocation() - } + locationClient?.startLocation() } catch (e: Exception) { + stopLocationTimeout() callback.onLocationError(-4, "启动后台定位失败: ${e.message}") } } } - - // 修改handleLocationResult方法,添加缓存逻辑 + + // 统一的handleLocationResult方法(整合优化逻辑) private fun handleLocationResult(location: AMapLocation?) { - // 处理Flutter回调 - val flutterCallback = currentLocationCallback - currentLocationCallback = null + if (location == null) { + Log.e(TAG, "定位结果为空") + return + } + + Log.d(TAG, "定位结果: 来源=${location.provider}, 精度=${location.accuracy}米, 坐标=(${location.latitude}, ${location.longitude})") - if (location != null && location.errorCode == 0) { - Log.d(TAG, "定位成功: ${location.latitude}, ${location.longitude}") - - // 缓存位置数据 - val locationData = LocationData( - latitude = location.latitude, - longitude = location.longitude, - address = location.address ?: "未知地址", - timestamp = System.currentTimeMillis() - ) - cachedLocationData = locationData - saveCachedLocationToPrefs(locationData) - - // 通知Flutter - if (flutterCallback != null) { - val result = mapOf( - "user_location" to (location.address ?: "未知地址"), - "user_lat" to location.latitude.toString(), - "user_lng" to location.longitude.toString() - ) - flutterCallback.success(result) - } + // 检查定位来源,只接受GPS定位 + if (location.provider != "gps") { + Log.w(TAG, "拒绝非GPS定位,当前来源: ${location.provider}") + // 继续等待GPS定位,不回调 + return + } + + if (location.errorCode == AMapLocation.LOCATION_SUCCESS) { + val accuracy = location.accuracy + Log.d(TAG, "GPS定位成功: 精度=${accuracy}米") - // 通知其他Android服务 - serviceCallbacks.forEach { callback -> + // 只接受高精度GPS定位 + if (accuracy <= HIGH_ACCURACY_THRESHOLD) { + Log.d(TAG, "接受高精度GPS定位: ${accuracy}米") + + // 停止超时计时器 + stopLocationTimeout() + + // 停止定位 try { - callback.onLocationSuccess( - location.latitude, - location.longitude, - location.address ?: "未知地址" - ) + locationClient?.stopLocation() } catch (e: Exception) { - Log.e(TAG, "服务回调失败: ${e.message}", e) + Log.e(TAG, "停止定位失败: ${e.message}", e) } - } - - } else { - Log.w(TAG, "定位失败: ${location?.errorCode} - ${location?.errorInfo}") - - // 通知Flutter - if (flutterCallback != null) { - flutterCallback.error("LOCATION_ERROR", "定位失败: ${location?.errorInfo ?: "未知错误"}", null) - } - - // 通知其他Android服务 - serviceCallbacks.forEach { callback -> - try { - callback.onLocationError( - location?.errorCode ?: -1, - location?.errorInfo ?: "未知错误" - ) - } catch (e: Exception) { - Log.e(TAG, "服务回调失败: ${e.message}", e) + + // 缓存位置数据 + val locationData = LocationData( + latitude = location.latitude, + longitude = location.longitude, + timestamp = System.currentTimeMillis() + ) + cachedLocationData = locationData + + // 处理成功的定位结果 + serviceCallbacks.forEach { callback -> + try { + callback.onLocationSuccess( + location.latitude, + location.longitude, + ) + } catch (e: Exception) { + Log.e(TAG, "服务回调失败: ${e.message}", e) + } } + // 清空服务回调列表(单次定位) + serviceCallbacks.clear() + } else { + Log.w(TAG, "GPS精度不足(${accuracy}米),继续等待更高精度定位") } + } else { + Log.e(TAG, "GPS定位失败: ${location.errorCode} - ${location.errorInfo}") } - - // 清空服务回调列表(单次定位) - serviceCallbacks.clear() } private fun startPermissionMonitor() { @@ -315,182 +335,56 @@ object LocationService { } } - /** - * 处理权限请求结果(Flutter用) - */ - fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ): Boolean { - if (requestCode != PERMISSION_REQUEST_CODE) { - return false - } - - val callback = permissionRequestCallback - permissionRequestCallback = null - - if (callback == null) { - return true - } - - val ctx = appcontext - if (ctx == null) { - callback.error("NO_CONTEXT", "上下文丢失", null) - return true - } - - val hasPermission = checkPermissions(ctx) - - if (hasPermission) { - // 权限获取成功,重新初始化定位服务 - if (!initialized) { - initialize(ctx) - } - callback.success(mapOf("granted" to true)) - } else { - callback.success(mapOf("granted" to false)) - } - - return true - } /** - * 按需获取当前位置(Flutter用) + * 位置数据类 */ - fun getCurrentLocation(result: MethodChannel.Result) { - val ctx = appcontext - if (ctx == null) { - result.error("NOT_INITIALIZED", "定位服务未初始化", null) - return - } - - if (!checkPermissions(ctx)) { - result.error("NO_PERMISSION", "需要定位权限", null) - return - } - - if (!initialized) { - result.error("NOT_INITIALIZED", "定位服务未初始化", null) - return - } - - if (currentLocationCallback != null) { - result.error("LOCATION_IN_PROGRESS", "正在定位中,请稍后再试", null) - return - } - - // 保存回调,开始定位 - currentLocationCallback = result - locationClient?.startLocation() - } + data class LocationData( + val latitude: Double, + val longitude: Double, + val timestamp: Long + ) - // ========== 以下是供其他Android服务使用的接口 ========== - - /** - * 检查是否有定位权限(供其他服务使用) - */ - fun hasLocationPermission(): Boolean { - val ctx = appcontext ?: return false - return checkPermissions(ctx) - } - - /** - * 获取当前位置(供其他Android服务使用) - * @param callback 定位结果回调 - */ - fun requestLocation(callback: LocationCallback) { - val ctx = appcontext - if (ctx == null) { - callback.onLocationError(-1, "定位服务未初始化") - return - } - - if (!checkPermissions(ctx)) { - callback.onLocationError(-2, "没有定位权限") - return - } - - if (!initialized) { - callback.onLocationError(-3, "定位服务未初始化") - return - } - - // 添加到回调列表 - serviceCallbacks.add(callback) - - // 如果没有正在进行的定位请求,开始定位 - if (currentLocationCallback == null && serviceCallbacks.size == 1) { - locationClient?.startLocation() - } - } - /** - * 同步获取缓存的位置信息(供其他服务使用) - * 注意:这个方法返回的可能是过期的位置信息 + * 启动定位超时计时器 */ - fun getCachedLocationData(): LocationData? { - // 优先返回内存中的缓存 - if (cachedLocationData != null) { - return cachedLocationData - } + private fun startLocationTimeout() { + // 如果已经有超时计时器在运行,先停止它 + stopLocationTimeout() - // 从SharedPreferences中读取缓存 - val ctx = appcontext ?: return null - return loadCachedLocationFromPrefs(ctx) - } - - private fun saveCachedLocationToPrefs(locationData: LocationData) { - val ctx = appcontext ?: return - try { - val prefs = ctx.getSharedPreferences(CACHE_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().apply { - putString("latitude", locationData.latitude.toString()) - putString("longitude", locationData.longitude.toString()) - putString("address", locationData.address) - putLong("timestamp", locationData.timestamp) - apply() + locationTimeoutHandler = Handler(Looper.getMainLooper()) + locationTimeoutHandler?.postDelayed({ + Log.w(TAG, "定位超时,停止定位请求") + + // 停止定位 + try { + locationClient?.stopLocation() + } catch (e: Exception) { + Log.e(TAG, "停止定位失败: ${e.message}", e) } - } catch (e: Exception) { - Log.e(TAG, "保存位置缓存失败: ${e.message}", e) - } - } - - private fun loadCachedLocationFromPrefs(context: Context): LocationData? { - return try { - val prefs = context.getSharedPreferences(CACHE_PREFS_NAME, Context.MODE_PRIVATE) - val latitude = prefs.getString("latitude", null)?.toDoubleOrNull() ?: return null - val longitude = prefs.getString("longitude", null)?.toDoubleOrNull() ?: return null - val address = prefs.getString("address", null) ?: return null - val timestamp = prefs.getLong("timestamp", 0L) - if (timestamp == 0L) return null + // 通知所有等待的回调超时错误 + serviceCallbacks.forEach { callback -> + try { + callback.onLocationError(-5, "定位超时,未能在${LOCATION_TIMEOUT_MS/1000}秒内获取到高精度位置") + } catch (e: Exception) { + Log.e(TAG, "超时回调失败: ${e.message}", e) + } + } - LocationData(latitude, longitude, address, timestamp) - } catch (e: Exception) { - Log.e(TAG, "读取位置缓存失败: ${e.message}", e) - null - } + // 清空回调列表 + serviceCallbacks.clear() + + // 清理超时处理器 + locationTimeoutHandler = null + }, LOCATION_TIMEOUT_MS) } /** - * 位置数据类 + * 停止定位超时计时器 */ - data class LocationData( - val latitude: Double, - val longitude: Double, - val address: String, - val timestamp: Long - ) - - fun stop() { - locationClient?.stopLocation() - locationClient?.onDestroy() - locationClient = null - currentLocationCallback = null - permissionRequestCallback = null - serviceCallbacks.clear() - initialized = false - stopPermissionMonitor() + private fun stopLocationTimeout() { + locationTimeoutHandler?.removeCallbacksAndMessages(null) + locationTimeoutHandler = null } } diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt index 3c90cfabc..21bd7184f 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt @@ -12,11 +12,10 @@ import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry /** LocationServicePlugin */ -class LocationServicePlugin: FlutterPlugin, MethodCallHandler, ActivityAware, PluginRegistry.RequestPermissionsResultListener { +class LocationServicePlugin: FlutterPlugin, MethodCallHandler { private val TAG = "LocationServicePlugin" private lateinit var context: Context private lateinit var channel : MethodChannel - private var activityBinding: ActivityPluginBinding? = null override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext @@ -25,43 +24,12 @@ class LocationServicePlugin: FlutterPlugin, MethodCallHandler, ActivityAware, Pl } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - // Log.d(TAG, "Method call: ${call.method}") - when (call.method) { - "location" -> LocationService.getCurrentLocation(result) - "requestPermission" -> LocationService.requestLocationPermission(activityBinding?.activity, result) - "checkPermission" -> LocationService.checkLocationPermission(context, result) - else -> result.notImplemented() - } + Log.d(TAG, "Method call: ${call.method}") + } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activityBinding = binding - binding.addRequestPermissionsResultListener(this) - LocationService.initialize(context) - } - - override fun onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity() - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - onAttachedToActivity(binding) - } - - override fun onDetachedFromActivity() { - activityBinding?.removeRequestPermissionsResultListener(this) - activityBinding = null - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ): Boolean { - return LocationService.onRequestPermissionsResult(requestCode, permissions, grantResults) - } } diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/NativeLocationService.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/NativeLocationService.kt new file mode 100644 index 000000000..af4064134 --- /dev/null +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/NativeLocationService.kt @@ -0,0 +1,499 @@ +package com.yunqiinnovation.location_service + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.location.* +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* + +object NativeLocationService { + private const val TAG = "NativeLocationService" + private const val PERMISSION_REQUEST_CODE = 1002 + + // 高精度定位阈值 + private const val HIGH_ACCURACY_THRESHOLD = 50.0f // 只接受50米以内的高精度定位 + private const val LOCATION_TIMEOUT_MS = 60000L // 60秒超时 + private const val MIN_TIME_BETWEEN_UPDATES = 5000L // 5秒最小更新间隔 + private const val MIN_DISTANCE_CHANGE_FOR_UPDATES = 10f // 10米最小距离变化 + + private var appContext: Context? = null + private var locationManager: LocationManager? = null + private var initialized = false + private val serviceCallbacks = mutableListOf() + private var cachedLocationData: LocationData? = null + private var locationTimeoutHandler: Handler? = null + private var locationStartTime: Long = 0 + private var gpsLocationListener: LocationListener? = null + private var networkLocationListener: LocationListener? = null + + // 定位回调接口 + interface LocationCallback { + fun onLocationSuccess(latitude: Double, longitude: Double) + fun onLocationError(errorCode: Int, errorMessage: String) + } + + /** + * 初始化原生定位服务 + */ + fun initialize(context: Context) { + if (initialized) { + Log.d(TAG, "原生定位服务已初始化") + return + } + + appContext = context.applicationContext + locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + if (locationManager == null) { + Log.e(TAG, "无法获取LocationManager") + return + } + + initialized = true + Log.d(TAG, "原生定位服务初始化完成") + } + + /** + * 检查GPS是否可用 + */ + private fun isGpsEnabled(): Boolean { + return locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) ?: false + } + + /** + * 检查网络定位是否可用 + */ + private fun isNetworkEnabled(): Boolean { + return locationManager?.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ?: false + } + + /** + * 获取位置信息(异步) + */ + fun requestLocation(callback: LocationCallback) { + val ctx = appContext + if (ctx == null) { + callback.onLocationError(-1, "定位服务未初始化") + return + } + + if (!checkPermissions(ctx)) { + callback.onLocationError(-2, "没有定位权限") + return + } + + if (!initialized) { + callback.onLocationError(-3, "定位服务未初始化") + return + } + + // 检查定位服务是否可用 + if (!isGpsEnabled() && !isNetworkEnabled()) { + callback.onLocationError(-4, "GPS和网络定位都不可用,请检查设置") + return + } + + // 优先返回缓存的位置 + cachedLocationData?.let { cached -> + val currentTime = System.currentTimeMillis() + // 如果缓存时间在5分钟内,直接返回 + if (currentTime - cached.timestamp < 5 * 60 * 1000) { + callback.onLocationSuccess(cached.latitude, cached.longitude) + return + } + } + + // 添加到回调列表 + serviceCallbacks.add(callback) + + // 记录定位开始时间 + locationStartTime = System.currentTimeMillis() + + // 启动超时计时器 + startLocationTimeout() + + // 使用协程在后台执行定位 + CoroutineScope(Dispatchers.IO).launch { + try { + startLocationUpdates() + } catch (e: Exception) { + stopLocationTimeout() + callback.onLocationError(-5, "启动定位失败: ${e.message}") + } + } + } + + /** + * 开始位置更新 + */ + private fun startLocationUpdates() { + val ctx = appContext ?: return + val locManager = locationManager ?: return + + // 检查权限 + if (ActivityCompat.checkSelfPermission( + ctx, + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && + ActivityCompat.checkSelfPermission( + ctx, + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.e(TAG, "没有定位权限") + return + } + + Log.d(TAG, "=== 启动混合定位模式 ===") + Log.d(TAG, "网络定位可用: ${isNetworkEnabled()}") + Log.d(TAG, "GPS定位可用: ${isGpsEnabled()}") + + // 优先启动网络定位 + if (isNetworkEnabled()) { + Log.d(TAG, "🌐 启动网络定位(优先)") + + networkLocationListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + Log.d(TAG, "🌐 网络定位更新: 精度=${location.accuracy}米") + handleLocationResult(location, "NETWORK") + } + + override fun onProviderEnabled(provider: String) { + Log.d(TAG, "网络提供者已启用: $provider") + } + + override fun onProviderDisabled(provider: String) { + Log.d(TAG, "网络提供者已禁用: $provider") + } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + Log.d(TAG, "网络状态变化: $provider, status: $status") + } + } + + locManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + MIN_TIME_BETWEEN_UPDATES, + MIN_DISTANCE_CHANGE_FOR_UPDATES, + networkLocationListener!!, + Looper.getMainLooper() + ) + Log.d(TAG, "✅ 网络定位已启动") + } + + // GPS作为补充定位 + if (isGpsEnabled()) { + Log.d(TAG, "🛰️ 启动GPS定位(补充)") + + gpsLocationListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + Log.d(TAG, "🛰️ GPS定位更新: 精度=${location.accuracy}米") + handleLocationResult(location, "GPS") + } + + override fun onProviderEnabled(provider: String) { + Log.d(TAG, "GPS提供者已启用: $provider") + } + + override fun onProviderDisabled(provider: String) { + Log.d(TAG, "GPS提供者已禁用: $provider") + } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + when (status) { + LocationProvider.AVAILABLE -> Log.d(TAG, "GPS状态: 可用") + LocationProvider.OUT_OF_SERVICE -> Log.d(TAG, "GPS状态: 服务中断") + LocationProvider.TEMPORARILY_UNAVAILABLE -> Log.d(TAG, "GPS状态: 暂时不可用") + } + } + } + + locManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + MIN_TIME_BETWEEN_UPDATES, + MIN_DISTANCE_CHANGE_FOR_UPDATES, + gpsLocationListener!!, + Looper.getMainLooper() + ) + Log.d(TAG, "✅ GPS定位已启动") + } else { + Log.w(TAG, "GPS未启用,仅使用网络定位") + } + + if (!isNetworkEnabled() && !isGpsEnabled()) { + Log.e(TAG, "❌ 所有定位服务都不可用") + serviceCallbacks.forEach { callback -> + callback.onLocationError(-6, "定位服务不可用") + } + } + } + + /** + * 处理定位结果 + */ + // 修改handleLocationResult方法,对网络定位进行更细致的处理 + private fun handleLocationResult(location: Location, provider: String) { + Log.d(TAG, "定位结果: 来源=$provider, 精度=${location.accuracy}米, 坐标=(${location.latitude}, ${location.longitude})") + + // 混合定位策略:优先接受可用的定位结果 + when (provider) { + "NETWORK" -> { + // 网络定位优先策略 + when { + location.accuracy <= 200f -> { + Log.d(TAG, "✅ 接受网络定位: ${location.accuracy}米") + processLocationSuccess(location) + } + location.accuracy <= 1000f -> { + Log.d(TAG, "⚠️ 接受中等精度网络定位: ${location.accuracy}米") + // 短暂等待,看是否有更好的定位 + val elapsedTime = System.currentTimeMillis() - locationStartTime + if (elapsedTime > 5000) { // 5秒后接受 + processLocationSuccess(location) + } else { + Log.d(TAG, "等待更高精度定位中...") + } + } + location.accuracy <= 3000f -> { + Log.w(TAG, "⚠️ 网络定位精度较低: ${location.accuracy}米") + // 10秒后接受低精度定位 + val elapsedTime = System.currentTimeMillis() - locationStartTime + if (elapsedTime > 10000) { + Log.d(TAG, "接受低精度网络定位") + processLocationSuccess(location) + } + } + else -> { + Log.w(TAG, "网络定位精度过低(${location.accuracy}米),继续等待") + } + } + } + "GPS" -> { + // GPS作为高精度补充 + if (location.accuracy <= HIGH_ACCURACY_THRESHOLD) { + Log.d(TAG, "✅ 接受高精度GPS定位: ${location.accuracy}米") + processLocationSuccess(location) + } else if (location.accuracy <= HIGH_ACCURACY_THRESHOLD * 2) { + Log.d(TAG, "✅ 接受GPS定位: ${location.accuracy}米") + processLocationSuccess(location) + } else { + Log.w(TAG, "GPS定位精度不足(${location.accuracy}米),继续等待") + } + } + } + } + + /** + * 处理定位成功 + */ + private fun processLocationSuccess(location: Location) { + // 停止超时计时器 + stopLocationTimeout() + + // 停止定位更新 + stopLocationUpdates() + + // 获取地址信息(可选) + val address = getAddressFromLocation(location) + + // 缓存位置数据 + val locationData = LocationData( + latitude = location.latitude, + longitude = location.longitude, + timestamp = System.currentTimeMillis() + ) + cachedLocationData = locationData + + // 处理成功的定位结果 + serviceCallbacks.forEach { callback -> + try { + callback.onLocationSuccess( + location.latitude, + location.longitude + ) + } catch (e: Exception) { + Log.e(TAG, "服务回调失败: ${e.message}", e) + } + } + + // 清空服务回调列表 + serviceCallbacks.clear() + } + + /** + * 停止定位更新 + */ + private fun stopLocationUpdates() { + val locManager = locationManager ?: return + + gpsLocationListener?.let { + locManager.removeUpdates(it) + gpsLocationListener = null + } + + networkLocationListener?.let { + locManager.removeUpdates(it) + networkLocationListener = null + } + + Log.d(TAG, "已停止定位更新") + } + + /** + * 从位置获取地址信息 + */ + private fun getAddressFromLocation(location: Location): String { + return try { + val ctx = appContext ?: return "" + val geocoder = Geocoder(ctx, Locale.getDefault()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Android 13+ 使用新的异步API + var addressResult = "" + geocoder.getFromLocation(location.latitude, location.longitude, 1) { addresses -> + if (addresses.isNotEmpty()) { + addressResult = addresses[0].getAddressLine(0) ?: "" + } + } + addressResult + } else { + // 旧版本使用同步API + @Suppress("DEPRECATION") + val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1) + if (!addresses.isNullOrEmpty()) { + addresses[0].getAddressLine(0) ?: "" + } else { + "" + } + } + } catch (e: Exception) { + Log.w(TAG, "获取地址失败: ${e.message}") + "" + } + } + + /** + * 检查定位权限 + */ + private fun checkPermissions(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * 检查精确定位权限 + */ + private fun hasFineLocationPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * 检查粗略定位权限 + */ + private fun hasCoarseLocationPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * 检查后台定位权限 + */ + private fun hasBackgroundLocationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } else { + true // Android 9 及以下没有这个权限 + } + } + + /** + * 位置数据类 + */ + data class LocationData( + val latitude: Double, + val longitude: Double, + val timestamp: Long + ) + + /** + * 启动定位超时计时器 + */ + private fun startLocationTimeout() { + // 如果已经有超时计时器在运行,先停止它 + stopLocationTimeout() + + locationTimeoutHandler = Handler(Looper.getMainLooper()) + locationTimeoutHandler?.postDelayed({ + Log.w(TAG, "定位超时,停止定位请求") + + // 停止定位 + stopLocationUpdates() + + // 通知所有等待的回调超时错误 + serviceCallbacks.forEach { callback -> + try { + callback.onLocationError(-6, "定位超时,未能在${LOCATION_TIMEOUT_MS/1000}秒内获取到位置") + } catch (e: Exception) { + Log.e(TAG, "超时回调失败: ${e.message}", e) + } + } + + // 清空回调列表 + serviceCallbacks.clear() + + // 清理超时处理器 + locationTimeoutHandler = null + }, LOCATION_TIMEOUT_MS) + } + + /** + * 停止定位超时计时器 + */ + private fun stopLocationTimeout() { + locationTimeoutHandler?.removeCallbacksAndMessages(null) + locationTimeoutHandler = null + } + + /** + * 清理资源 + */ + fun cleanup() { + stopLocationUpdates() + stopLocationTimeout() + serviceCallbacks.clear() + cachedLocationData = null + initialized = false + Log.d(TAG, "原生定位服务已清理") + } +} + +// 调整精度阈值,更适合混合定位 +private const val HIGH_ACCURACY_THRESHOLD = 100.0f // 放宽到100米 +private const val LOCATION_TIMEOUT_MS = 30000L // 缩短到30秒 +private const val MIN_TIME_BETWEEN_UPDATES = 2000L // 2秒更新 +private const val MIN_DISTANCE_CHANGE_FOR_UPDATES = 5f // 5米距离变化 \ No newline at end of file diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift index 5facd02e0..221a1ed56 100644 --- a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift +++ b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift @@ -2,15 +2,15 @@ import Foundation /// 位置数据结构 public struct LocationData: Codable { - public let latitude: Double public let longitude: Double + public let latitude: Double public let timestamp: TimeInterval - public let latitudeandlongitude: String + public let location: String - public init(latitude: Double, longitude: Double,timestamp: TimeInterval) { - self.latitude = latitude + public init( longitude: Double,latitude: Double,timestamp: TimeInterval) { self.longitude = longitude + self.latitude = latitude self.timestamp = timestamp - self.latitudeandlongitude = "\(latitude),\(longitude)" + self.location = "\(longitude),\(latitude)" } } diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift index 53c23686d..faccc1834 100644 --- a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift +++ b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift @@ -204,8 +204,8 @@ public class LocationService: NSObject { let longitude = Double(lngStr) { let locationData = LocationData( - latitude: latitude, longitude: longitude, + latitude: latitude, timestamp: Date().timeIntervalSince1970 * 1000 ) completion(.success(locationData)) @@ -245,8 +245,8 @@ public class LocationService: NSObject { /// 处理位置数据 private func processLocation(_ location: CLLocation) { let locationInfo: [String: Any] = [ - "user_lat": String(location.coordinate.latitude), "user_lng": String(location.coordinate.longitude), + "user_lat": String(location.coordinate.latitude), "timestamp": Date().timeIntervalSince1970 * 1000, "accuracy": location.horizontalAccuracy // 添加精度信息 ] @@ -255,8 +255,8 @@ public class LocationService: NSObject { // 更新缓存 cachedLocationData = LocationData( - latitude: location.coordinate.latitude, longitude: location.coordinate.longitude, + latitude: location.coordinate.latitude, timestamp: Date().timeIntervalSince1970 * 1000 ) @@ -268,8 +268,9 @@ public class LocationService: NSObject { /// 格式化位置数据 private func formatLocationData(_ locationData: LocationData) -> [String: Any] { return [ - "user_lat": String(locationData.latitude), "user_lng": String(locationData.longitude), + "user_lat": String(locationData.latitude), + "location": "\(locationData.longitude),\(locationData.latitude)", "timestamp": locationData.timestamp ] } From 723780b7cc1fdddadfe576aa82eec79ea3cc0454 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Tue, 12 Aug 2025 11:17:15 +0800 Subject: [PATCH 20/23] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../location_service/LocationService.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt index fcdc8ce17..59bea1b2f 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt @@ -16,6 +16,7 @@ import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.json.JSONObject object LocationService { @@ -115,6 +116,15 @@ object LocationService { } initialized = true + //首次请求一次定位 + requestLocation(object : LocationCallback { + override fun onLocationSuccess(latitude: Double, longitude: Double) { + } + + override fun onLocationError(errorCode: Int, errorMessage: String) { + } + }) + Log.d(TAG, "定位服务初始化完成 - 优化高精度模式") } catch (e: Exception) { Log.e(TAG, "高德定位初始化失败: ${e.message}", e) @@ -173,14 +183,6 @@ object LocationService { } Log.d(TAG, "定位结果: 来源=${location.provider}, 精度=${location.accuracy}米, 坐标=(${location.latitude}, ${location.longitude})") - - // 检查定位来源,只接受GPS定位 - if (location.provider != "gps") { - Log.w(TAG, "拒绝非GPS定位,当前来源: ${location.provider}") - // 继续等待GPS定位,不回调 - return - } - if (location.errorCode == AMapLocation.LOCATION_SUCCESS) { val accuracy = location.accuracy Log.d(TAG, "GPS定位成功: 精度=${accuracy}米") From af58f50777b9213d7f76c66771a910afa66da27d Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 12 Aug 2025 12:46:40 +0800 Subject: [PATCH 21/23] =?UTF-8?q?fix(=E6=9D=83=E9=99=90=E8=A7=86=E5=9B=BE)?= =?UTF-8?q?:=20=E4=BB=85=E5=9C=A8Android=E5=B9=B3=E5=8F=B0=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=80=9A=E8=AE=AF=E6=9D=83=E9=99=90=E7=BB=84=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8DiOS=E5=B9=B3=E5=8F=B0=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/views/permissions_view.dart | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/modules/settings/views/permissions_view.dart b/lib/modules/settings/views/permissions_view.dart index c6eebac8c..bf83d02fc 100644 --- a/lib/modules/settings/views/permissions_view.dart +++ b/lib/modules/settings/views/permissions_view.dart @@ -4,6 +4,8 @@ import 'package:permission_handler/permission_handler.dart'; import '../controllers/settings_controller.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'dart:ui'; +import 'dart:io' show Platform; + import '../../../core/utils/permission_util.dart'; // 引入PermissionUtil class PermissionsView extends GetView { @@ -37,7 +39,7 @@ class PermissionsView extends GetView { centerTitle: true, ), body: FutureBuilder>( - future: controller.checkAllPermissions(), + future: controller.checkAllPermissions(), //检查权限当前状态 builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -45,8 +47,8 @@ class PermissionsView extends GetView { if (snapshot.hasError) { return Center( - child: Text('getPermissionStatusFailed'.tr + - ': ${snapshot.error}')); // 获取权限状态失败 + child: Text( + '${'getPermissionStatusFailed'.tr}: ${snapshot.error}')); // 获取权限状态失败 } final permissionStatuses = snapshot.data ?? {}; @@ -54,15 +56,16 @@ class PermissionsView extends GetView { return ListView( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), children: [ - // 通讯权限组 - _buildPermissionSection( - 'communicationPermissions'.tr, // 通讯权限 - Icons.phone_android, - [Permission.phone, Permission.sms], - permissionStatuses, - isDarkMode), - - SizedBox(height: 20.h), + // 通讯权限组- iOS平台没有通讯权限 + if (Platform.isAndroid) ...[ + _buildPermissionSection( + 'communicationPermissions'.tr, // 通讯权限 + Icons.phone_android, + [Permission.phone, Permission.sms], + permissionStatuses, + isDarkMode), + SizedBox(height: 20.h), + ], // 日历权限组 _buildPermissionSection( From 88a1e4761af45222fa02fa7a6b510b6374ffd242 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 12 Aug 2025 16:11:13 +0800 Subject: [PATCH 22/23] =?UTF-8?q?feat(=E6=9D=83=E9=99=90):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=B2=BE=E7=A1=AE=E9=97=B9=E9=92=9F=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在AndroidManifest.xml中添加SCHEDULE_EXACT_ALARM权限声明 在设置控制器中添加闹钟权限配置项 更新中英文翻译文件添加闹钟权限相关文案 在权限管理页面添加闹钟权限显示逻辑 --- android/app/src/main/AndroidManifest.xml | 2 ++ lib/core/translations/language/en_us.dart | 6 ++++++ lib/core/translations/language/zh_cn.dart | 4 ++++ .../controllers/settings_controller.dart | 16 ++++++++++++++-- .../settings/views/permissions_view.dart | 19 +++++++++++++++++++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 127288e55..ebb128fb5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,8 @@ + + diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index 7dca392ef..b4c776ab6 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -487,15 +487,19 @@ const Map enUS = { // 权限管理页面 'getPermissionStatusFailed': 'Failed to get permission status', 'communicationPermissions': 'Communication Permissions', + 'alarmPermissions': 'Alarm Permissions', 'calendarPermissions': 'Calendar Permissions', 'locationPermissions': 'Location Permissions', 'phone': 'Phone', 'sms': 'SMS', + 'alarm': 'Alarm', 'calendarWrite': 'Calendar Write', 'calendarFullAccess': 'Calendar Full Access', 'locationInfo': 'Location Info', 'phonePermissionDesc': 'Used for phone call functionality', 'smsPermissionDesc': 'Used for SMS functionality', + 'alarmPermissionDesc': + 'Used to set exact alarms to ensure reminders trigger on time', 'calendarWritePermissionDesc': 'Used for creating calendar events and reminders', 'calendarFullAccessPermissionDesc': @@ -512,6 +516,8 @@ const Map enUS = { 'phonePermissionDetailDesc': 'Phone call functionality requires phone permission', 'smsPermissionDetailDesc': 'SMS functionality requires SMS permission', + 'alarmPermissionDetailDesc': + 'This permission is required to set exact alarms and ensure reminders trigger on time', 'calendarWritePermissionDetailDesc': 'Creating calendar events requires calendar write permission', 'calendarFullAccessPermissionDetailDesc': diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index d73546416..e0757379e 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -455,15 +455,18 @@ const Map zhCN = { // 权限管理页面 'getPermissionStatusFailed': '获取权限状态失败', 'communicationPermissions': '通讯权限', + 'alarmPermissions': '闹钟权限', 'calendarPermissions': '日历权限', 'locationPermissions': '位置权限', 'phone': '电话', 'sms': '短信', + 'alarm': '闹钟', 'calendarWrite': '日历写入', 'calendarFullAccess': '日历完整访问', 'locationInfo': '位置信息', 'phonePermissionDesc': '用于拨打电话功能', 'smsPermissionDesc': '用于发送短信功能', + 'alarmPermissionDesc': '用于设置精确闹钟以确保提醒按时触发', 'calendarWritePermissionDesc': '用于创建日历事件和提醒', 'calendarFullAccessPermissionDesc': '用于读取和管理日历事件及提醒', 'locationPermissionDesc': '用于获取当前位置信息', @@ -477,6 +480,7 @@ const Map zhCN = { 'permissionRequestError': '请求权限时出错', 'phonePermissionDetailDesc': '拨打电话功能需要电话权限', 'smsPermissionDetailDesc': '发送短信功能需要短信权限', + 'alarmPermissionDetailDesc': '设置精确闹钟需要此权限,以确保提醒按时触发', 'calendarWritePermissionDetailDesc': '创建日历事件需要日历写入权限', 'calendarFullAccessPermissionDetailDesc': '管理日历事件需要完整日历权限', 'locationPermissionDetailDesc': '获取位置信息需要位置权限', diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index 4dcecf302..496e405e3 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/lib/modules/settings/controllers/settings_controller.dart @@ -192,7 +192,18 @@ class SettingsController extends GetxController { }, {'type': Permission.bluetooth, 'name': '蓝牙', 'description': '用于连接蓝牙设备'} ] - } + }, + { + 'name': '闹钟权限', + 'icon': Icons.alarm, + 'permissions': [ + { + 'type': Permission.scheduleExactAlarm, + 'name': '闹钟', + 'description': '用于设置精确闹钟' + } + ] + }, ]; // 检查单个权限状态 @@ -453,7 +464,8 @@ class SettingsController extends GetxController { Logger().i('更新agent服务语音识别语言: ${supportedLanguages.join(', ')}'); // 调用agent服务更新语言设置 - final success = await AgentService.setSupportedLanguages(supportedLanguages); + final success = + await AgentService.setSupportedLanguages(supportedLanguages); if (success) { Logger().i('agent服务语言设置更新成功'); } else { diff --git a/lib/modules/settings/views/permissions_view.dart b/lib/modules/settings/views/permissions_view.dart index bf83d02fc..2b0ceaf80 100644 --- a/lib/modules/settings/views/permissions_view.dart +++ b/lib/modules/settings/views/permissions_view.dart @@ -66,6 +66,17 @@ class PermissionsView extends GetView { isDarkMode), SizedBox(height: 20.h), ], + // 闹钟权限组 + if (Platform.isAndroid) ...[ + _buildPermissionSection( + 'alarmPermissions'.tr, // 闹钟权限 + Icons.alarm, + [Permission.scheduleExactAlarm], + permissionStatuses, + isDarkMode, + ), + SizedBox(height: 20.h), + ], // 日历权限组 _buildPermissionSection( @@ -165,6 +176,8 @@ class PermissionsView extends GetView { return 'calendarFullAccess'.tr; // 日历完整访问 case Permission.location: return 'locationInfo'.tr; // 位置信息 + case Permission.scheduleExactAlarm: + return 'alarm'.tr; // 闹钟 default: return permission.toString(); } @@ -183,6 +196,8 @@ class PermissionsView extends GetView { return 'calendarFullAccessPermissionDesc'.tr; // 用于读取和管理日历事件及提醒 case Permission.location: return 'locationPermissionDesc'.tr; // 用于获取当前位置信息 + case Permission.scheduleExactAlarm: + return 'alarmPermissionDesc'.tr; // 设置精确闹钟需要此权限 default: return 'appFunctionNeeds'.tr; // 应用功能需要 } @@ -358,6 +373,8 @@ class PermissionsView extends GetView { return 'calendarFullAccess'.tr; // 日历完整访问 case Permission.location: return 'locationInfo'.tr; // 位置信息 + case Permission.scheduleExactAlarm: + return 'alarm'.tr; // 闹钟 default: return permission.toString(); } @@ -376,6 +393,8 @@ class PermissionsView extends GetView { return 'calendarFullAccessPermissionDetailDesc'.tr; // 管理日历事件需要完整日历权限 case Permission.location: return 'locationPermissionDetailDesc'.tr; // 获取位置信息需要位置权限 + case Permission.scheduleExactAlarm: + return 'alarmPermissionDetailDesc'.tr; // 设置精确闹钟需要此权限 default: return 'appNeedsPermissionForFullFunction'.tr; // 应用需要此权限以提供完整功能 } From 56fd5f10dfda1687cfb18411d1f12528ddd2250e Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Tue, 12 Aug 2025 17:21:58 +0800 Subject: [PATCH 23/23] =?UTF-8?q?=E4=BC=98=E5=8C=96mcp=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat_api/CustomSseClientTransport.kt | 57 ++++-- .../com/yunqiinnovation/chat_api/MCPClient.kt | 39 ++-- .../yunqiinnovation/chat_api/MCPSubClient.kt | 175 ++++++++++-------- .../location_service/LocationService.kt | 10 +- 4 files changed, 168 insertions(+), 113 deletions(-) diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt index aa469e62a..a08ed5472 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt @@ -22,7 +22,8 @@ import java.util.concurrent.atomic.AtomicBoolean */ class CustomSseClientTransport( private val client: HttpClient, - private val urlString: String?, + private val serviceidString: String?, + public val urlString: String?, private val reconnectionTime: Duration? = null, private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, private val onConnectionLost: (() -> Unit)? = null @@ -88,7 +89,7 @@ class CustomSseClientTransport( Triple(hostUrl, path, params) } catch (e: Exception) { - Log.e(TAG, "解析URL失败: $url, ${e.message}") + Log.e(TAG, "$serviceidString 解析URL失败: $url, ${e.message}") Triple(url, "", emptyMap()) } } @@ -102,9 +103,9 @@ class CustomSseClientTransport( session.incoming.collect { event -> when (event.event) { "error" -> { - Log.e(TAG, "SSE错误: ${event.data}") + Log.e(TAG, "$serviceidString SSE错误: ${event.data}") isConnected.set(false) - val exception = Exception("SSE Error: ${event.data}") + val exception = Exception("$serviceidString SSE Error: ${event.data}") _onError(exception) onConnectionLost?.invoke() throw exception @@ -112,7 +113,7 @@ class CustomSseClientTransport( "open" -> { // SSE连接已打开 - Log.d(TAG, "SSE连接已打开") + Log.d(TAG, "$serviceidString SSE连接已打开") isConnected.set(true) } "ping" -> { @@ -146,7 +147,7 @@ class CustomSseClientTransport( endpoint.complete(endpointWithParams) } catch (e: Exception) { - Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) + Log.e(TAG, "$serviceidString 处理endpoint事件失败: ${e.message}", e) _onError(e) close() error(e) @@ -161,22 +162,22 @@ class CustomSseClientTransport( val message = json.decodeFromString(data) _onMessage(message) } catch (e: Exception) { - Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) + Log.e(TAG, "$serviceidString 解析JSON-RPC消息失败: ${e.message}", e) _onError(e) } } } catch (e: Exception) { - Log.e(TAG, "处理事件失败: ${e.message}", e) + Log.e(TAG, "$serviceidString 处理事件失败: ${e.message}", e) _onError(e) } } } } } catch (e: CancellationException) { - Log.d(TAG, "SSE事件收集被取消") + Log.d(TAG, "$serviceidString SSE事件收集被取消") throw e } catch (e: Exception) { - Log.e(TAG, "SSE连接异常断开: ${e.message}", e) + Log.e(TAG, "$serviceidString SSE连接异常断开: ${e.message}", e) isConnected.set(false) _onError(e) onConnectionLost?.invoke() @@ -199,13 +200,13 @@ class CustomSseClientTransport( // 检查session是否仍然活跃 if (session.coroutineContext[Job]?.isCancelled == true) { - Log.w(TAG, "检测到SSE会话已取消") + Log.w(TAG, "$serviceidString 检测到SSE会话已取消") isConnected.set(false) onConnectionLost?.invoke() break } } catch (e: Exception) { - Log.e(TAG, "连接监控异常: ${e.message}", e) + Log.e(TAG, "$serviceidString 连接监控异常: ${e.message}", e) isConnected.set(false) onConnectionLost?.invoke() break @@ -219,7 +220,7 @@ class CustomSseClientTransport( */ override suspend fun start() { if (!initialized.compareAndSet(false, true)) { - Log.e(TAG, "传输层已经启动,不能重复启动") + Log.e(TAG, "$serviceidString 传输层已经启动,不能重复启动") error("CustomSseClientTransport already started!") } @@ -283,12 +284,30 @@ class CustomSseClientTransport( setBody(jsonString) } - if (!response.status.isSuccess()) { - val text = response.bodyAsText() - // Log.e(TAG, "发送消息失败:URL:${urlString} HTTP ${response.status}, $text") + // 优化处理百度API的202状态码 + when { + response.status.isSuccess() -> { + // 2xx状态码都视为成功 + Log.d(TAG, "$serviceidString 消息发送成功: HTTP ${response.status}") + } + response.status == HttpStatusCode.Accepted -> { + // 202 Accepted - 百度API异步处理中,这是正常状态 + Log.d(TAG, "$serviceidString 消息已被接受,正在异步处理: HTTP ${response.status}") + } + else -> { + // 其他非成功状态码才记录为错误 + val text = response.bodyAsText() + Log.w(TAG, "$serviceidString 发送消息收到非成功状态码: URL:${messageEndpoint} HTTP ${response.status}, $text") + + // 根据具体状态码决定是否抛出异常 + if (response.status.value >= 400) { + // 4xx和5xx错误才抛出异常 + throw Exception("HTTP ${response.status}: $text") + } + } } } catch (e: Exception) { - Log.e(TAG, "发送消息异常: ${e.message}", e) + Log.e(TAG, "$serviceidString 发送消息异常: ${e.message}", e) _onError(e) throw e } @@ -306,7 +325,7 @@ class CustomSseClientTransport( */ override suspend fun close() { if (!initialized.get()) { - Log.e(TAG, "关闭失败: 传输层未初始化") + Log.e(TAG, "$serviceidString 关闭失败: 传输层未初始化") error("CustomSseClientTransport is not initialized!") } @@ -317,6 +336,6 @@ class CustomSseClientTransport( job?.cancelAndJoin() connectionMonitorJob?.cancelAndJoin() - Log.d(TAG, "CustomSseClientTransport已关闭") + Log.d(TAG, "$serviceidString CustomSseClientTransport已关闭") } } \ No newline at end of file diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt index c727a8f82..01b403ada 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt @@ -67,45 +67,54 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { Log.w(TAG, "Failed to initialize system functions", e) } } - + /** * 连接到SSE服务器 * 直接接收完整的JSON配置字符串 - * + * * @param mcpConfigJson 包含mcpServers字段的JSON配置字符串 * @return 是否连接成功 */ suspend fun connectToSSE(mcpConfigJson: String): Boolean { // 清除现有连接 closeAllConnections() - + return try { val config = JSONObject(mcpConfigJson) val mcpServers = config.optJSONObject("mcpServers") ?: return false - + var connectedCount = 0 val serverIds = mcpServers.keys() - + while (serverIds.hasNext()) { val serverId = serverIds.next() val serverConfig = mcpServers.optJSONObject(serverId) ?: continue - + val url = serverConfig.optString("url", "") val tools = serverConfig.optString("tools", "") if (url.isEmpty()) continue - val subClient = MCPSubClient(serverId, url,tools, sharedHttpClient) - - if (subClient.connect()) { - subClients[serverId] = subClient - connectedCount++ - } else { - Log.w(TAG, "Failed to connect to MCP server: $serverId") + + Log.d(TAG, "开始连接MCP服务器: $serverId") + val subClient = MCPSubClient(serverId, url, tools, sharedHttpClient) + + // 使用协程并发连接,但每个服务器都会进行重试 + try { + if (subClient.connect()) { + subClients[serverId] = subClient + connectedCount++ + Log.d(TAG, "MCP服务器连接成功: $serverId") + } else { + Log.w(TAG, "MCP服务器连接失败: $serverId") + } + } catch (e: Exception) { + Log.e(TAG, "MCP服务器连接异常: $serverId, ${e.message}", e) } } - + isConnectedFlag = connectedCount > 0 + Log.d(TAG, "MCP连接完成,成功连接 $connectedCount 个服务器") connectedCount > 0 - + } catch (e: Exception) { Log.e(TAG, "Failed to connect to SSE", e) false diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt index cf3a96703..2644dad03 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt @@ -33,7 +33,7 @@ class MCPSubClient( private var heartbeatJob: Job? = null private val heartbeatInterval = 30000L // 30秒 // 重连配置 - private val maxRetryAttempts = 3 + private val maxRetryAttempts = 10 private val initialReconnectDelay = 1000L // 初始重连延迟1秒 private val maxReconnectDelay = 30000L // 最大重连延迟30秒 private var currentReconnectDelay = initialReconnectDelay @@ -45,95 +45,122 @@ class MCPSubClient( private var isConnected = false private var availableTools = mutableListOf() private var transport: CustomSseClientTransport? = null - + /** * 连接到MCP服务器 */ suspend fun connect(): Boolean = connectionMutex.withLock { - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 1") if (isConnected) return true Log.e(TAG, "[$serverId] 开始连接mcp服务器: $serverUrl") - return try { - // 创建MCP客户端实例 - val client = Client( - clientInfo = Implementation( - name = "deep-voice-chat-api", - version = "1.0.0" + + // 重试连接逻辑 + for (attempt in 0 until maxRetryAttempts) { + try { + Log.d(TAG, "[$serverId] 连接尝试 ${attempt + 1}/$maxRetryAttempts") + + // 创建MCP客户端实例 + val client = Client( + clientInfo = Implementation( + name = "deep-voice-chat-api", + version = "1.0.0" + ) ) - ) - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 2") - // 根据URL类型选择传输方式 - val newTransport = when { - serverUrl.startsWith("http://") || serverUrl.startsWith("https://") -> { - // SSE传输 - 使用自定义的CustomSseClientTransport - val mcpHttpClient = httpClient ?: createMcpHttpClient() - CustomSseClientTransport( - client = mcpHttpClient, - urlString = serverUrl, - onConnectionLost = { - // 连接断开回调 - Log.w(TAG, "[$serverId] 检测到连接断开") - scope.launch { - handleConnectionLost() + + // 根据URL类型选择传输方式 + val newTransport = when { + serverUrl.startsWith("http://") || serverUrl.startsWith("https://") -> { + // SSE传输 - 使用自定义的CustomSseClientTransport + val mcpHttpClient = httpClient ?: createMcpHttpClient() + CustomSseClientTransport( + client = mcpHttpClient, + serviceidString = serverId, + urlString = serverUrl, + onConnectionLost = { + // 连接断开回调 + Log.w(TAG, "[$serverId] 检测到连接断开") + scope.launch { + handleConnectionLost() + } } - } - ) - } - else -> { - Log.e(TAG, "[$serverId] 不支持的服务器URL格式: $serverUrl") - return false + ) + } + else -> { + Log.e(TAG, "[$serverId] 不支持的服务器URL格式: $serverUrl") + return false + } } - } - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 3") - transport = newTransport - try { - // 连接到服务器 - withTimeout(10000) { // 10秒超时 - client.connect(newTransport) + + transport = newTransport + + // 连接到服务器 - 增加超时时间 + try { + Log.d(TAG, "[$serverId] 尝试建立连接 ${transport?.urlString}") + withTimeout(30000) { // 30秒超时 + client.connect(newTransport) + } + Log.d(TAG, "[$serverId] 连接建立成功") + } catch (e: TimeoutCancellationException) { + Log.w(TAG, "[$serverId] 连接超时 (尝试 ${attempt + 1}/$maxRetryAttempts)") + if (attempt < maxRetryAttempts - 1) { + delay(currentReconnectDelay) + currentReconnectDelay = (currentReconnectDelay * 2).coerceAtMost(maxReconnectDelay) + continue // 继续下一次重试 + } else { + Log.e(TAG, "[$serverId] 所有连接尝试都超时,连接失败") + return false + } + } catch (e: Exception) { + Log.w(TAG, "[$serverId] 连接异常 (尝试 ${attempt + 1}/$maxRetryAttempts): ${e.message}") + if (attempt < maxRetryAttempts - 1) { + delay(currentReconnectDelay) + currentReconnectDelay = (currentReconnectDelay * 2).coerceAtMost(maxReconnectDelay) + continue // 继续下一次重试 + } else { + Log.e(TAG, "[$serverId] 所有连接尝试都失败: ${e.message}", e) + return false + } } - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 4") - } catch (e: TimeoutCancellationException) { - Log.e(TAG, "liwei---------Mcp [$serverId] 连接超时") - return false - } catch (e: Exception) { - Log.e(TAG, "liwei---------Mcp [$serverId] 连接异常: ${e.message}", e) - return false - } - - // 获取可用工具列表 - try { - val toolsResult = client.listTools() - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 5") - if (toolsResult != null) { - availableTools.clear() - val filtered = toolsResult.tools.filter { tool -> - filterTools.isEmpty() || filterTools.contains(tool.name) + + // 获取可用工具列表 + try { + val toolsResult = client.listTools() + if (toolsResult != null) { + availableTools.clear() + val allToolNames = toolsResult.tools.map { it.name } + Log.d(TAG, "$serverId:所有工具名称列表: $allToolNames") + val filtered = toolsResult.tools.filter { tool -> + filterTools.isEmpty() || filterTools.contains(tool.name) + } + val filteredToolNames = filtered.map { it.name } + Log.d(TAG, "$serverId: 过滤后的工具: $filteredToolNames") + availableTools.addAll(filtered) } - Log.w(TAG, "[$serverId] [${filterTools}] 获取工具列表: ${filtered} 原始列表:${toolsResult.tools}") - availableTools.addAll(filtered) - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 6") + } catch (e: Exception) { + Log.w(TAG, "[$serverId] 获取工具列表失败: ${e.message}") + // 即使获取工具失败,连接也可能是成功的 } + + mcpClient = client + isConnected = true + retryCount = 0 + currentReconnectDelay = initialReconnectDelay + + Log.e(TAG, "[$serverId] 连接mcp服务器成功: $serverUrl") + return true + } catch (e: Exception) { - Log.w(TAG, "[$serverId] 获取工具列表失败: ${e.message}") - // 即使获取工具失败,连接也可能是成功的 + Log.w(TAG, "[$serverId] 连接尝试 ${attempt + 1} 失败: ${e.message}") + if (attempt < maxRetryAttempts - 1) { + delay(currentReconnectDelay) + currentReconnectDelay = (currentReconnectDelay * 2).coerceAtMost(maxReconnectDelay) + } } - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 7") - mcpClient = client - isConnected = true - - retryCount = 0 - currentReconnectDelay = initialReconnectDelay - // Log.e(TAG, "liwei---------Mcp [$serverId] 连接 8") - // 启动心跳检测 -// startHeartbeat() - Log.e(TAG, "[$serverId] 连接mcp服务器成功: $serverUrl") - true - } catch (e: Exception) { - Log.e(TAG, "[$serverId] MCP连接失败: ${e.message}", e) - false } + + Log.e(TAG, "[$serverId] 所有重试尝试都失败,MCP连接失败") + return false } - + /** * 检查是否包含指定工具 */ diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt index 59bea1b2f..bf217f258 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt +++ b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt @@ -185,10 +185,10 @@ object LocationService { Log.d(TAG, "定位结果: 来源=${location.provider}, 精度=${location.accuracy}米, 坐标=(${location.latitude}, ${location.longitude})") if (location.errorCode == AMapLocation.LOCATION_SUCCESS) { val accuracy = location.accuracy - Log.d(TAG, "GPS定位成功: 精度=${accuracy}米") + // Log.d(TAG, "GPS定位成功: 精度=${accuracy}米") // 只接受高精度GPS定位 - if (accuracy <= HIGH_ACCURACY_THRESHOLD) { + // if (accuracy <= HIGH_ACCURACY_THRESHOLD) { Log.d(TAG, "接受高精度GPS定位: ${accuracy}米") // 停止超时计时器 @@ -222,9 +222,9 @@ object LocationService { } // 清空服务回调列表(单次定位) serviceCallbacks.clear() - } else { - Log.w(TAG, "GPS精度不足(${accuracy}米),继续等待更高精度定位") - } + // } else { + // Log.w(TAG, "GPS精度不足(${accuracy}米),继续等待更高精度定位") + // } } else { Log.e(TAG, "GPS定位失败: ${location.errorCode} - ${location.errorInfo}") }