diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4cde8e5c9..1087023b6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -102,6 +102,47 @@ NSAllowsArbitraryLoadsInWebContent + + NSExceptionDomains + + music.126.net + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.0 + NSIncludesSubdomains + + + p1.music.126.net + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.0 + + p2.music.126.net + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.0 + + p3.music.126.net + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.0 + + p4.music.126.net + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.0 + + NSBluetoothAlwaysUsageDescription Bluetooth permission is required to connect and manage audio devices diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 4a8b34c2d..1a74af25c 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -43,8 +43,10 @@ abstract class AsrService { /// 开始录音 /// /// [filePath] 录音文件路径 + /// [audioSourceType] 音频源类型 /// [acceptAudioData] 是否接受音频数据回调,默认为 false - Future enableRecord(String filePath, {bool acceptAudioData = false}); + Future enableRecord(bool audioSourceType, String filePath, + {bool acceptAudioData = false}); /// 获取音频数据流(如果支持) Stream? getAudioDataStream() => null; diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 954c2b61c..4d7ce9fcb 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -857,6 +857,16 @@ class BleManager extends GetxService { } } + /// 打开解码器 + Future openCallRecordDecoder() async { + try { + return await _bleService.openCallRecordDecoder(); + } catch (e) { + Logger.error('打开解码器失败: ${e.toString()}'); + return false; + } + } + /// 关闭编解码器 Future closeCodec() async { try { diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 9c46e528e..eb003736a 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -465,10 +465,11 @@ class AzureAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String filePath, + Future enableRecord(bool audioSourceType, String filePath, {bool acceptAudioData = false}) async { try { final bool result = await _channel.invokeMethod('enableRecord', { + 'audioSourceType': audioSourceType, 'filePath': filePath, 'acceptAudioData': acceptAudioData, // 新增参数 }); 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 87e0863d8..80714696f 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,8 @@ class VolcanoAsrApiService implements AsrService { } @override - Future enableRecord(String filePath, {bool acceptAudioData = false}) { + Future enableRecord(bool audioSourceType, String filePath, + {bool acceptAudioData = false}) { // TODO: implement enableRecord 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 6d090d642..3ed457c3b 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -348,7 +348,8 @@ class VolcanoAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String filePath, {bool acceptAudioData = false}) { + Future enableRecord(bool audioSourceType, String filePath, + {bool acceptAudioData = false}) { // TODO: implement enableRecord 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 29daf7015..9dda44c97 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -272,7 +272,8 @@ class XunfeiAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String filePath, {bool acceptAudioData = false}) { + Future enableRecord(bool audioSourceType, String filePath, + {bool acceptAudioData = false}) { // TODO: implement enableRecord throw UnimplementedError(); } diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 4fabc7cc6..78fa49473 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -498,7 +498,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { switch (event.type) { case AgentServiceEventType.recognitionStarted: isListening.value = true; - isTextInputMode.value = false; + //isTextInputMode.value = false; Logger.i(TAG, '订阅代理服务事件识别开始'); // isTextInputMode.refresh(); // 只标记识别开始,不添加消息 @@ -937,7 +937,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 注意:这里不需要调用startVoiceInput(),因为原生层已经启动了语音识别 // 我们只需要同步Flutter层的状态 - isTextInputMode.value = false; + //isTextInputMode.value = false; Logger.i(TAG, 'BLE唤醒状态已设置,等待原生层语音识别结果'); } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 96bc62d21..792618710 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -70,7 +70,7 @@ class MeetingRecordController extends GetxController // 性能优化:音频处理采样控制 int _audioProcessCounter = 0; - static const int _audioProcessInterval = 3; // 每3个音频包处理1次 + static const int _audioProcessInterval = 1; // 每1个音频包处理1次 // 波形流动动画控制器 late AnimationController waveformAnimationController; @@ -306,9 +306,13 @@ class MeetingRecordController extends GetxController final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final fullFileName = "${fileName.value}_$formattedTime"; - // Start audio recording - await _asrService.enableRecord("${dir.path}$fullFileName.wav", - acceptAudioData: true); + if (fileName.value == "liveRecording".tr) { + await _asrService.enableRecord(false, "${dir.path}$fullFileName.wav", + acceptAudioData: true); + } else { + await _asrService.enableRecord(true, "${dir.path}$fullFileName.wav", + acceptAudioData: true); + } // 通过 asrService 获取音频数据流 _audioDataSubscription = @@ -350,7 +354,7 @@ class MeetingRecordController extends GetxController break; case 2: _asrService.setAudioConfig(sampleRate: 16000, channels: 2); - _bleManager.openA2DPDecoder(); + _bleManager.openCallRecordDecoder(); break; } } diff --git a/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart b/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart index 150c3549f..7f594cdc6 100644 --- a/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart +++ b/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart @@ -35,38 +35,38 @@ class NavigationBarBottomSheet extends GetView { controller.addMeetingAudio(); }, ), - // if (controller.meetingController.type == 1) - // _buildItem( - // context, // 传递 context 参数 - // 'audioVideoRecording'.tr, // 对应中文:音、视频录音 - // Icons.videocam, - // Colors.green, - // onTap: controller.bleManager.isConnected - // ? () async { - // Get.back(); - // await Get.toNamed(Routes.meetingRecord, arguments: { - // 'audioTypes': 1, - // }); - // controller.addMeetingAudio(); - // } - // : null, - // ), - // if (controller.meetingController.type == 1) - // _buildItem( - // context, // 传递 context 参数 - // 'callRecording'.tr, // 对应中文:通话录音 - // Icons.phone, - // Colors.orange, - // onTap: controller.bleManager.isConnected - // ? () async { - // Get.back(); - // await Get.toNamed(Routes.meetingRecord, arguments: { - // 'audioTypes': 2, - // }); - // controller.addMeetingAudio(); - // } - // : null, - // ), + if (controller.meetingController.type == 1) + _buildItem( + context, // 传递 context 参数 + 'audioVideoRecording'.tr, // 对应中文:音、视频录音 + Icons.videocam, + Colors.green, + onTap: controller.bleManager.isConnected + ? () async { + Get.back(); + await Get.toNamed(Routes.meetingRecord, arguments: { + 'audioTypes': 1, + }); + controller.addMeetingAudio(); + } + : null, + ), + if (controller.meetingController.type == 1) + _buildItem( + context, // 传递 context 参数 + 'callRecording'.tr, // 对应中文:通话录音 + Icons.phone, + Colors.orange, + onTap: controller.bleManager.isConnected + ? () async { + Get.back(); + await Get.toNamed(Routes.meetingRecord, arguments: { + 'audioTypes': 2, + }); + controller.addMeetingAudio(); + } + : null, + ), _buildItem( context, // 传递 context 参数 'importAudio'.tr, // 对应中文:导入音频 diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 97d54cb09..f20d097ac 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -74,7 +74,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { final isTranslating = false.obs; final isTtsEnabled = true.obs; final isRecording = false.obs; - final lasyIsRecording = false.obs; + final isCreateRecord = false.obs; final hasRecordPermission = false.obs; // ==================== 语言相关 ==================== @@ -128,6 +128,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (mode == 'call') { await _initializeCallModeTranslationService(); + } else { + _reinitializeAsrService(); } } @@ -186,8 +188,16 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// [speakerId] 说话者ID Future _startFaceToFaceRecognition(int speakerId) async { try { - await _asrService.startContinuousRecognition(_audioSourceType); - await continueRecording(); + await _asrService.startContinuousRecognition(_audioSourceType); //开启识别 + _startAsrActiveTracking(); //开启识别活动跟踪 + if (isRecording.value) { + if (isCreateRecord.value) { + //已经创建文件 + await continueRecording(); + } else { + await startRecording(); + } + } Logger.info('面对面翻译:激活说话者 $speakerId'); } catch (e) { Logger.error('启动面对面翻译失败: ${e.toString()}'); @@ -198,13 +208,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 停止面对面翻译的语音识别 Future _stopFaceToFaceRecognition() async { try { - await pauseRecording(); //暂停 + if (isRecording.value) { + await pauseRecording(); //暂停 + } if (faceToFaceRecognitionText.value.isNotEmpty) { handleFinalResult(faceToFaceRecognitionText.value); faceToFaceRecognitionText.value = ''; } await _asrService.stopContinuousRecognition(); - _stopAsrActiveTracking(); + _stopAsrActiveTracking(); //停止识别活动跟踪 isRecognizing.value = false; currentSessionId = null; @@ -444,6 +456,19 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Future _initializeCallModeTranslationService() async { try { Logger.info('开始初始化通话模式语音翻译服务'); + + // 初始化 ASR 服务时,明确指定需要支持的语言 + final List asrSupportedLanguages = [targetLanguageCode.value]; + Logger.info('初始化ASR服务,支持语言: $asrSupportedLanguages'); + + await _asrService.initialize(supportedLanguages: asrSupportedLanguages); + + // 获取识别事件流 + var recognitionStream = await _asrService.recognizeCallback(); + _recognitionSubscription?.cancel(); + _recognitionSubscription = + recognitionStream.listen(_handleRecognitionEvent); + final List callModeLanguages = [ sourceLanguageCode.value, targetLanguageCode.value @@ -498,7 +523,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 切换录音功能开关 Future toggleisRecord() async { isRecording.toggle(); - if (isRecording.value && isRecognizing.value) { + if (isRecording.value && + isRecognizing.value && + currentMode.value != 'faceToFace') { startRecording(); } else if (!isRecording.value) { stopRecording(); @@ -508,21 +535,27 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 开始录音 Future startRecording() async { _timerManager.startTimer(); - final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - await _asrService.enableRecord( - "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav"); - if (currentMode.value == 'call') { - await _astService.enableRecord( - "${dir.path}/${currentModeTitle.value.tr}_${formattedTime}_mic.wav"); + if (currentMode.value == 'call' || currentMode.value == 'audioVideo') { + await _asrService.enableRecord( + true, "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav"); + } else { + await _asrService.enableRecord( + false, "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav"); } + + // if (currentMode.value == 'call') { + // await _astService.enableRecord( + // "${dir.path}/${currentModeTitle.value.tr}_${formattedTime}_mic.wav"); + // } + isCreateRecord.value = true; } /// 停止录音 Future stopRecording() async { _timerManager.stopTimer(); _asrService.stopRecord(true); - lasyIsRecording.value = false; + isCreateRecord.value = false; Logger.info('录音已停止,计时器已重置'); } @@ -530,7 +563,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Future pauseRecording() async { _timerManager.pauseTimer(); _asrService.pauseRecord(); - lasyIsRecording.value = false; + Logger.info(' 录音已暂停'); } @@ -538,7 +571,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Future continueRecording() async { _timerManager.resumeTimer(); _asrService.resumeRecord(); - lasyIsRecording.value = true; + Logger.info(' 录音已继续'); } @@ -552,8 +585,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { try { await _initializeSession(); await _configureAudioMode(); - await _startAsrService(); - await _finalizeRecognitionStart(); + if (currentMode.value != 'faceToFace') { + await _startAsrService(); + await _finalizeRecognitionStart(); + } } catch (e) { isRecognizing.value = false; Logger.error('启动语音识别失败: ${e.toString()}'); @@ -622,9 +657,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 启动语音识别 Future _startAsrService() async { - if (currentMode.value != 'faceToFace') { - await _asrService.startContinuousRecognition(_audioSourceType); - } + await _asrService.startContinuousRecognition(_audioSourceType); } /// 完成识别启动 diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 58a5d33c9..a27210718 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -1087,19 +1087,19 @@ class TranslationView extends GetView { await controller.startRecognition(); }, ), - // _buildModeItem( - // context, - // 'audioVideoTranslation'.tr, - // Icons.videocam, - // Colors.orange, - // onTap: controller.bleManager.isConnected - // ? () async { - // Get.back(); - // await controller.changeTranslationMode('audioVideo'); - // await controller.startRecognition(); - // } - // : null, - // ), + _buildModeItem( + context, + 'audioVideoTranslation'.tr, + Icons.videocam, + Colors.orange, + onTap: controller.bleManager.isConnected + ? () async { + Get.back(); + await controller.changeTranslationMode('audioVideo'); + await controller.startRecognition(); + } + : null, + ), _buildModeItem( context, 'callTranslation'.tr, 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 73a2e51c9..97d4df5f2 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 @@ -130,13 +130,10 @@ Log.d(TAG, "手动启动语音识别: ") } } - override fun onAudioDataReceived(data: ByteArray) { + override fun onAudioDataReceived(data: ByteArray, channel: Int) { // Log.d(TAG, "onAudioDataReceived, data: ${data.size}") AgentService.pushAudioData(data) // 可选:处理音频数据 - } - override fun onAudioDataReceivedCall(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 fccbf1bf4..c1b27f252 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 @@ -917,19 +917,19 @@ private func jsonToString(_ json: [String: Any]) -> String? { // 处理音乐卡片 private func processMusicCard(_ cardMusic: [String: Any]) { let id = cardMusic["id"] as? Int ?? 0 - let url = cardMusic["url"] as? String ?? "" +// let url = cardMusic["url"] as? String ?? "" let title = cardMusic["name"] as? String ?? "" let artist = cardMusic["sgener"] as? String ?? "" let coverUrl = cardMusic["image"] as? String ?? "" - let ts = cardMusic["ts"] as? Int ?? 0 +// let ts = cardMusic["ts"] as? Int ?? 0 processMusicPlay([ "id": id, - "url": url, +// "url": url, "title": title, "artist": artist, "coverUrl": coverUrl, - "ts": ts +// "ts": ts ]) } @@ -939,19 +939,19 @@ private func jsonToString(_ json: [String: Any]) -> String? { for item in musics { let id = item["id"] as? Int ?? 0 - let url = item["url"] as? String ?? "" +// let url = item["url"] as? String ?? "" let title = item["name"] as? String ?? "" let artist = item["sgener"] as? String ?? "" let coverUrl = item["image"] as? String ?? "" - let ts = item["ts"] as? Int ?? 0 +// let ts = item["ts"] as? Int ?? 0 playlist.append([ "id": id, - "url": url, +// "url": url, "title": title, "artist": artist, "coverUrl": coverUrl, - "ts": ts +// "ts": ts ]) } @@ -1507,13 +1507,10 @@ extension AgentServiceImpl: BleService.Callback { func onConnectionStateChanged(state: Int) { } - func onAudioDataReceived(data: Data) { + func onAudioDataReceived(data: Data, channel: Int32) { pushAudioData(data) } - - func onAudioDataReceivedCall(data: Data) { - } func onWakeupSignalReceived() { stopTts() 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 8f7d5b571..fbc8bcf4d 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 @@ -31,9 +31,6 @@ class AzureAsrHelper(private val context: Context) { private var audioConfig: AudioConfig? = null private var continuousCallback: ContinuousRecognizeCallback? = null - // 状态管理 - private var isContinuousRecognitionActive = false - // 配置参数 private var currentLanguage = "zh-CN" private var supportedLanguages = arrayOf("zh-CN") @@ -44,8 +41,8 @@ class AzureAsrHelper(private val context: Context) { // 音频源配置 var audioSourceType = AudioSourceType.MICROPHONE - // 音频处理 - var audioStream: SimpleAudioReceiver? = null + // 音频处理 - Initialize immediately to avoid null checks + var audioStream: SimpleAudioReceiver = SimpleAudioReceiver(context) // 网络状态监听 private var networkMonitor: NetworkStateMonitor = NetworkStateMonitor(context) @@ -181,10 +178,10 @@ class AzureAsrHelper(private val context: Context) { private fun setupRecognizer(): Boolean { try { // 如果正在进行连续识别,先停止 - if (isContinuousRecognitionActive) { + if (audioStream.isContinuousRecognitionActive) { // 直接停止,不等待结果 recognizer?.stopContinuousRecognitionAsync() - isContinuousRecognitionActive = false + audioStream.isContinuousRecognitionActive = false } setupMicrophoneStream() @@ -214,18 +211,15 @@ class AzureAsrHelper(private val context: Context) { try { Log.d(tag, "设置麦克风流 - 使用拉流方式: }") - if (audioStream == null) { - // 创建外部音频拉流对象 - audioStream = SimpleAudioReceiver(context) - audioStream!!.initAudioRecord() + // Initialize if not already done + if (!audioStream.isInitialized()) { + audioStream.initAudioRecord() } - audioConfig = AudioConfig.fromStreamInput(audioStream!!.pushAudioStream) - + audioConfig = AudioConfig.fromStreamInput(audioStream.pushAudioStream) } catch (e: Exception) { Log.e(tag, "设置麦克风流失败: ${e.message}") - //microphoneStream = null } } @@ -241,7 +235,7 @@ class AzureAsrHelper(private val context: Context) { audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = null ): Boolean { - Log.d(tag, "startContinuousRecognition:$isContinuousRecognitionActive ") + Log.d(tag, "startContinuousRecognition:${audioStream.isContinuousRecognitionActive} ") // 检查网络状态 if (!checkNetworkStatus()) { @@ -250,7 +244,7 @@ class AzureAsrHelper(private val context: Context) { return false } - if (isContinuousRecognitionActive) { + if (audioStream.isContinuousRecognitionActive) { return true } if (!isRecognizerValid()) { @@ -266,13 +260,12 @@ class AzureAsrHelper(private val context: Context) { this.audioSourceType = audioSourceType try { - Log.d(tag, "startContinuousRecognition: ${audioStream!!.isWriting()}") + Log.d(tag, "startContinuousRecognition: ${audioStream.isWriting()}") // 启动音频处理(若已启动则跳过) - if (!audioStream!!.isWriting()) { + if (!audioStream.isContinuousRecognitionActive) { Log.d(tag, "startAudioRecord:$audioSourceType") - audioStream!!.startAudioRecord( + audioStream.startAudioRecord( when (audioSourceType) { - AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL }, @@ -283,12 +276,12 @@ class AzureAsrHelper(private val context: Context) { } recognizer?.startContinuousRecognitionAsync() - isContinuousRecognitionActive = true + audioStream.isContinuousRecognitionActive = true return true } catch (e: Exception) { Log.e(tag, "开始连续识别失败: ${e.message}") - stopContinuousRecognition() - isContinuousRecognitionActive = false + stopContinuousRecognition() + audioStream.isContinuousRecognitionActive = false return false } } @@ -297,7 +290,7 @@ class AzureAsrHelper(private val context: Context) { * 设置事件监听器 */ fun setupEventListeners(callback: ContinuousRecognizeCallback): Boolean { - Log.d(tag, "设置ssssss监听器:${speechConfig} ") + Log.d(tag, "设置ssssss监听器:${speechConfig ?: "null"} ") if (speechConfig == null) { callback.onError(1002,"语音服务未初始化") return false @@ -357,7 +350,7 @@ class AzureAsrHelper(private val context: Context) { Log.d(tag, "会话结束事件") if (audioSourceType == AudioSourceType.EXTERNAL) { callback.onSessionStopped() - isContinuousRecognitionActive = false + audioStream?.isContinuousRecognitionActive = false } } ) @@ -392,26 +385,25 @@ class AzureAsrHelper(private val context: Context) { } try { Log.d(tag, "停止连续语音识别: ") - // 停止音频处理 - audioStream!!.stopMicrophoneCapture() + // 停止音频处理 + audioStream.stopMicrophoneCapture() // 直接停止连续识别(SDK内部已是异步操作) recognizer?.stopContinuousRecognitionAsync()?.get(1000, TimeUnit.MILLISECONDS) recognizer?.close() recognizer = null - isContinuousRecognitionActive = false + audioStream.isContinuousRecognitionActive = false return true } catch (e: Exception) { // 强制重置状态 - isContinuousRecognitionActive = false + audioStream.isContinuousRecognitionActive = false Log.e(tag, "停止连续识别失败: ${e.message}") - // 停止音频处理 - audioStream!!.stopMicrophoneCapture() + // 停止音频处理 + audioStream.stopMicrophoneCapture() recognizer?.stopContinuousRecognitionAsync() - - recognizer?.close() + recognizer?.close() recognizer = null - isContinuousRecognitionActive = false + audioStream.isContinuousRecognitionActive = false return false } } @@ -419,7 +411,7 @@ class AzureAsrHelper(private val context: Context) { /** * 检查连续识别是否活跃 */ - fun isContinuousRecognitionActive(): Boolean = isContinuousRecognitionActive + fun isContinuousRecognitionActive(): Boolean = audioStream.isContinuousRecognitionActive /** * 释放所有资源 @@ -427,19 +419,18 @@ class AzureAsrHelper(private val context: Context) { fun dispose() { try { // 如果正在进行连续识别,先停止 - if (isContinuousRecognitionActive) { + if (audioStream.isContinuousRecognitionActive) { // 直接停止,不等待结果 recognizer?.stopContinuousRecognitionAsync() - isContinuousRecognitionActive = false + audioStream.isContinuousRecognitionActive = false } // 停止音频处理 stopAudioProcessing() // 清理网络监听 clearNetworkDetection() // 停止录音 - if (audioStream!!.recordfile != null) { - audioStream!!.recordfile!!.closeFile(true) - } + audioStream.recordfile?.closeFile(true) + // 释放recognizer recognizer?.close() recognizer = null @@ -452,11 +443,11 @@ class AzureAsrHelper(private val context: Context) { audioConfig?.close() audioConfig = null // 确保状态被重置 - isContinuousRecognitionActive = false + audioStream.isContinuousRecognitionActive = false } catch (e: Exception) { // 确保状态被重置 - isContinuousRecognitionActive = false + audioStream.isContinuousRecognitionActive = false audioConfig = null recognizer = null speechConfig = null @@ -468,20 +459,14 @@ class AzureAsrHelper(private val context: Context) { * 停止音频处理 */ private fun stopAudioProcessing() { - audioStream?.let { - try { - Log.e(tag, "停止音频处理: }") - it.releaseAudioResources() - - it.pushAudioStream?.close() - - audioStream = null - } catch (e: Exception) { - Log.e(tag, "关闭麦克风流失败: ${e.message}") - e.printStackTrace() - } - } // 关闭音频流(根据实际实现可能需要) - + try { + Log.e(tag, "停止音频处理: }") + audioStream.releaseAudioResources() + audioStream.pushAudioStream?.close() + } catch (e: Exception) { + Log.e(tag, "关闭麦克风流失败: ${e.message}") + e.printStackTrace() + } } @@ -492,47 +477,61 @@ class AzureAsrHelper(private val context: Context) { /** * 开启录音 */ - fun enableRecord(filePath: String, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = null) { + fun enableRecord(audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE,filePath: String, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = null) { Log.i(tag, "开启录音:") - if (audioStream == null) { - // 创建外部音频拉流对象 - audioStream = SimpleAudioReceiver(context) - audioStream!!.initAudioRecord() - } - if (audioStream!!.recordfile == null) { - audioStream!!.recordfile = RecordFile() + this.audioSourceType = audioSourceType + if(audioSourceType == AudioSourceType.EXTERNAL){ + Log.i(tag, "外部音频源不在这里录音") + return } - - // 启动音频处理 - audioStream!!.startAudioRecord(SimpleAudioReceiver.AudioSourceType.MICROPHONE - - ,audioDataCallback) - audioStream!!.recordfile!!.closeFile(true) - - - audioStream!!.recordfile!!.creatingFiles(filePath) - + // Initialize if not already done + if (!audioStream.isInitialized()) { + audioStream.initAudioRecord() + } + + if (audioStream.recordfile == null) { + audioStream.recordfile = RecordFile() + } + + if (!audioStream.isContinuousRecognitionActive) { + // 启动音频处理 + Log.i(tag, "开启录音:audioSourceType${audioSourceType}") + audioStream.startAudioRecord( when (audioSourceType) { + AudioSourceType.MICROPHONE -> SimpleAudioReceiver.AudioSourceType.MICROPHONE + AudioSourceType.EXTERNAL -> SimpleAudioReceiver.AudioSourceType.EXTERNAL + }, + audioDataCallback + ) + } + + audioStream.isRecord = true + audioStream.recordfile?.closeFile(true) + audioStream.recordfile?.creatingFiles(filePath) } /** * 移动文件到新路径 */ fun moveFile(sourcePath: String, destPath: String): Boolean { + if(audioSourceType == AudioSourceType.EXTERNAL){ + Log.i(tag, "外部音频源不在这里移动文件") + return false + } Log.i(tag, "移动文件到新路径:") - if (audioStream!!.recordfile == null)return false - audioStream!!.recordfile!!.moveFile(sourcePath, destPath) + audioStream.recordfile?.moveFile(sourcePath, destPath) ?: return false return true } - /** * 重命名指定路径的音频文件 */ fun renameFile(filePath: String, newName: String): Boolean { + if(audioSourceType == AudioSourceType.EXTERNAL){ + Log.i(tag, "外部音频源不在这里重命名文件") + return false + } Log.i(tag, "重命名指定路径的音频文件:") - - if (audioStream!!.recordfile == null)return false - audioStream!!.recordfile!!.renameFile(filePath, newName) + audioStream.recordfile?.renameFile(filePath, newName) ?: return false return true } @@ -540,35 +539,45 @@ class AzureAsrHelper(private val context: Context) { * 停止录音 */ fun pauseRecord() { + if(audioSourceType == AudioSourceType.EXTERNAL){ + Log.i(tag, "外部音频源不在这里暂停录音") + return + } Log.i(tag, "停止连续录音:") - if (audioStream!!.recordfile == null)return - - audioStream!!.stopMicrophoneCapture() - - + audioStream.recordfile ?: return + audioStream.isRecord = false } /** * 继续录音 */ fun resumeRecord() { - Log.i(tag, "继续录音:") - if (audioStream!!.recordfile == null)return - - audioStream!!.resumeRecord() + if(audioSourceType == AudioSourceType.EXTERNAL){ + Log.i(tag, "外部音频源不在这里继续录音") + return + } + Log.i(tag, "继续录音:") + audioStream.recordfile ?: return + audioStream.isRecord = true } /** * 关闭录音 */ fun stopRecord(isSave: Boolean) { - Log.i(tag, "关闭录音:") - if (audioStream == null) { + if(audioSourceType == AudioSourceType.EXTERNAL){ + Log.i(tag, "外部音频源不在这里关闭录音") return } - if (audioStream!!.recordfile == null)return - audioStream!!.stopMicrophoneCapture() - audioStream!!.recordfile!!.closeFile(isSave) + Log.i(tag, "关闭录音:") + audioStream.recordfile ?: return + + if (!audioStream.isContinuousRecognitionActive) { + audioStream.stopMicrophoneCapture() + } + + audioStream.isRecord = false + audioStream.recordfile?.closeFile(isSave) } 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 2341bfb48..6348732c2 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 @@ -19,7 +19,7 @@ import com.deep_voice.speech.tts.TtsEventType import com.yunqiinnovation.ble_service.BleService import com.deep_voice.speech.tts.AudioOutputDevice import kotlinx.coroutines.* - +import com.yunqiinnovation.azure_speech.tools.RecordFile /** AzureSpeechPlugin */ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { private val tag = "AzureSpeechPlugin" @@ -49,7 +49,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { private lateinit var audioDataEventChannel: EventChannel private var audioDataEventSink: EventChannel.EventSink? = null - + private var recordfile: RecordFile? = null + private var isRecord = false // 是否已添加TTS事件监听器 private var isTtsListenerAdded = false @@ -446,7 +447,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { val newName = call.argument("newName") ?: "" try { FileLogger.d(tag, "音频文件名称为: ${filePath}") // - azureAsrHelper.renameFile(filePath, newName) + if(azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL){ + isRecord = false + recordfile?.renameFile(filePath, newName) + }else{ + azureAsrHelper.renameFile(filePath, newName) + } result.success(true) } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) @@ -458,8 +464,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { val destPath = call.argument("destPath") ?: "" try { FileLogger.d(tag, "音频文件名称为: ${sourcePath}") // - - azureAsrHelper.moveFile(sourcePath, destPath) + if(azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL){ + isRecord = false + recordfile?.moveFile(sourcePath, destPath) + }else{ + azureAsrHelper.moveFile(sourcePath, destPath) + } result.success(true) } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) @@ -470,11 +480,28 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { val filePath = call.argument("filePath") ?: "" // 是否接受音频数据 val acceptAudioData = call.argument("acceptAudioData") ?: false + val useExternalAudio = call.argument("audioSourceType") ?: false + + try { + + FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // + // 选择音频源类型 + val audioSourceType = if (useExternalAudio) { + if(recordfile == null){ + recordfile = RecordFile() + } + recordfile?.closeFile(true) + recordfile?.creatingFiles(filePath) + isRecord = true + AzureAsrHelper.AudioSourceType.EXTERNAL + } else { + AzureAsrHelper.AudioSourceType.MICROPHONE + // 启用录音,并根据 acceptAudioData 参数决定是否设置音频数据回调 + } FileLogger.d(tag, "音频文件名称为: ${filePath}") - // 启用录音,并根据 acceptAudioData 参数决定是否设置音频数据回调 - azureAsrHelper.enableRecord(filePath, if(acceptAudioData) { + azureAsrHelper.enableRecord(audioSourceType, filePath, if(acceptAudioData) { // 创建音频数据回调,将音频数据发送到 Flutter 层 object : SimpleAudioReceiver.AudioDataCallback { override fun onAudio(audioData: ByteArray) { @@ -499,18 +526,36 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } "pauseRecord" -> { + if(azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL){ + isRecord = false + }else{ + azureAsrHelper.pauseRecord() + + azureAsrHelper.pauseRecord() + } result.success(true) } "resumeRecord" -> { - azureAsrHelper.resumeRecord() + if(azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL){ + isRecord = true + }else{ + azureAsrHelper.resumeRecord() + } result.success(true) } "stopRecord" -> { val isSave = call.argument("isSave") ?: false try { - azureAsrHelper.stopRecord(isSave) + if(azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL){ + isRecord = false + recordfile?.closeFile(true) + } + else + { + azureAsrHelper.stopRecord(isSave) + } result.success(true) } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) @@ -521,7 +566,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { val sampleRate = call.argument("sampleRate") ?: 16000 val channels = call.argument("channels") ?: 1 try { - azureAsrHelper.audioStream?.recordfile?.setAudioConfig(sampleRate, channels) + // azureAsrHelper.audioStream?.recordfile?.setAudioConfig(sampleRate, channels) result.success(true) } catch (e: Exception) { result.error("SET_AUDIO_CONFIG_ERROR", e.message, null) @@ -688,17 +733,17 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { when (call.method) { - "enableRecord" -> { - val filePath = call.argument("filePath") ?: "" - try { - FileLogger.d(tag, "音频文件名称为: ${filePath}") // + // "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) - } - } + // //azureAstHelper.enableRecord(filePath) + // result.success(true) + // } catch (e: Exception) { + // result.error("ENABLERECORD_ERROR", e.message, null) + // } + // } "startContinuousTranslation" -> { try { FileLogger.d(tag, "开启翻译") @@ -996,20 +1041,70 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { override fun onConnectionStateChanged(state: Int) { } - override fun onAudioDataReceived(data: ByteArray) { - if (azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL) { + override fun onAudioDataReceived(data: ByteArray,channel:Int) { + if (channel == 0) { + return + } + else if (channel == 1) { + if (isRecord) { + recordfile?.setAudioConfig(16000,1) + recordfile?.saveAudioDataToWav(data) + // 构建音频数据事件映射 + val audioEvent = mapOf( + "type" to "audioData", + "data" to data, + "timestamp" to System.currentTimeMillis(), + "size" to data.size + ) + // 发送音频数据事件到 Flutter 层 + sendAudioDataEvent(audioEvent) + } + if (azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL) { azureAsrHelper.audioStream?.saveAudioDataTo(data) } - // 可选:处理音频数据 - } - - override fun onAudioDataReceivedCall(data: ByteArray) { + } + else if (channel == 2) { + if (isRecord) { + recordfile?.setAudioConfig(16000,2) + recordfile?.saveAudioDataToWav(data) + // 构建音频数据事件映射 + val audioEvent = mapOf( + "type" to "audioData", + "data" to data, + "timestamp" to System.currentTimeMillis(), + "size" to data.size + ) + // 发送音频数据事件到 Flutter 层 + sendAudioDataEvent(audioEvent) + } + 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] + } - // FileLogger.d(tag, "onAudioDataReceivedCall: ${data.size}") - azureAstHelper.pushAudioData(data) - + + if (azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL) { + azureAsrHelper.audioStream?.saveAudioDataTo(rightBuffer) + } + azureAstHelper?.pushAudioData(leftBuffer) + } + // 可选:处理音频数据 } + /** * 处理唤醒信号 * 在收到唤醒信号时启动语音识别 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 66a24e058..35e356892 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 @@ -117,10 +117,10 @@ class RecordFile { fos = FileOutputStream(currentAudioFile, true) startWriteThread() - // 如果是双声道,创建左右声道文件 - if (channels == 2) { - createStereoChannelFiles(filePath) - } + // // 如果是双声道,创建左右声道文件 + // if (channels == 2) { + // createStereoChannelFiles(filePath) + // } } /** @@ -242,14 +242,14 @@ class RecordFile { internal fun saveAudioDataToWav(buffer: ByteArray) { if (fos == null || currentAudioFile == null) return - if (channels == 2) { - // 双声道:拆分左右声道 - splitStereoToMono(buffer) - } else { + // if (channels == 2) { + // // 双声道:拆分左右声道 + // splitStereoToMono(buffer) + // } else { // 单声道:直接保存 writeQueue.offer(buffer.copyOf()) totalBytesWritten += buffer.size - } + // } } /** @@ -454,21 +454,22 @@ class RecordFile { } } - // 处理左右声道文件(如果是双声道) - if (channels == 2) { - leftChannelSuccess = closeStereoChannelFile(leftChannelFile, leftChannelFos, leftChannelBytesWritten, "左声道", isSave) - rightChannelSuccess = closeStereoChannelFile(rightChannelFile, rightChannelFos, rightChannelBytesWritten, "右声道", isSave) + // // 处理左右声道文件(如果是双声道) + // 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 - } + // // 等待左右声道写入线程结束 + // leftChannelWriteThread?.join(500) + // rightChannelWriteThread?.join(500) + // } + + // return if (channels == 2) { + // leftChannelSuccess && rightChannelSuccess + // } else { + // mainFileSuccess + // } + return mainFileSuccess } catch (e: Exception) { Log.e("tag", "更新WAV文件头失败: ${e.message}") return false 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 bec452ee3..51a40aef4 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 @@ -49,7 +49,7 @@ class SimpleAudioReceiver(private val context: Context) { private val isRunning = AtomicBoolean(false)// 控制线程是否继续存在 private val isWriting = AtomicBoolean(false) // 控制是否应该写入数据 private var writeThread: Thread? = null - + private var isInitialized = false // 音频配置 private val channelConfig = AudioFormat.CHANNEL_IN_MONO private val audioFormat = AudioFormat.ENCODING_PCM_16BIT @@ -59,6 +59,8 @@ class SimpleAudioReceiver(private val context: Context) { // 录音文件处理 var recordfile: RecordFile? = null + var isRecord =false + var isContinuousRecognitionActive = false /** * 获取最优音频格式 */ @@ -94,8 +96,9 @@ class SimpleAudioReceiver(private val context: Context) { // 初始化音频管理器 audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL + isInitialized = true } - + fun isInitialized(): Boolean = isInitialized /** * 启动写入线程 */ @@ -140,14 +143,14 @@ class SimpleAudioReceiver(private val context: Context) { if (bytesToWrite < data.size) data.copyOf(bytesToWrite) else data try { - // Log.d(TAG, "写入数据大小: ${finalData.size}") + Log.d(TAG, "写入数据大小: ${finalData.size},isContinuousRecognitionActive:${isContinuousRecognitionActive},isRecord${isRecord}") if(audioDataCallback!=null){ audioDataCallback?.onAudio(finalData) } - if(pushAudioStream!=null&&audioDataCallback==null){ + if(pushAudioStream!=null&&isContinuousRecognitionActive){ pushAudioStream?.write(finalData) } - if(recordfile!=null){ + if(recordfile!=null&&isRecord){ recordfile?.saveAudioDataToWav(finalData) } @@ -342,7 +345,23 @@ class SimpleAudioReceiver(private val context: Context) { Log.e(TAG, "继续录音失败: ${e.message}") } } - +/** + * 暂停麦克风捕获 + */ + fun pauseMicrophoneCapture() { + try { + isWriting.set(false) + + // 停止录音 + audioRecord?.stop() + + // 恢复音频模式 这里会导致关闭麦克风卡住待优化 + //restoreCommunicationAudioMode() + + } catch (e: Exception) { + Log.e(TAG, "暂停麦克风捕获失败: ${e.message}") + } + } /** * 停止麦克风捕获 */ @@ -376,6 +395,7 @@ class SimpleAudioReceiver(private val context: Context) { // 释放录音实例 audioRecord?.release() audioRecord = null + isInitialized = false } catch (e: Exception) { Log.e(TAG, "释放音频资源失败: ${e.message}") e.printStackTrace() 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 0fe8d7891..9f60f9218 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 @@ -39,7 +39,7 @@ public class AzureAsrHelper: NSObject { // 防抖机制相关变量 - 只保留开始操作的防抖 private var lastAudioStartTime: Int64 = 0 - private let audioOperationDebounceInterval: Int64 = 500 // 500毫秒防抖间隔 + private let audioOperationDebounceInterval: Int64 = 0 // 500毫秒防抖间隔 private var audioStartWorkItem: DispatchWorkItem? // 串行队列和状态标记,用于顺序控制音频启动/停止,避免竞态 private let audioOpQueue = DispatchQueue(label: "com.azure.speech.audio.operations") @@ -47,8 +47,7 @@ public class AzureAsrHelper: NSObject { private var isAudioStarted = false private var pendingStopRequest = false - // 状态管理 - private var _isContinuousRecognitionActive = false + // 配置参数 private var currentLanguage = "zh-CN" @@ -66,7 +65,7 @@ public class AzureAsrHelper: NSObject { case external } - private var audioSourceType = AudioSourceType.microphone + public var audioSourceType = AudioSourceType.microphone // 音频处理 // private var externalAudioStream: ExternalAudioPullStream? @@ -240,7 +239,7 @@ public class AzureAsrHelper: NSObject { /// /// 修复点: /// 1) 新任务创建前,取消旧的未执行任务,确保只有“最后一次启动请求”会生效 - /// 2) 任务执行前二次校验:必须是当前挂起任务且 _isContinuousRecognitionActive 仍为 true 才执行启动 + /// 2) 任务执行前二次校验:必须是当前挂起任务且 audioStream?.isContinuousRecognitionActive 仍为 true 才执行启动 private func startAudioRecordAsync( audioSourceType: AudioSourceType, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil, @@ -264,9 +263,9 @@ public class AzureAsrHelper: NSObject { completion(false) return } - // print("startAudioRecordAsync\(_isContinuousRecognitionActive)") + // print("startAudioRecordAsync\(audioStream?.isContinuousRecognitionActive)") // // 二次校验:stop 可能已发生,确认仍需启动 - // guard self._isContinuousRecognitionActive else { + // guard self.audioStream?.isContinuousRecognitionActive else { // os_log("已收到停止请求,取消启动音频录制", log: self.log, type: .info) // completion(false) // return @@ -321,10 +320,12 @@ public class AzureAsrHelper: NSObject { audioDataCallback: SimpleAudioReceiver.AudioDataCallback? ) -> Bool { do { - audioStream?.startAudioRecord( - audioSourceType: audioSourceType == .microphone ? .microphone : .external, - audioDataCallback: audioDataCallback - ) + if(!audioStream!.isContinuousRecognitionActive){ + audioStream?.startAudioRecord( + audioSourceType: audioSourceType == .microphone ? .microphone : .external, + audioDataCallback: audioDataCallback + ) + } os_log("音频录制启动成功", log: log, type: .info) return true } catch { @@ -382,7 +383,7 @@ public class AzureAsrHelper: NSObject { * @param audioDataCallback 音频数据回调 * @return 是否成功开始识别(调度成功即返回 true) * - * 修复点:在音频启动成功后的闭包中,先判断 _isContinuousRecognitionActive 是否仍然为 true, + * 修复点:在音频启动成功后的闭包中,先判断 audioStream?.isContinuousRecognitionActive 是否仍然为 true, * 若 stop 已发生则直接跳过 recognizer 的启动,避免“已停止但仍启动识别”的情况。 */ public func startContinuousRecognition( @@ -399,7 +400,7 @@ public class AzureAsrHelper: NSObject { return false } - if _isContinuousRecognitionActive { + if audioStream?.isContinuousRecognitionActive == true { print("连续识别已在进行中") return true } @@ -421,7 +422,7 @@ public class AzureAsrHelper: NSObject { print("startContinuousRecognition:\(audioSourceType)") // 移除这里的状态设置,等音频启动成功后再设置 - // self._isContinuousRecognitionActive = true + // self.audioStream?.isContinuousRecognitionActive = true // 异步启动音频处理 startAudioRecordAsync(audioSourceType: audioSourceType, audioDataCallback: audioDataCallback) { [weak self] (success: Bool) in @@ -429,8 +430,8 @@ public class AzureAsrHelper: NSObject { if success { // 只有在音频启动成功后才设置状态为 true - self._isContinuousRecognitionActive = true - print("startContinuousRecognition:\(self._isContinuousRecognitionActive)") + self.audioStream?.isContinuousRecognitionActive = true + print("startContinuousRecognition:\(self.audioStream?.isContinuousRecognitionActive)") // 启动语音识别 do { @@ -439,18 +440,18 @@ public class AzureAsrHelper: NSObject { } catch { os_log("启动连续识别失败: %{public}@", log: self.log, type: .error, error.localizedDescription) self.stopContinuousRecognition() - self._isContinuousRecognitionActive = false + self.audioStream?.isContinuousRecognitionActive = false } } else { os_log("音频启动失败,无法开始识别", log: self.log, type: .error) - self._isContinuousRecognitionActive = false + self.audioStream?.isContinuousRecognitionActive = false } } return true } catch { stopContinuousRecognition() - _isContinuousRecognitionActive = false + audioStream?.isContinuousRecognitionActive = false return false } } @@ -478,7 +479,7 @@ public class AzureAsrHelper: NSObject { guard let self = self else { return } if success { // 标记不再活跃,供启动任务二次校验 - self._isContinuousRecognitionActive = false + self.audioStream?.isContinuousRecognitionActive = false os_log("音频停止成功", log: self.log, type: .info) } else { os_log("音频停止失败", log: self.log, type: .error) @@ -497,7 +498,7 @@ public class AzureAsrHelper: NSObject { return true } catch { // 强制重置状态 - _isContinuousRecognitionActive = false + audioStream?.isContinuousRecognitionActive = false // 关键:即使异常,也取消未执行的启动任务 cancelPendingAudioStart() @@ -532,7 +533,7 @@ public class AzureAsrHelper: NSObject { * 检查连续识别是否活跃 */ public func isContinuousRecognitionActive() -> Bool { - return self._isContinuousRecognitionActive + return self.audioStream?.isContinuousRecognitionActive == true } /** @@ -542,10 +543,10 @@ public class AzureAsrHelper: NSObject { do { print("释放所有资源:") // 如果正在进行连续识别,先停止 - if _isContinuousRecognitionActive { + if audioStream?.isContinuousRecognitionActive == true { // 直接停止,不等待结果 try? recognizer?.stopContinuousRecognition() - _isContinuousRecognitionActive = false + audioStream?.isContinuousRecognitionActive = false } // 取消防抖任务 - 只取消开始操作的防抖 audioStartWorkItem?.cancel() @@ -559,11 +560,11 @@ public class AzureAsrHelper: NSObject { audioConfig = nil audioStream = nil // 确保状态被重置 - _isContinuousRecognitionActive = false + audioStream?.isContinuousRecognitionActive = false //externalAudioStream = nil } catch { // 确保状态被重置 - _isContinuousRecognitionActive = false + audioStream?.isContinuousRecognitionActive = false //externalAudioStream = nil audioConfig = nil recognizer = nil @@ -599,13 +600,13 @@ public class AzureAsrHelper: NSObject { */ private func setupRecognizer() -> Bool { do { - print("设置识别器\(_isContinuousRecognitionActive)") + print("设置识别器\(audioStream?.isContinuousRecognitionActive ?? false)") // 如果正在进行连续识别,先停止 - if _isContinuousRecognitionActive { - try? recognizer?.stopContinuousRecognition() - _isContinuousRecognitionActive = false - } + if audioStream?.isContinuousRecognitionActive == true { + try? recognizer?.stopContinuousRecognition() + audioStream?.isContinuousRecognitionActive = false + } // 【优化】只有在音频流未初始化时才创建,避免重复初始化 if audioStream == nil || audioConfig == nil { @@ -741,7 +742,7 @@ public class AzureAsrHelper: NSObject { self.isAutoDetectLanguage = (validatedSourceLang != validatedTargetLang) // 如果正在识别,需要重新设置识别器 - if _isContinuousRecognitionActive { + if audioStream?.isContinuousRecognitionActive == true { let wasActive = stopContinuousRecognition() if wasActive { return setupRecognizer() @@ -815,7 +816,7 @@ public class AzureAsrHelper: NSObject { print("会话结束事件:") // 直接在当前线程调用回调 callback.onSessionStopped() - self._isContinuousRecognitionActive = false + self.audioStream?.isContinuousRecognitionActive = false //self.stopAudioProcessing() } @@ -865,12 +866,17 @@ public class AzureAsrHelper: NSObject { /** * 开启录音 + * @param audioSourceType 音频源类型 * @param filePath 录音文件路径 * @param audioDataCallback 音频数据回调接口 */ - public func enableRecord(filePath: String, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil) { + public func enableRecord( audioSourceType: AudioSourceType = .microphone,filePath: String, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil) { os_log("开启录音: %{public}@", log: log, type: .info, filePath) - + self.audioSourceType = audioSourceType + if(audioSourceType == .external){ + os_log("外部音频源不在这里录音", log: log, type: .info) + return + } if audioStream == nil { // 创建外部音频拉流对象 audioStream = SimpleAudioReceiver() @@ -881,21 +887,17 @@ public class AzureAsrHelper: NSObject { audioStream?.recordfile = RecordFile() } - self.audioSourceType = .microphone + // 修复:移除多余的 audioDataCallback 参数 // 修复:添加类型转换 - audioStream?.startAudioRecord( - audioSourceType: { - switch audioSourceType { - case .microphone: - return SimpleAudioReceiver.AudioSourceType.microphone - case .external: - return SimpleAudioReceiver.AudioSourceType.external - } - }(), - audioDataCallback: audioDataCallback - ) - + if(!audioStream!.isContinuousRecognitionActive){ + // 异步启动音频处理 + startAudioRecordAsync(audioSourceType: audioSourceType, audioDataCallback: audioDataCallback) { [weak self] (success: Bool) in + guard let self = self else { return } + + } + } + audioStream?.isRecord = true audioStream?.recordfile?.closeFile(isSave: true) audioStream?.recordfile?.creatingFiles(atPath: filePath) } @@ -905,7 +907,10 @@ public class AzureAsrHelper: NSObject { */ public func moveFile(sourcePath: String, destPath: String)-> Bool { - + if(audioSourceType == .external){ + os_log(" 外部音频源不在这里移动文件", log: log, type: .info) + return true + } audioStream?.recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call return true } @@ -915,6 +920,10 @@ public class AzureAsrHelper: NSObject { * 重命名指定路径的音频文件 */ public func renameFile(filePath: String, newName: String)-> Bool { + if(audioSourceType == .external){ + os_log(" 外部音频源不在这里重命名文件", log: log, type: .info) + return true + } audioStream?.recordfile?.renameFile(at: filePath, to: newName) // Fixed method call return true } @@ -925,32 +934,47 @@ public class AzureAsrHelper: NSObject { * 暂停录音 */ public func pauseRecord() { + if(audioSourceType == .external){ + os_log(" 外部音频源不在这里暂停录音", log: log, type: .info) + return + } os_log("暂停录音", log: log, type: .info) guard let audioStream = audioStream, audioStream.recordfile != nil else { return } - audioStream.stopMicrophoneCapture() + audioStream.isRecord = false } /** * 继续录音 */ public func resumeRecord() { + if(audioSourceType == .external){ + os_log(" 外部音频源不在这里继续录音", log: log, type: .info) + return + } os_log("继续录音", log: log, type: .info) guard let audioStream = audioStream, audioStream.recordfile != nil else { return } - audioStream.resumeRecord() + audioStream.isRecord = true } /** * 关闭录音 */ public func stopRecord(isSave: Bool) { + if(audioSourceType == .external){ + os_log(" 外部音频源不在这里关闭录音", log: log, type: .info) + return + } guard let audioStream = audioStream, audioStream.recordfile != nil else { return } - audioStream.stopMicrophoneCapture() + audioStream.isRecord = false + if (!audioStream.isContinuousRecognitionActive) { + audioStream.stopMicrophoneCapture() + } audioStream.recordfile?.isPause = false // audioStream.recordfile?.closeFile(isSave: true) // } 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 83503dbcb..4a272ea12 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 @@ -49,10 +49,6 @@ import ble_service } // 替换原有的 StreamHandler 类 -private typealias AsrEventStreamHandler = BaseEventStreamHandler -private typealias TtsEventStreamHandler = BaseEventStreamHandler -private typealias AudioDataEventStreamHandler = BaseEventStreamHandler -private typealias AstEventStreamHandler = BaseEventStreamHandler // 事件类型枚举 @@ -72,6 +68,11 @@ private typealias AstEventStreamHandler = BaseEventStreamHandler let methodHandler: (FlutterMethodCall, @escaping FlutterResult) -> Void } + private typealias AsrEventStreamHandler = BaseEventStreamHandler + private typealias TtsEventStreamHandler = BaseEventStreamHandler + private typealias AudioDataEventStreamHandler = BaseEventStreamHandler + private typealias AstEventStreamHandler = BaseEventStreamHandler + // ASR相关 private var asrChannel: FlutterMethodChannel? private var asrEventChannel: FlutterEventChannel? @@ -96,8 +97,9 @@ private typealias AstEventStreamHandler = BaseEventStreamHandler private var audioDataEventChannel: FlutterEventChannel? internal var audioDataEventSink: FlutterEventSink? - - + // 录音相关 + public var recordfile: RecordFile? + private var isRecord = false // 创建事件回调 private var astEventCallback: AstEventCallback? // 是否已添加AST事件监听器 @@ -391,8 +393,14 @@ private func sendAstEvent(_ event: [String: Any]) { return } do { - print("音频文件名称为: (filePath)") - try azureAsrHelper.renameFile(filePath: filePath, newName: newName) + print("renameFile音频文件名称为: \(filePath),新名称为: \(newName)") + if(azureAsrHelper.audioSourceType == .external){ + try recordfile?.renameFile(at: filePath, to: newName) + } + else + { + try azureAsrHelper.renameFile(filePath: filePath, newName: filePath) + } result(true) } catch { result(FlutterError(code: "RENAMEFILE_ERROR", message: error.localizedDescription, details: nil)) @@ -406,8 +414,13 @@ private func sendAstEvent(_ event: [String: Any]) { return } do { - print("音频文件名称为: (sourcePath)") - try azureAsrHelper.moveFile(sourcePath: sourcePath, destPath: destPath) + print("moveFile音频文件名称为: \(sourcePath),新名称为: \(destPath)") + if(azureAsrHelper.audioSourceType == .external){ + try recordfile?.moveFile(from: sourcePath, to: destPath) + } + else{ + try azureAsrHelper.moveFile(sourcePath: sourcePath, destPath: destPath) + } result(true) } catch { result(FlutterError(code: "MOVEFILE_ERROR", message: error.localizedDescription, details: nil)) @@ -422,13 +435,28 @@ private func sendAstEvent(_ event: [String: Any]) { // 检查是否需要接受音频数据 let acceptAudioData = args["acceptAudioData"] as? Bool ?? false - + // 检查是否需要接受音频数据 + let isExternalActive = args["audioSourceType"] as? Bool ?? false + if isExternalActive { + if(recordfile == nil){ + recordfile = RecordFile() + } + recordfile?.closeFile(isSave: true) + recordfile?.creatingFiles(atPath: filePath) + isRecord = true + } + // 根据参数确定音频源类型 + let audioSourceType = isExternalActive ? + AzureAsrHelper.AudioSourceType.external : + AzureAsrHelper.AudioSourceType.microphone + + print("startContinuousRecognition: \(audioSourceType)") do { if acceptAudioData { // 使用复用的音频数据回调实例 - try azureAsrHelper.enableRecord(filePath: filePath, audioDataCallback: audioDataCallback) + try azureAsrHelper.enableRecord(audioSourceType: audioSourceType, filePath: filePath, audioDataCallback: audioDataCallback) } else { - try azureAsrHelper.enableRecord(filePath: filePath) + try azureAsrHelper.enableRecord(audioSourceType: audioSourceType, filePath: filePath) } result(true) } catch { @@ -438,22 +466,34 @@ private func sendAstEvent(_ event: [String: Any]) { case "pauseRecord": + isRecord = false azureAsrHelper.pauseRecord() result(true) case "resumeRecord": + isRecord = true azureAsrHelper.resumeRecord() result(true) case "stopRecord": + + guard let args = call.arguments as? [String: Any], let isSave = args["isSave"] as? Bool else { result(FlutterError(code: "INVALID_ARGUMENTS", message: "isSave 参数不能为空", details: nil)) return } do { - - try azureAsrHelper.stopRecord(isSave: isSave) + print("stopRecord : \(azureAsrHelper.audioSourceType)") + if(azureAsrHelper.audioSourceType == .external){ + print("stopRecord音频文件名称为:)") + isRecord = false + recordfile?.closeFile(isSave: true) + } + else + { + try azureAsrHelper.stopRecord(isSave: isSave) + } result(true) } catch { result(FlutterError(code: "STOPRECORD_ERROR", message: error.localizedDescription, details: nil)) @@ -595,20 +635,20 @@ private func sendAstEvent(_ event: [String: Any]) { // MARK: - AST 方法处理 private func handleAstMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - case "enableRecord": - guard let args = call.arguments as? [String: Any], - let filePath = args["filePath"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing filePath", details: nil)) - return - } - - do { - os_log("音频文件名称为: %@", log: log, type: .info, filePath) - try azureAstHelper.enableRecord(filePath: filePath) - result(true) - } catch { - result(FlutterError(code: "ENABLERECORD_ERROR", message: error.localizedDescription, details: nil)) - } + // case "enableRecord": + // guard let args = call.arguments as? [String: Any], + // let filePath = args["filePath"] as? String else { + // result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing filePath", details: nil)) + // return + // } + + // do { + // os_log("音频文件名称为: %@", log: log, type: .info, filePath) + // try azureAstHelper.enableRecord(filePath: filePath) + // result(true) + // } catch { + // result(FlutterError(code: "ENABLERECORD_ERROR", message: error.localizedDescription, details: nil)) + // } case "startContinuousTranslation": do { @@ -935,28 +975,79 @@ extension AzureSpeechPlugin: BleService.Callback { /// 接收到音频数据回调 /// - Parameter data: 音频数据 - public func onAudioDataReceived(data: Data) { - // 安全解包版本 + public func onAudioDataReceived(data: Data, channel: Int32) { + if channel == 0 { + return + } else if channel == 1 { + if (isRecord) { + recordfile?.setAudioConfig(sampleRate: 16000, channels: 1) + recordfile?.saveAudioDataToWav(data) + handleAudioData(data) + } + // 安全解包版本 guard let audioStream = azureAsrHelper.audioStream else { os_log("音频流未初始化", type: .error) return } - // print("liwei--------------接收到音频数据回调") - audioStream.saveAudioDataTo(data: data) - } - - /// 接收到音频数据回调1 - /// - Parameter data: 音频数据 - public func onAudioDataReceivedCall(data: Data) { - // 安全解包版本 - guard let audioStream = azureAstHelper.audioProcessor else { + azureAsrHelper.audioStream?.saveAudioDataTo(data: data) + } + else if channel == 2 { + if (isRecord) { + recordfile?.setAudioConfig(sampleRate: 16000, channels: 2) + recordfile?.saveAudioDataToWav(data) + handleAudioData(data) + } + let sampleCount = data.count / 4 // 每个样本4字节(左右声道各2字节) + + guard sampleCount > 0 else { + print("双声道数据样本数为0") + return + } + + var leftBuffer = Data() + var rightBuffer = Data() + + leftBuffer.reserveCapacity(sampleCount * 2) + rightBuffer.reserveCapacity(sampleCount * 2) + + data.withUnsafeBytes { bytes in + let uint8Ptr = bytes.bindMemory(to: UInt8.self) + + for i in 0.. Bool { + audioProcessor?.isContinuousRecognitionActive = true os_log("尝试启动连续翻译,当前状态:初始化=%@, 识别中=%@", log: log, type: .info, serviceState.isInitialized ? "true" : "false", serviceState.isRecognizing ? "true" : "false") @@ -597,7 +599,8 @@ import os.log */ public func stopContinuousTranslation() { do { - self.serviceState.isRecognizing = false + audioProcessor?.isContinuousRecognitionActive = false + self.serviceState.isRecognizing = false try recognizer?.stopContinuousRecognition() audioProcessor?.stopMicrophoneCapture() recordFile?.closeFile(isSave: true) diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift index a175aa829..857d30041 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift @@ -10,10 +10,11 @@ public class MicrophoneCapture: NSObject { */ public static let shared = MicrophoneCapture() - private var audioEngine: AVAudioEngine! - private var audioSession: AVAudioSession! + // 修复:将强制解包改为可选类型,避免内存访问错误 + private var audioEngine: AVAudioEngine? + private var audioSession: AVAudioSession? private var audioFormat: AVAudioFormat? - private var audioInputNode: AVAudioInputNode! + private var audioInputNode: AVAudioInputNode? private var sampleRate: Double = 16000 // 修改为16000采样率,符合Microsoft要求 public var isCapturing = false public var hasBluetoothDevices = false @@ -84,8 +85,13 @@ public class MicrophoneCapture: NSObject { audioFormat = getOptimalAudioFormat() audioSession = AVAudioSession.sharedInstance() + // 修复:添加安全检查 + guard let audioSession = audioSession else { + print("音频会话初始化失败") + return + } + do { - try audioSession.setPreferredSampleRate(sampleRate) try audioSession.setPreferredIOBufferDuration(0.005) // 5ms缓冲 @@ -121,10 +127,11 @@ public class MicrophoneCapture: NSObject { * 配置蓝牙模式的音频路由:末端在手机,输出端在耳机 */ private func configureAudioForBluetoothMode() { - guard let availableInputs = audioSession.availableInputs else { return } + guard let audioSession = audioSession, + let availableInputs = audioSession.availableInputs else { return } // 强制使用内置麦克风作为输入(手机) - if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + if let builtInMic = availableInputs.first(where: { $0.portType == AVAudioSession.Port.builtInMic }) { do { try audioSession.setPreferredInput(builtInMic) print("蓝牙模式:音频输入设置为内置麦克风(手机)") @@ -139,7 +146,7 @@ public class MicrophoneCapture: NSObject { // 确保音频输出通过蓝牙耳机 let hasBluetoothOutput = currentRoute.outputs.contains { output in - [.bluetoothHFP, .bluetoothLE].contains(output.portType) + [AVAudioSession.Port.bluetoothHFP, AVAudioSession.Port.bluetoothLE].contains(where: { $0 == output.portType }) } if hasBluetoothOutput { @@ -164,10 +171,11 @@ public class MicrophoneCapture: NSObject { * 配置手机模式的音频路由:末端和输出端都在手机 */ private func configureAudioForPhoneMode() { - guard let availableInputs = audioSession.availableInputs else { return } + guard let audioSession = audioSession, + let availableInputs = audioSession.availableInputs else { return } // 使用内置麦克风作为输入(手机) - if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + if let builtInMic = availableInputs.first(where: { $0.portType == AVAudioSession.Port.builtInMic }) { do { // 设置音频会话参数 try audioSession.setCategory(.playAndRecord, @@ -199,6 +207,9 @@ public class MicrophoneCapture: NSObject { public func startCapture() throws { print("开始捕获\(isCapturing)") guard !isCapturing else { return } + guard let audioSession = audioSession else { + throw NSError(domain: "音频会话未初始化", code: -1) + } // 检查麦克风权限 switch audioSession.recordPermission { @@ -250,20 +261,24 @@ public class MicrophoneCapture: NSObject { * 根据Microsoft音频堆栈要求优化格式转换 */ private func setupAudioEngine() throws { + // 修复:先安全清理现有资源 + cleanupAudioEngine() + audioEngine = AVAudioEngine() - + + guard let audioEngine = audioEngine else { + throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "音频引擎初始化失败"]) + } + audioInputNode = audioEngine.inputNode - // 获取实际硬件采样率 - sampleRate = audioSession.sampleRate - // 安全地停止音频引擎 - if let engine = audioEngine { - engine.stop() + guard let audioInputNode = audioInputNode else { + throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "音频输入节点获取失败"]) } - // 安全地移除音频处理块 - if let inputNode = audioInputNode { - inputNode.removeTap(onBus: 0) + // 获取实际硬件采样率 + if let audioSession = audioSession { + sampleRate = audioSession.sampleRate } // 设置音频格式 @@ -274,72 +289,56 @@ public class MicrophoneCapture: NSObject { try audioInputNode.setVoiceProcessingEnabled(true) } - // 检查输入格式是否符合Microsoft要求 let needsConversion = !isMicrosoftCompatibleFormat(inputFormat) - // if needsConversion { - // 需要格式转换 - guard let targetFormat = audioFormat, - let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { - throw NSError(domain: "AudioSetup", code: 2) - } + // 需要格式转换 + guard let targetFormat = audioFormat, + let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { + throw NSError(domain: "AudioSetup", code: 2, userInfo: [NSLocalizedDescriptionKey: "音频格式转换器创建失败"]) + } + + print("输入格式不符合Microsoft要求,进行格式转换") + print("输入格式: \(inputFormat.sampleRate)Hz, \(inputFormat.commonFormat.rawValue)") + print("目标格式: \(targetFormat.sampleRate)Hz, \(targetFormat.commonFormat.rawValue)") + + // 添加tap进行格式转换 + audioInputNode.installTap(onBus: 0, + bufferSize: 1024, + format: inputFormat) { [weak self] buffer, when in - print("输入格式不符合Microsoft要求,进行格式转换") - print("输入格式: \(inputFormat.sampleRate)Hz, \(inputFormat.commonFormat.rawValue)") - print("目标格式: \(targetFormat.sampleRate)Hz, \(targetFormat.commonFormat.rawValue)") + guard let strongSelf = self else { return } - // 添加tap进行格式转换 - audioInputNode.installTap(onBus: 0, - bufferSize: 1024, - format: inputFormat) { [weak self] buffer, when in - - guard let strongSelf = self else { return } - - // 创建目标格式的音频缓冲区 - let convertedBuffer = AVAudioPCMBuffer( - pcmFormat: targetFormat, - frameCapacity: AVAudioFrameCount( - targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate - ) - )! - - var error: NSError? - // 执行音频格式转换 - let status = converter.convert( - to: convertedBuffer, - error: &error, - withInputFrom: { inNumPackets, outStatus in - outStatus.pointee = .haveData - return buffer - } + // 修复:添加安全检查,避免强制解包崩溃 + guard let convertedBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: AVAudioFrameCount( + targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate ) - - // 转换成功且无错误 - if status == .haveData, error == nil { - let data = strongSelf.audioBufferToData(convertedBuffer) - //print("转换后数据长度: \(data.count)") - strongSelf.audioDataHandler?(data) - } + ) else { + print("创建转换缓冲区失败") + return } - // } else { - // // 格式已符合Microsoft要求,直接使用 - // print("输入格式符合Microsoft要求,无需转换") - // print("格式: \(inputFormat.sampleRate)Hz, \(inputFormat.commonFormat.rawValue)") - // audioInputNode.installTap(onBus: 0, - // bufferSize: 1024, - // format: inputFormat) { [weak self] buffer, when in - - // guard let strongSelf = self else { return } - //strongSelf.processAudioBuffer(buffer) - // // 直接处理原始音频数据 - // let data = strongSelf.audioBufferToData(convertedBuffer) - // print("转换后数据长度: \(data.count)") - // strongSelf.audioDataHandler?(data) - - // } - // } + var error: NSError? + // 执行音频格式转换 + let status = converter.convert( + to: convertedBuffer, + error: &error, + withInputFrom: { inNumPackets, outStatus in + outStatus.pointee = .haveData + return buffer + } + ) + + // 转换成功且无错误 + if status == .haveData, error == nil { + let data = strongSelf.audioBufferToData(convertedBuffer) + strongSelf.audioDataHandler?(data) + } else if let error = error { + print("音频格式转换失败: \(error.localizedDescription)") + } + } // 启动引擎 do { @@ -347,9 +346,26 @@ public class MicrophoneCapture: NSObject { isCapturing = true } catch { print("音频引擎启动失败: \(error.localizedDescription)") + throw error } } + /** + * 安全清理音频引擎资源 + * 防止内存访问错误 + */ + private func cleanupAudioEngine() { + // 安全地停止音频引擎 + audioEngine?.stop() + + // 安全地移除音频处理块 + audioInputNode?.removeTap(onBus: 0) + + // 重置引用 + audioInputNode = nil + audioEngine = nil + } + /** * 将音频缓冲区转换为Data格式 * 支持16位整数和32位浮点格式 @@ -423,36 +439,36 @@ public class MicrophoneCapture: NSObject { guard isCapturing else { return } isCapturing = false - // 安全地停止音频引擎 - if let engine = audioEngine { - engine.stop() - } + // 修复:使用安全的清理方法 + cleanupAudioEngine() - // 安全地移除音频处理块 - if let inputNode = audioInputNode { - inputNode.removeTap(onBus: 0) - } + // 安全地处理音频会话 + guard let audioSession = audioSession else { return } - try? audioSession.setActive(false) - if self.hasBluetoothDevices { - // 设置音频会话参数 - try? audioSession.setCategory(.playback, - mode: .videoChat, - options: [ - - .allowBluetoothA2DP, - .allowBluetooth - - ]) // 添加音频优先级控制 - } else { - try? audioSession.setCategory(.playback, - mode: .videoChat, - options: [ - .mixWithOthers, - .defaultToSpeaker]) + do { + try audioSession.setActive(false) + + if self.hasBluetoothDevices { + // 设置音频会话参数 + try audioSession.setCategory(.playback, + mode: .videoChat, + options: [ + .allowBluetoothA2DP, + .allowBluetooth + ]) // 添加音频优先级控制 + } else { + try audioSession.setCategory(.playback, + mode: .videoChat, + options: [ + .mixWithOthers, + .defaultToSpeaker]) + } + + try audioSession.overrideOutputAudioPort(.none) + try audioSession.setActive(true) + } catch { + print("音频会话配置失败: \(error.localizedDescription)") } - try? audioSession.overrideOutputAudioPort(.none) - try? audioSession.setActive(true) } /** @@ -462,6 +478,10 @@ public class MicrophoneCapture: NSObject { func reset() { stopCapture() audioDataHandler = nil + // 修复:完全清理资源 + cleanupAudioEngine() + audioSession = nil + audioFormat = nil } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift index ba6733fc9..6fa231100 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift @@ -10,11 +10,47 @@ public class RecordFile { private var isWriting = false private var dataBuffer = Data() private var totalBytesWritten = 0 - private let sampleRate: UInt32 = 16000 + + // 音频配置 - 可配置的采样率和声道数 + private var sampleRate: UInt32 = 16000 + private var channels: UInt32 = 1 // 1=单声道, 2=立体声 + private var lastSavedFile: URL? public var isPause = false var fileName = "" - public init() {} + + public init() {} + + /** + * 设置音频配置 + * @param sampleRate 采样率 (8000, 16000, 24000, 32000, 44100, 48000) + * @param channels 声道数 (1=单声道, 2=立体声) + */ + public func setAudioConfig(sampleRate: UInt32, channels: UInt32) { + self.sampleRate = sampleRate + self.channels = channels + print("音频配置已更新: 采样率=\(sampleRate)Hz, 声道数=\(channels)") + } + + /** + * 获取当前音频配置信息 + * @return Dictionary包含采样率和声道数信息 + */ + public func getAudioConfig() -> [String: UInt32] { + return [ + "sampleRate": sampleRate, + "channels": channels + ] + } + + /** + * 检查是否为双声道模式 + * @return true表示双声道,false表示单声道 + */ + public func isStereoMode() -> Bool { + return channels == 2 + } + // 创建音频文件并初始化 WAV 头 public func creatingFiles(atPath path: String) { guard fileHandle == nil else { return } @@ -87,10 +123,16 @@ public class RecordFile { } } - // 生成 WAV 文件头 + /** + * 生成WAV文件头,支持可配置的采样率和声道数 + * @param dataLength 音频数据长度 + * @return WAV文件头数据 + */ private func generateWavHeader(dataLength: Int) -> Data { - let totalLength = 36 + dataLength - let byteRate = sampleRate * 2 + let totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据 + let bytesPerSample: UInt32 = 2 // 16位PCM + let byteRate = sampleRate * channels * bytesPerSample // 采样率 * 声道数 * 字节/样本 + let blockAlign = channels * bytesPerSample // 声道数 * 字节/样本 var header = Data() header.append("RIFF".data(using: .ascii)!) @@ -104,20 +146,26 @@ public class RecordFile { header.append("fmt ".data(using: .ascii)!) header.append(Data(bytes: [16, 0, 0, 0])) // PCM 头长度 header.append(Data(bytes: [1, 0])) // PCM 格式 - header.append(Data(bytes: [1, 0])) // 单声道 + header.append(Data(bytes: [ + UInt8(channels & 0xFF), + UInt8((channels >> 8) & 0xFF) + ])) // 声道数 header.append(Data(bytes: [ UInt8(sampleRate & 0xFF), UInt8((sampleRate >> 8) & 0xFF), UInt8((sampleRate >> 16) & 0xFF), UInt8((sampleRate >> 24) & 0xFF) - ])) + ])) // 采样率 header.append(Data(bytes: [ UInt8(byteRate & 0xFF), UInt8((byteRate >> 8) & 0xFF), UInt8((byteRate >> 16) & 0xFF), UInt8((byteRate >> 24) & 0xFF) - ])) - header.append(Data(bytes: [2, 0])) // 块对齐 + ])) // 字节率 + header.append(Data(bytes: [ + UInt8(blockAlign & 0xFF), + UInt8((blockAlign >> 8) & 0xFF) + ])) // 块对齐 header.append(Data(bytes: [16, 0])) // 样本位数 header.append("data".data(using: .ascii)!) header.append(Data(bytes: [ diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift index 4f9e3d02d..02db119fd 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift @@ -42,6 +42,9 @@ public class SimpleAudioReceiver: NSObject { private var audioDataCallback: AudioDataCallback? private var isRunning = false public var _isWriting = false + public var isRecord = false + public var isContinuousRecognitionActive = false + private var isInitialized = false private let bufferSize: Int = 4096 private var writeThread: DispatchQueue? private var currentRoute: AudioOutputRoute? @@ -63,7 +66,6 @@ public class SimpleAudioReceiver: NSObject { // .audioDataHandler = { data in // self.writeQueue.offer(data) // } - print("初始化了") pushAudioStream = SPXPushAudioInputStream() @@ -74,8 +76,18 @@ public class SimpleAudioReceiver: NSObject { // 创建新的写线程 writeThread = DispatchQueue(label: "audio.stream.writer") startWriteThread() + isInitialized = true } + /** + * 检查音频接收器是否已初始化 + * @return 如果已初始化则返回true,否则返回false + */ + public func getIsInitialized() -> Bool { + print("getIsInitialized=\(isInitialized)") + return isInitialized + } + /** * 设置音频配置参数 @@ -144,11 +156,11 @@ public class SimpleAudioReceiver: NSObject { if self.audioDataCallback != nil { self.audioDataCallback!.onAudio(dataToWrite) } - if self.pushAudioStream != nil && self.audioDataCallback == nil { + if self.pushAudioStream != nil && isContinuousRecognitionActive { try self.pushAudioStream?.write(dataToWrite) } - if self.recordfile != nil { + if self.recordfile != nil && isRecord{ // print("写入recordfile数据长度: \(dataToWrite.count)") recordfile?.saveAudioDataToWav(dataToWrite) } @@ -330,6 +342,10 @@ public func restoreOriginalAudioState() { print("stopMicrophoneCapture") } + /** + * 释放音频资源 + * 安全地清理音频引擎和相关资源 + */ public func releaseAudioResources() { print("释放了") stopMicrophoneCapture() 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 dd3988a04..5bb24ea8a 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 @@ -90,8 +90,10 @@ object BleConst { const val CODEC_CONTROL_CLOSE = 0x00 /** 音乐或者通话远端声音 */ const val CODEC_CONTROL_DECODE_ON = 0xA1 - /** mic和dac(音乐或者通话远端)声音 */ + /** mic和dac(音乐或者通话远端)声音+翻译后重新编码 */ const val CODEC_CONTROL_A2DP_PLAY = 0xA2 + /** mic和dac(音乐或者通话远端)声音 */ + const val CODEC_CONTROL_CALL_RECORD_PLAY = 0xA3 /** 打开编码指令 */ const val CODEC_CONTROL_ENCODE_ON = 0xB1 /** 左声道 */ 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 4f4660c80..baaed09ec 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 @@ -53,9 +53,7 @@ object BleService { fun onConnectionStateChanged(state: Int) // 数据相关回调 - fun onAudioDataReceived(data: ByteArray) - // 数据相关回调 - fun onAudioDataReceivedCall(data: ByteArray) + fun onAudioDataReceived(data: ByteArray,channel:Int) // 唤醒信号相关回调 fun onWakeupSignalReceived() @@ -1054,11 +1052,13 @@ startBytesStatistics() BleConst.CODEC_CONTROL_CLOSE -> "已关闭编解码" BleConst.CODEC_CONTROL_DECODE_ON -> "已打开解码" BleConst.CODEC_CONTROL_A2DP_PLAY -> "A2DP播放模式" + BleConst.CODEC_CONTROL_CALL_RECORD_PLAY -> "通话记录播放模式" BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码" else -> "未知状态($codecStatus)" } if (codecStatus == BleConst.CODEC_CONTROL_DECODE_ON || codecStatus == BleConst.CODEC_CONTROL_A2DP_PLAY || + codecStatus == BleConst.CODEC_CONTROL_CALL_RECORD_PLAY || codecStatus == BleConst.CODEC_CONTROL_ENCODE_ON ) { recordfile1!!.closeFile() @@ -1276,38 +1276,8 @@ startBytesStatistics() opusManager?.startDecodeStream(option, object : OnDecodeStreamCallback { override fun onDecodeStream(data: ByteArray?) { if (data != null) { - // Log.d(TAG, "Opus解码数据: ${data.size} bytes") - 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 - - // 左声道(低位字节在前,高位字节在后) - leftBuffer[monoIndex] = data[stereoIndex] - leftBuffer[monoIndex + 1] = data[stereoIndex + 1] - - // 右声道 - 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) + if (option!!.getChannel() >= 1) { + notifyAudioDataReceived(data,option!!.getChannel()) } else { Log.e(TAG, "Opus解码数据错误: ${data.size} bytes") } @@ -1707,6 +1677,22 @@ startBytesStatistics() ) ) } + /** + * 控制编解码 - 打开解码 + */ + fun openCallRecordDecoder(): Boolean { + Log.i(TAG, "打开编码 0xA3") + //双声道 80字节 + startOpusStreamDecoding(false, 2, 16000, 80) + // Log.i(TAG, "打开解码...") + return sendCommand( + BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( + BleConst.CODEC_CONTROL_CALL_RECORD_PLAY.toByte(), + + BleConst.AUDIO_CHANNEL_RIGHT.toByte() + ) + ) + } /** * 控制编解码 - A2DP播放 @@ -1980,28 +1966,17 @@ startBytesStatistics() /** * 向所有回调监听器分发音频数据 */ - private fun notifyAudioDataReceived(data: ByteArray) { - for (callback in callbacks) { - try { - callback.onAudioDataReceived(data) - } catch (e: Exception) { - Log.e(TAG, "分发音频数据回调异常", e) - } - } - } - /** - * 向所有回调监听器分发音频数据 - */ - private fun notifyAudioDataReceived1(data: ByteArray) { + private fun notifyAudioDataReceived(data: ByteArray,channel:Int) { for (callback in callbacks) { try { - callback.onAudioDataReceivedCall(data) + callback.onAudioDataReceived(data,channel) } catch (e: Exception) { Log.e(TAG, "分发音频数据回调异常", e) } } } + /** * 向所有回调监听器分发唤醒信号 */ 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 80fcb7780..3e2a2677d 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 @@ -209,6 +209,10 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, val success = BleService.openA2DPDecoder() result.success(success) } + "openCallRecordDecoder" -> { + val success = BleService.openCallRecordDecoder() + result.success(success) + } "closeCodec" -> { val success = BleService.closeCodec() result.success(success) @@ -357,11 +361,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, sendEvent(statusEventSink, stateMap, "发送连接状态异常") } - override fun onAudioDataReceived(data: ByteArray) { - // sendEvent(dataEventSink, mapOf("type" to "audioData", "data" to data), "发送音频数据异常") - } - - override fun onAudioDataReceivedCall(data: ByteArray) { + override fun onAudioDataReceived(data: ByteArray,channel:Int) { // sendEvent(dataEventSink, mapOf("type" to "audioData", "data" to data), "发送音频数据异常") } diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift index f15c40b8b..33d92f331 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleConst.swift @@ -93,6 +93,8 @@ class BleConst { static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1 /** mic和dac(音乐或者通话远端)声音 */ static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2 + /** mic和dac(音乐或者通话远端)声音 */ + static let CODEC_CONTROL_CALL_RECORD_PLAY: UInt8 = 0xA3 /** 打开编码指令 */ static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1 /** 左声道 */ 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 942728de5..3ad24d30f 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 @@ -27,8 +27,8 @@ public class BleService: NSObject { func onConnectionStateChanged(state: Int) // 数据相关回调 - func onAudioDataReceived(data: Data) - func onAudioDataReceivedCall(data: Data) + func onAudioDataReceived(data: Data, channel: Int32) + // 唤醒信号回调 func onWakeupSignalReceived() @@ -524,15 +524,9 @@ private var cmdReplyType: UInt8 = 0 } /// 通知所有回调接收到音频数据 - private func notifyAudioDataReceived(data: Data) { - for callback in callbacks { - callback.onAudioDataReceived(data: data) - } - } - /// 通知所有回调接收到音频数据 - private func notifyAudioDataReceived1(data: Data) { + private func notifyAudioDataReceived(data: Data, channel: Int32) { for callback in callbacks { - callback.onAudioDataReceivedCall(data: data) + callback.onAudioDataReceived(data: data, channel: channel) } } @@ -642,6 +636,12 @@ private var cmdReplyType: UInt8 = 0 let paramData = Data([BleConst.CODEC_CONTROL_A2DP_PLAY, BleConst.AUDIO_CHANNEL_RIGHT]) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) } + /// 打开通话记录解码器并开始录制 + func openCallRecordDecoder() -> Bool { + os_log("打开通话记录解码并开始录制...", log: logger, type: .info) + let paramData = Data([BleConst.CODEC_CONTROL_CALL_RECORD_PLAY, BleConst.AUDIO_CHANNEL_RIGHT]) + return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) + } /// 关闭编解码器并停止录制 /// - Returns: 操作是否成功 @@ -983,6 +983,8 @@ private var cmdReplyType: UInt8 = 0 statusDesc = "已打开解码" case Int(BleConst.CODEC_CONTROL_A2DP_PLAY): statusDesc = "A2DP播放模式" + case Int(BleConst.CODEC_CONTROL_CALL_RECORD_PLAY): + statusDesc = "通话录音播放模式" case Int(BleConst.CODEC_CONTROL_ENCODE_ON): statusDesc = "已打开编码" default: @@ -1184,14 +1186,10 @@ private var cmdReplyType: UInt8 = 0 @available(iOS 13.0, *) extension BleService: SwiftOpusAudioProcessor.AudioDataCallback { /// 接收到解码后的PCM音频数据 - func onAudioDataReceived(data: Data) { - notifyAudioDataReceived(data: data) + func onAudioDataReceived(data: Data, channel: Int32) { + notifyAudioDataReceived(data: data, channel: channel) } - /// 接收到解码后的PCM音频数据 - func onAudioDataReceivedCall(data: Data) { - notifyAudioDataReceived1(data: data) - } /// 接收到编码后的音频数据 /// - Parameter data: 编码后的音频数据 diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index da70071fe..dd9d07f14 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift @@ -100,6 +100,9 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { case "openA2DPDecoder": result(BleService.shared.openA2DPDecoder()) + case "openCallRecordDecoder": + result(BleService.shared.openCallRecordDecoder()) + case "closeCodec": result(BleService.shared.closeCodec()) @@ -225,21 +228,19 @@ extension SwiftBleServicePlugin: BleService.Callback { } } - public func onAudioDataReceived(data: Data) { + public func onAudioDataReceived(data: Data, channel: Int32) { if let eventSink = dataEventSink { DispatchQueue.main.async { eventSink([ "type": "audioData", - "data": FlutterStandardTypedData(bytes: data) + "data": FlutterStandardTypedData(bytes: data), + "channel": channel ]) } } } - public func onAudioDataReceivedCall(data: Data) { - - } - + public func onWakeupSignalReceived() { if let eventSink = statusEventSink { DispatchQueue.main.async { diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift index 5030de4ee..87367acd5 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftOpusAudioProcessor.swift @@ -8,8 +8,7 @@ class SwiftOpusAudioProcessor: NSObject { /// 音频数据回调协议 protocol AudioDataCallback: AnyObject { - func onAudioDataReceived(data: Data) - func onAudioDataReceivedCall(data: Data) + func onAudioDataReceived(data: Data, channel: Int32) func onEncodedDataReceived(data: Data) // 编码数据回调 } @@ -284,61 +283,61 @@ class SwiftOpusAudioProcessor: NSObject { } print("Opus解码数据: \(data.count) bytes, 声道数: \(channels)") - - if channels == 2 { - processStereoAudioData(data) - } else if channels == 1 { - DispatchQueue.main.async { [weak self] in - self?.callback?.onAudioDataReceived(data: data) + DispatchQueue.main.async { [weak self] in + self?.callback?.onAudioDataReceived(data: data, channel: self?.channels ?? 0) } - } else { - print("不支持的声道数: \(channels)") - } + // if channels == 2 { + // processStereoAudioData(data) + // } else if channels == 1 { + + // } else { + // print("不支持的声道数: \(channels)") + // } } - /// 处理双声道音频数据,分离左右声道 - /// - Parameter stereoData: 交错的双声道PCM数据 - private func processStereoAudioData(_ stereoData: Data) { - let sampleCount = stereoData.count / 4 // 每个样本4字节(左右声道各2字节) + // /// 处理双声道音频数据,分离左右声道 + // /// - Parameter stereoData: 交错的双声道PCM数据 + // private func processStereoAudioData(_ stereoData: Data) { + // let sampleCount = stereoData.count / 4 // 每个样本4字节(左右声道各2字节) - guard sampleCount > 0 else { - print("双声道数据样本数为0") - return - } + // guard sampleCount > 0 else { + // print("双声道数据样本数为0") + // return + // } - var leftBuffer = Data() - var rightBuffer = Data() + // var leftBuffer = Data() + // var rightBuffer = Data() - leftBuffer.reserveCapacity(sampleCount * 2) - rightBuffer.reserveCapacity(sampleCount * 2) + // leftBuffer.reserveCapacity(sampleCount * 2) + // rightBuffer.reserveCapacity(sampleCount * 2) - stereoData.withUnsafeBytes { bytes in - let uint8Ptr = bytes.bindMemory(to: UInt8.self) + // stereoData.withUnsafeBytes { bytes in + // let uint8Ptr = bytes.bindMemory(to: UInt8.self) - for i in 0.. openCallRecordDecoder() async { + try { + final result = + await _methodChannel.invokeMethod('openCallRecordDecoder'); + return result ?? false; + } catch (e) { + print('打开解码器失败: $e'); + return false; + } + } + /// 关闭编解码器 Future closeCodec() async { try { diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt index b500773cf..e8e70f9a7 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt @@ -48,41 +48,7 @@ class QQMusicSystemFunctionHandler(private val context: Context? = null) { ), QQMusicSearchHandler(context) ) - - // QQ音乐播放歌曲 -// client.registerLocalFunction( -// "qqmusic_play_songs", -// "播放QQ音乐歌曲列表", -// mapOf( -// "type" to "object", -// "properties" to mapOf( -// "songMids" to mapOf( -// "type" to "array", -// "description" to "歌曲ID列表", -// "items" to mapOf("type" to "string") -// ), -// "index" to mapOf( -// "type" to "integer", -// "description" to "开始播放的歌曲索引,默认为0" -// ) -// ), -// "required" to listOf("songMids") -// ), -// QQMusicPlaySongsHandler(context) -// ) - - //QQ音乐播放控制 -// client.registerLocalFunction( -// "qqmusic_play", -// "播放QQ音乐", -// mapOf( -// "type" to "object", -// "properties" to emptyMap(), -// "required" to emptyList() -// ), -// QQMusicPlayHandler(context) -// ) - + client.registerLocalFunction( "qqmusic_pause", "暂停QQ音乐", @@ -137,40 +103,7 @@ class QQMusicSystemFunctionHandler(private val context: Context? = null) { ), QQMusicPreviousHandler(context) ) - - // QQ音乐状态查询 - client.registerLocalFunction( - "qqmusic_get_playback_state", - "获取QQ音乐播放状态", - mapOf( - "type" to "object", - "properties" to emptyMap(), - "required" to emptyList() - ), - QQMusicGetPlaybackStateHandler(context) - ) - - client.registerLocalFunction( - "qqmusic_get_current_song", - "获取QQ音乐当前播放歌曲信息", - mapOf( - "type" to "object", - "properties" to emptyMap(), - "required" to emptyList() - ), - QQMusicGetCurrentSongHandler(context) - ) - - client.registerLocalFunction( - "qqmusic_get_play_time", - "获取QQ音乐播放时间信息", - mapOf( - "type" to "object", - "properties" to emptyMap(), - "required" to emptyList() - ), - QQMusicGetPlayTimeHandler(context) - ) + Log.d(TAG, "QQ音乐所有功能注册完成") } catch (e: Exception) { Log.e(TAG, "注册QQ音乐功能失败: ${e.message}", e) @@ -179,31 +112,6 @@ class QQMusicSystemFunctionHandler(private val context: Context? = null) { } -private class QQMusicLoginHandler(private val context: Context?) : FunctionHandler { - override suspend fun handle(arguments: Map): String { - return try { - val qqMusicSingleton = QQMusicSingleton.getInstance() - var result = "" - - qqMusicSingleton.login { loginResult -> - result = if (loginResult.isSuccess) { - val loginData = loginResult.getOrNull() - "{\"success\": true, \"message\": \"登录成功\", \"data\": {\"openId\": \"${loginData?.openId}\", \"openToken\": \"${loginData?.openToken}\", \"expireTime\": \"${loginData?.expireTime}\"}}" - } else { - val error = loginResult.exceptionOrNull() - "{\"success\": false, \"message\": \"登录失败: ${error?.message}\"}" - } - } - - // 等待回调完成 - delay(3000) - result.ifEmpty { "{\"success\": false, \"message\": \"登录超时\"}" } - } catch (e: Exception) { - Log.e("QQMusicLoginHandler", "QQ音乐登录失败", e) - "{\"success\": false, \"message\": \"登录异常: ${e.message}\"}" - } - } -} private class QQMusicSearchHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { return try { @@ -537,27 +445,3 @@ private class QQMusicGetCurrentSongHandler(private val context: Context?) : Func } } -private class QQMusicGetPlayTimeHandler(private val context: Context?) : FunctionHandler { - override suspend fun handle(arguments: Map): String { - return try { - val qqMusicSingleton = QQMusicSingleton.getInstance() - var result = "" - - qqMusicSingleton.getPlayTime { timeResult -> - result = if (timeResult.isSuccess) { - val timeInfo = timeResult.getOrNull() - "{\"success\": true, \"message\": \"获取播放时间成功\", \"data\": {\"totalTime\": ${timeInfo?.totalTime}, \"currentTime\": ${timeInfo?.currentTime}}}" - } else { - val error = timeResult.exceptionOrNull() - "{\"success\": false, \"message\": \"获取播放时间失败: ${error?.message}\"}" - } - } - - delay(2000) - result.ifEmpty { "{\"success\": false, \"message\": \"获取播放时间超时\"}" } - } catch (e: Exception) { - Log.e("QQMusicLoginHandler", "获取QQ音乐播放时间失败", e) - "{\"success\": false, \"message\": \"获取播放时间异常: ${e.message}\"}" - } - } -} \ No newline at end of file diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift index 79e54376b..4e6c098ea 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift +++ b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift @@ -16,6 +16,7 @@ public class MusicService: NSObject { private var player: AVPlayer? private var playlist: [[String: Any]] = [] private var currentIndex = 0 + private var currentsongid = Int64(0) public var isPlaying = false private var flutterChannel: FlutterMethodChannel? private var progressTimer: Timer? @@ -34,7 +35,7 @@ public class MusicService: NSObject { private override init() { super.init() setupRemoteCommandCenter() - //setupAudioSession() + setupAudioSession() } public func initialize(serviceUrl: String, userToken: String) { @@ -44,15 +45,21 @@ public class MusicService: NSObject { } - // // MARK: - 音频会话设置 - // private func setupAudioSession() { - // do { - // try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.allowAirPlay, .allowBluetooth]) - // try AVAudioSession.sharedInstance().setActive(true) - // } catch { - // print("设置音频会话失败: \(error)") - // } - // } + // MARK: - 音频会话设置 + private func setupAudioSession() { + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default, options: [.allowAirPlay, .allowBluetooth, .allowBluetoothA2DP]) + try audioSession.setActive(true) + + // 确保媒体控制中心可以接收到我们的应用 + setupRemoteCommandCenter() + + print("音频会话和媒体控制设置成功") + } catch { + print("设置音频会话失败: \(error)") + } + } // MARK: - 基础播放控制 func getCurrentUrlString() -> String? { @@ -144,9 +151,7 @@ public class MusicService: NSObject { "id": songObj["id"] as? Int64, "name": songObj["name"] as? String, "singer": songObj["singer"] as? String, - "url": songObj["url"] as? String, "image": songObj["image"] as? String, - "ts": songObj["ts"] as? Int64 ]) } @@ -154,17 +159,203 @@ public class MusicService: NSObject { } - public func play(song: [String: Any]) { - guard let urlString = song["url"] as? String, let url = URL(string: urlString) else { return } + // MARK: - 获取歌曲URL + public func getSongUrl(id: Int64,curragnt:Int,allagent:Int, completion: @escaping (Result<[String: Any?], Error>) -> Void) { + guard !serviceUrl.isEmpty, let url = URL(string: "\(serviceUrl)/api/home/music_url") else { + completion(.failure(NSError(domain: "MusicService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Service URL is not configured"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(userToken, forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30.0 + + let body: [String: Any] = [ + "id": id + ] + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) + } catch { + if (allagent > 0 && curragnt < allagent){ + getSongUrl(id: id,curragnt: curragnt+1,allagent: allagent,completion: completion) + }else{ + completion(.failure(error)) + } + + return + } + + print("发送获取URL请求: id=\(id)") + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + print("获取URL请求失败: \(error.localizedDescription)") + if (allagent > 0 && curragnt < allagent){ + self.getSongUrl(id: id,curragnt: curragnt+1,allagent: allagent,completion: completion) + }else{ + completion(.failure(error)) + } + return + } + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + print("获取URL失败,HTTP状态码: \(statusCode)") + if (allagent > 0 && curragnt < allagent){ + self.getSongUrl(id: id,curragnt: curragnt+1,allagent: allagent,completion: completion) + }else{ + completion(.failure(NSError(domain: "MusicService", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "获取URL失败,HTTP状态码: \(statusCode)"]))) + } + return + } + + guard let data = data else { + print("获取URL响应数据为空") + if (allagent > 0 && curragnt < allagent){ + self.getSongUrl(id: id,curragnt: curragnt+1,allagent: allagent,completion: completion) + }else{ + completion(.failure(NSError(domain: "MusicService", code: -2, userInfo: [NSLocalizedDescriptionKey: "响应数据为空"]))) + } + return + } + + do { + if let jsonResponse = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + let urlData = self.parseUrlFromResponse(jsonResponse: jsonResponse) + print("获取URL成功: \(urlData)") + if (!urlData.isEmpty){ + completion(.success(urlData)) + }else{ + if (allagent > 0 && curragnt < allagent){ + self.getSongUrl(id: id,curragnt: curragnt+1,allagent: allagent,completion: completion) + }else{ + completion(.failure(NSError(domain: "MusicService", code: -3, userInfo: [NSLocalizedDescriptionKey: "获取连接失败!"]))) + } + } + } else { + if (allagent > 0 && curragnt < allagent){ + self.getSongUrl(id: id,curragnt: curragnt+1,allagent: allagent,completion: completion) + }else{ + completion(.failure(NSError(domain: "MusicService", code: -3, userInfo: [NSLocalizedDescriptionKey: "解析JSON响应失败"]))) + } + } + } catch { + print("解析获取URL响应时发生异常: \(error.localizedDescription)") + if (allagent > 0 && curragnt < allagent){ + self.getSongUrl(id: id,curragnt: curragnt+1,allagent: allagent,completion: completion) + }else{ + completion(.failure(error)) + } + } + } + + task.resume() + } + + private func parseUrlFromResponse(jsonResponse: [String: Any]) -> [String: Any?] { + var urlData: [String: Any?] = [:] + + guard let data = jsonResponse["data"] as? [String: Any] else { + return urlData + } + urlData["id"] = data["id"] as? Int64 + urlData["url"] = data["url"] as? String + urlData["ts"] = data["ts"] as? Int64 + + return urlData + } + + public func playPlaylist(songs: [[String: Any]]) { + playlist = songs + currentIndex = 0 + if !playlist.isEmpty { + play(song: playlist[currentIndex]) + } + } + + + public func play(song: [String: Any]) { // 获取歌曲ID - if let id = song["id"] as? NSNumber { - currentIdInternal = id.int64Value + let id: Int64 + if let songId = song["id"] as? NSNumber { + id = songId.int64Value + currentIdInternal = id } else { + id = 0 currentIdInternal = 0 } + currentsongid = id + print("开始播放play id: \(id)") + + // 检查 URL 和时间戳 + let url = song["url"] as? String + let ts = song["ts"] as? Int64 + let currentTime = Int64(Date().timeIntervalSince1970) // 当前时间戳(秒) + + // 判断是否需要重新获取 URL + let needNewUrl = url?.isEmpty != false || ts == nil || ts! <= currentTime + + if needNewUrl { + print("URL 不存在或已过期,重新获取 URL") + // 调用 getSongUrl 获取新的 URL + getSongUrl(id: id,curragnt: 0,allagent: 3) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let urlData): + if let newUrl = urlData["url"] as? String, !newUrl.isEmpty { + // 更新 song 数据 + var updatedSong = song + updatedSong["url"] = newUrl + updatedSong["ts"] = urlData["ts"] ?? 0 + + // 更新 playlist 中对应的歌曲数据 + self?.updatePlaylistSong(songId: id, updatedSong: updatedSong) + + if (self?.currentsongid == id) { + // 使用新 URL 开始播放 + self?.startPlayback(song: updatedSong, url: newUrl) + } + + } else { + print("获取到的 URL 为空") + } + case .failure(let error): + print("获取 URL 失败: \(error.localizedDescription)") + } + } + } + } else { + // URL 有效,直接播放 + startPlayback(song: song, url: url!) + } + } + + // 新增方法:更新 playlist 中指定 ID 的歌曲数据 + private func updatePlaylistSong(songId: Int64, updatedSong: [String: Any]) { + for i in 0.. { // 已授权,直接请求登录信息 Log.d(TAG, "已授权,直接获取登录信息") - callback.onSuccess("","","") -// requestAuth(callback) + isBindQQMusicService = true + callback.onSuccess() } ErrorCodes.ERROR_API_NO_PERMISSION -> { // 需要授权 @@ -110,12 +115,14 @@ class QQMusicLoginManager(private val context: Context) { startAuthProcess(callback) } else -> { + isBindQQMusicService = false Log.e(TAG, "检查授权状态失败: $code") callback.onError("AUTH_CHECK_FAILED", "检查授权状态失败") } } } catch (e: Exception) { Log.e(TAG, "检查授权状态异常", e) + isBindQQMusicService = false callback.onError("AUTH_CHECK_ERROR", "检查授权状态异常: ${e.message}") } } @@ -172,7 +179,7 @@ class QQMusicLoginManager(private val context: Context) { }, failed = { errorCode, errorMsg -> Log.e(TAG, "授权失败: code=$errorCode, msg=$errorMsg") - + isBindQQMusicService = false val errorMessage = when (errorCode) { LOGIN_FAILED -> "登录失败: $errorMsg" else -> "授权失败: $errorMsg" @@ -197,22 +204,23 @@ class QQMusicLoginManager(private val context: Context) { // 检查签名 if (OpenIDHelper.checkQMSign(sign, returnNonce)) { - val openId = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_ID) - val openToken = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_TOKEN) - val expireTime = appDecryptJson.getString(Keys.API_PARAM_KEY_SDK_EXPIRETIME) - - Log.d(TAG, "登录成功: openId=$openId") - +// val openId = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_ID) +// val openToken = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_TOKEN) +// val expireTime = appDecryptJson.getString(Keys.API_PARAM_KEY_SDK_EXPIRETIME) +// Log.d(TAG, "登录成功: openId=$openId") + isBindQQMusicService = true handler.post { - callback.onSuccess(openId, openToken, expireTime) + callback.onSuccess() } } else { + isBindQQMusicService = false Log.e(TAG, "签名验证失败") handler.post { callback.onError("SIGN_ERROR", "签名验证失败") } } } else { + isBindQQMusicService = false Log.e(TAG, "解密失败") handler.post { callback.onError("DECRYPT_ERROR", "解密失败") @@ -220,6 +228,7 @@ class QQMusicLoginManager(private val context: Context) { } } catch (e: Exception) { Log.e(TAG, "解析登录结果失败", e) + isBindQQMusicService = false handler.post { callback.onError("PARSE_ERROR", "解析登录结果失败: ${e.message}") } diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt index 822a6cc06..414d46856 100644 --- a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt @@ -39,38 +39,27 @@ class QQMusicManager(private val context: Context) { fun onSuccess(data: Map) fun onError(errorCode: Int, errorMessage: String) } - - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - Log.d(TAG, "QQ音乐服务连接成功") - qqmusicApi = IQQMusicApi.Stub.asInterface(service) - isBindQQMusicService = true - - // 初始化SDK - CommonCmd.init(Config.BIND_PLATFORM) - - // 延迟发送hi消息,确保Binder连接稳定 - Handler(Looper.getMainLooper()).postDelayed({ - sayHi() - }, 500) // 延迟500ms - } - - override fun onServiceDisconnected(name: ComponentName?) { - Log.d(TAG, "QQ音乐服务连接断开") - qqmusicApi = null - isBindQQMusicService = false - //重连绑定 - bindQQMusicApiService(); - } + + // 新增:绑定服务的回调接口 + interface BindCallback { + fun onSuccess() + fun onError(errorCode: Int, errorMessage: String) } + init { - bindQQMusicApiService() + // bindQQMusicApiService() } - + + //是否连接 + public val isConnected: Boolean + get() = isBindQQMusicService && qqmusicApi != null + + + private fun sayHi() { if (!isBindQQMusicService || qqmusicApi == null) { - Log.w(TAG, "QQ音乐服务未连接,无法发送hi消息") + Log.w(TAG, "连接QQ音乐失败,可能是QQ音乐帐号状态异常,请打开QQ音乐检查帐号状态是否异常?") return } @@ -97,9 +86,9 @@ class QQMusicManager(private val context: Context) { } } - fun bindQQMusicApiService() { + fun bindQQMusicApiService(callback: BindCallback? = null) { Log.d(TAG, "开始绑定QQ音乐服务,平台类型: ${Config.BIND_PLATFORM}") - + val intent = when (Config.BIND_PLATFORM) { CommonCmd.AIDL_PLATFORM_TYPE_PHONE -> { Intent("com.tencent.qqmusic.third.api.QQMusicApiService").apply { @@ -118,6 +107,7 @@ class QQMusicManager(private val context: Context) { } else -> { Log.e(TAG, "不支持的平台类型: ${Config.BIND_PLATFORM}") + callback?.onError(-1, "不支持的平台类型: ${Config.BIND_PLATFORM}") return } } @@ -125,22 +115,71 @@ class QQMusicManager(private val context: Context) { Log.d(TAG, "绑定Intent: action=${intent.action}, package=${intent.`package`}") try { - val bindResult = context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + val bindResult = context.bindService(intent, object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "QQ音乐服务连接成功") + qqmusicApi = IQQMusicApi.Stub.asInterface(service) + isBindQQMusicService = true + + // 初始化SDK + CommonCmd.init(Config.BIND_PLATFORM) + + // 延迟发送hi消息,确保Binder连接稳定 + Handler(Looper.getMainLooper()).postDelayed({ + sayHi() + // 绑定成功后调用回调 + callback?.onSuccess() + }, 500) // 延迟500ms + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "QQ音乐服务连接断开") + qqmusicApi = null + isBindQQMusicService = false + // 服务断开时通知错误 + callback?.onError(-2, "QQ音乐服务连接断开") + //重连绑定 + bindQQMusicApiService(callback) + } + }, Context.BIND_AUTO_CREATE) + Log.d(TAG, "bindService结果: $bindResult") - + if (!bindResult) { Log.e(TAG, "绑定QQ音乐服务失败,尝试启动QQ音乐进程") // 尝试启动QQ音乐进程 CommonCmd.startQQMusicProcess(context, context.packageName) - + // 延迟重试绑定 Handler(Looper.getMainLooper()).postDelayed({ - Log.d(TAG, "重试绑定QQ音乐服务") - context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + val retryResult = context.bindService(intent, object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "QQ音乐服务连接成功") + qqmusicApi = IQQMusicApi.Stub.asInterface(service) + isBindQQMusicService = true + // 初始化SDK + CommonCmd.init(Config.BIND_PLATFORM) + // 延迟发送hi消息,确保Binder连接稳定 + Handler(Looper.getMainLooper()).postDelayed({ + sayHi() + callback?.onSuccess() + }, 500) // 延迟500ms + } + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "QQ音乐服务连接断开") + qqmusicApi = null + isBindQQMusicService = false + } + }, Context.BIND_AUTO_CREATE) + Log.d(TAG, "重试绑定QQ音乐服务 $retryResult") + if (!retryResult) { + callback?.onError(-3, "重试绑定QQ音乐服务失败") + } }, 2000) } } catch (e: Exception) { Log.e(TAG, "绑定QQ音乐服务异常", e) + callback?.onError(-4, "绑定QQ音乐服务异常: ${e.message}") } } @@ -659,7 +698,6 @@ class QQMusicManager(private val context: Context) { fun cleanup() { try { if (isBindQQMusicService) { - context.unbindService(serviceConnection) isBindQQMusicService = false Log.d(TAG, "QQ音乐服务已解绑") } diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt index 53ff7b3e8..9b9762381 100644 --- a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt @@ -8,8 +8,6 @@ import android.os.Handler import android.os.Looper import android.util.Log import com.tencent.qqmusic.third.api.contract.* -import java.text.SimpleDateFormat -import java.util.* /** * QQ音乐单例管理器 @@ -23,12 +21,7 @@ class QQMusicSingleton private constructor() { private const val KEY_OPEN_ID = "open_id" private const val KEY_OPEN_TOKEN = "open_token" private const val KEY_EXPIRE_TIME = "expire_time" - private const val LOGIN_WAIT_DELAY_MS = 30000L - // 添加搜索重试相关常量 - private const val SEARCH_TIMEOUT_MS = 10000L - private const val MAX_RETRY_COUNT = 1 - private const val RETRY_DELAY_MS = 1000L - + @Volatile private var INSTANCE: QQMusicSingleton? = null @@ -53,9 +46,9 @@ class QQMusicSingleton private constructor() { */ fun initialize(context: Context) { this.context = context - this.sharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - loadSavedLoginInfo() - Log.d(TAG, "QQ音乐单例初始化完成") + // this.sharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + // loadSavedLoginInfo() + // Log.d(TAG, "QQ音乐单例初始化完成") } /** * 检查QQ音乐App是否已安装 @@ -87,58 +80,54 @@ class QQMusicSingleton private constructor() { * 检查是否已登录且未过期 */ fun isLoggedIn(): Boolean { - if (currentLoginResult == null){ - loadSavedLoginInfo() - if (currentLoginResult == null){ - return false - } - return true - }else{ - return true - } + return loginManager != null && loginManager!!.isLogined } - - /** - * 获取当前登录信息 - */ - fun getCurrentLoginInfo(): LoginResult? { - return if (isLoggedIn()) currentLoginResult else null - } - + /** * 登录接口 */ - fun login(callback: (Result) -> Unit) { + fun login(callback: (Result) -> Unit) { if (!isQQMusicInstalled()){ callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件"))) return } - val currentActivity = context + val currentContext = context // 先检查是否已经登录且未过期 if (isLoggedIn()) { Log.d(TAG, "用户已登录且未过期,直接返回登录信息") - ensureMusicManager(currentActivity!!) - callback(Result.success(currentLoginResult!!)) + ensureMusicManager(currentContext!!,object: QQMusicManager. BindCallback{ + override fun onSuccess() { + callback(Result.success(Unit)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + callback(Result.failure(Exception("no logined $errorMessage"))) + } + }) + return } - if (currentActivity == null) { + if (currentContext == null) { callback(Result.failure(Exception("Activity not available"))) return } Log.d(TAG, "开始新的登录流程") - loginManager = QQMusicLoginManager(currentActivity) + loginManager = QQMusicLoginManager(currentContext) loginManager?.startLogin(object : QQMusicLoginManager.LoginCallback { - override fun onSuccess(openId: String, openToken: String, expireTime: String) { + override fun onSuccess() { handler.post { - ensureMusicManager(currentActivity!!) - val result = LoginResult(openId, openToken, expireTime) - currentLoginResult = result - saveLoginInfo(result) - Log.d(TAG, "登录成功并已保存: $result") - callback(Result.success(result)) + ensureMusicManager(currentContext,object: QQMusicManager. BindCallback{ + override fun onSuccess() { + callback(Result.success(Unit)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + callback(Result.failure(Exception("no logined $errorMessage"))) + } + }) } } @@ -164,8 +153,16 @@ class QQMusicSingleton private constructor() { // 先检查是否已经登录且未过期 if (isLoggedIn()) { Log.d(TAG, "用户已登录且未过期,直接返回登录信息") - ensureMusicManager(currentActivity!!) - callback(Result.success(currentLoginResult!!)) + ensureMusicManager(currentActivity!!,object: QQMusicManager. BindCallback{ + override fun onSuccess() { + callback(Result.success(currentLoginResult!!)) + } + + override fun onError(errorCode: Int, errorMessage: String) { + callback(Result.failure(Exception("no logined $errorMessage"))) + } + }) + return }else{ callback(Result.failure(Exception("no logined"))) @@ -190,129 +187,12 @@ class QQMusicSingleton private constructor() { searchType: Int = Data.SearchType.SEARCH_TYPE_SONG, callback: (Result>>) -> Unit ) { - if (!isQQMusicInstalled()){ - callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件"))) - return - } - searchWithRetry(keyword, searchType, 1, callback) - } - - /** - * 带重试的搜索实现 - */ - private fun searchWithRetry( - keyword: String, - searchType: Int, - retryCount: Int, - callback: (Result>>) -> Unit - ) { - val currentActivity = context - if (currentActivity == null) { - callback(Result.failure(Exception("Activity not available"))) - return - } - - // 检查登录状态 - if (!isLoggedIn()) { - Log.d(TAG, "搜索前检测到未登录,开始自动登录") - login { loginResult -> - if (loginResult.isSuccess) { - Log.d(TAG, "自动登录成功,等待QQ音乐服务连接...") - handler.postDelayed({ - Log.d(TAG, "等待完成,开始搜索") - performSearchWithTimeout(keyword, searchType, retryCount, callback) - }, LOGIN_WAIT_DELAY_MS) - } else { - callback(Result.failure(Exception("自动登录失败: ${loginResult.exceptionOrNull()?.message}"))) - } - } - } else { - performSearchWithTimeout(keyword, searchType, retryCount, callback) - } - } - - /** - * 执行带超时的搜索操作 - */ - private fun performSearchWithTimeout( - keyword: String, - searchType: Int, - retryCount: Int, - callback: (Result>>) -> Unit - ) { - val currentActivity = context ?: return - val searchKey = "${keyword}_${System.currentTimeMillis()}" - var completed = false - - Log.d(TAG, "开始搜索: $keyword (第${retryCount + 1}次)") - - // 设置超时 - val timeoutRunnable = Runnable { - if (!completed) { - completed = true - searchTimeouts.remove(searchKey) - Log.w(TAG, "搜索超时: $keyword") - - if (retryCount < MAX_RETRY_COUNT) { - Log.d(TAG, "准备重试: ${retryCount + 1}/$MAX_RETRY_COUNT") - handler.postDelayed({ - searchWithRetry(keyword, searchType, retryCount + 1, callback) - }, RETRY_DELAY_MS) - } else { - callback(Result.failure(Exception("搜索超时,已重试${MAX_RETRY_COUNT}次"))) - } - } - } - - searchTimeouts[searchKey] = timeoutRunnable - handler.postDelayed(timeoutRunnable, SEARCH_TIMEOUT_MS) - - ensureMusicManager(currentActivity) - musicManager?.searchSongs(keyword, searchType, object : QQMusicManager.SearchCallback { - override fun onSuccess(songs: List>) { - handler.post { - if (!completed) { - completed = true - searchTimeouts.remove(searchKey)?.let { handler.removeCallbacks(it) } - Log.d(TAG, "搜索成功: 找到 ${songs.size} 首歌曲") - callback(Result.success(songs)) - } - } - } - - override fun onError(errorCode: Int, errorMessage: String) { - handler.post { - if (!completed) { - completed = true - searchTimeouts.remove(searchKey)?.let { handler.removeCallbacks(it) } - Log.e(TAG, "搜索失败: code=$errorCode, message=$errorMessage") - - // 如果是登录相关错误,清除本地登录信息 - if (errorMessage.contains("登录") || errorMessage.contains("授权")) { - Log.d(TAG, "检测到登录相关错误,清除本地登录信息") - logout() - } - - // 网络相关错误才重试 - if (retryCount < MAX_RETRY_COUNT && - (errorMessage.contains("网络") || errorMessage.contains("超时") || - errorMessage.contains("连接") || errorCode == -1)) { - Log.d(TAG, "网络错误,准备重试: ${retryCount + 1}/$MAX_RETRY_COUNT") - if (errorCode == -1){ - musicManager?.bindQQMusicApiService() - } - handler.postDelayed({ - searchWithRetry(keyword, searchType, retryCount + 1, callback) - }, RETRY_DELAY_MS) - } else { - callback(Result.failure(Exception("搜索失败: $errorMessage"))) - } - } - } - } - }) + executeSearchbackAction("搜索歌曲",0,2) { musicManager, searchCallback -> + musicManager.searchSongs(keyword,searchType,searchCallback) + }(callback) } + /** * 播放歌曲列表(带自动登录检查) */ @@ -321,8 +201,8 @@ class QQMusicSingleton private constructor() { index: Int = 0, callback: (Result) -> Unit ) { - Log.e(TAG, "播放音乐列表: $songMids") - executePlaybackAction("播放列表") { musicManager, playCallback -> + executePlaybackAction("播放列表",0,2) { musicManager, playCallback -> + Log.d(TAG, "播放歌曲:${songMids[index]}") musicManager.playSongs(songMids, index,playCallback) }(callback) } @@ -331,7 +211,7 @@ class QQMusicSingleton private constructor() { * 播放音乐 */ fun playMusic(callback: (Result) -> Unit) { - executePlaybackAction("播放") { musicManager, playCallback -> + executePlaybackAction("播放",0,2) { musicManager, playCallback -> musicManager.playMusic(playCallback) }(callback) } @@ -386,11 +266,12 @@ class QQMusicSingleton private constructor() { } }) } + /** * 恢复播放 */ fun resumeMusic(callback: (Result) -> Unit) { - executePlaybackAction("恢复播放") { musicManager, playCallback -> + executePlaybackAction("恢复播放",0,0) { musicManager, playCallback -> musicManager.resumeMusic(playCallback) }(callback) } @@ -399,7 +280,7 @@ class QQMusicSingleton private constructor() { * 停止音乐 */ fun stopMusic(callback: (Result) -> Unit) { - executePlaybackAction("停止") { musicManager, playCallback -> + executePlaybackAction("停止",0,0) { musicManager, playCallback -> musicManager.stopMusic(playCallback) }(callback) } @@ -408,7 +289,7 @@ class QQMusicSingleton private constructor() { * 下一首 */ fun skipToNext(callback: (Result) -> Unit) { - executePlaybackAction("下一首") { musicManager, playCallback -> + executePlaybackAction("下一首",0,0) { musicManager, playCallback -> musicManager.skipToNext(playCallback) }(callback) } @@ -417,7 +298,7 @@ class QQMusicSingleton private constructor() { * 上一首 */ fun skipToPrevious(callback: (Result) -> Unit) { - executePlaybackAction("上一首") { musicManager, playCallback -> + executePlaybackAction("上一首",0,0) { musicManager, playCallback -> musicManager.skipToPrevious(playCallback) }(callback) } @@ -443,60 +324,20 @@ class QQMusicSingleton private constructor() { /** * 获取播放时间 */ - fun getPlayTime(callback: (Result) -> Unit) { - val currentActivity = context - if (currentActivity == null) { - callback(Result.failure(Exception("Activity not available"))) - return - } - - ensureMusicManager(currentActivity) - musicManager?.getPlayTime(object : QQMusicManager.InfoCallback { - override fun onSuccess(data: Map) { - handler.post { - val totalTime = data["totalTime"] as? Long ?: 0L - val currentTime = data["currentTime"] as? Long ?: 0L - val playTimeInfo = PlayTimeInfo(totalTime, currentTime) - Log.d(TAG, "获取播放时间成功: $playTimeInfo") - callback(Result.success(playTimeInfo)) - } - } - - override fun onError(errorCode: Int, errorMessage: String) { - handler.post { - Log.e(TAG, "获取播放时间失败: $errorMessage") - callback(Result.failure(Exception("获取播放时间失败: $errorMessage"))) - } - } - }) + fun getPlayTime(callback: (Result>) -> Unit) { + executeInfoAction("获取播放时间") { musicManager, infoCallback -> + musicManager.getPlayTime(infoCallback) + }(callback) + } /** * 同步当前播放信息 - 统一接口 */ fun syncCurrentPlayInfo(callback: (Result>) -> Unit) { - val currentcontext = context - if (currentcontext == null) { - callback(Result.failure(Exception("Activity not available"))) - return - } - - ensureMusicManager(currentcontext) - musicManager?.syncCurrentPlayInfo(object : QQMusicManager.InfoCallback { - override fun onSuccess(data: Map) { - handler.post { - Log.d(TAG, "同步播放信息成功") - callback(Result.success(data)) - } - } - - override fun onError(errorCode: Int, errorMessage: String) { - handler.post { - Log.e(TAG, "同步播放信息失败: $errorMessage") - callback(Result.failure(Exception("同步播放信息失败: $errorMessage"))) - } - } - }) + executeInfoAction("同步当前播放信息") { musicManager, infoCallback -> + musicManager.syncCurrentPlayInfo(infoCallback) + }(callback) } /** @@ -561,14 +402,81 @@ class QQMusicSingleton private constructor() { } // 私有辅助方法 - private fun ensureMusicManager(context: Context) { + private fun ensureMusicManager(context: Context,callback: QQMusicManager. BindCallback) { if (musicManager == null) { musicManager = QQMusicManager(context) + musicManager!!.bindQQMusicApiService(callback) + }else{ + if(!musicManager!!.isConnected) { //未连接 + musicManager?.bindQQMusicApiService(callback) + }else{ + callback.onSuccess() + } } } - + + + private fun executeSearchbackAction( + actionName: String, + curragain:Int, + allagain:Int, + action: (QQMusicManager, QQMusicManager.SearchCallback) -> Unit + ): (callback: (Result>>) -> Unit) -> Unit { + return lambda@{ callback -> + + if (!isQQMusicInstalled()){ + callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件"))) + return@lambda + } + + val currentActivity = context + if (currentActivity == null) { + callback(Result.failure(Exception("Activity not available"))) + return@lambda + } + Log.d(TAG, "开始 ${actionName}") + ensureMusicManager(currentActivity,object: QQMusicManager. BindCallback{ + override fun onSuccess(){ + action(musicManager!!, object : QQMusicManager.SearchCallback { + override fun onSuccess(songs: List>) { + Log.d(TAG, "${actionName} 成功") + handler.post { + callback(Result.success(songs)) + } + } + + override fun onError(errorCode: Int, errorMessage: String) { + Log.d(TAG, "失败 ${actionName} 成功") + if (allagain > 0 && curragain < allagain){ //重试机制 + executeSearchbackAction(actionName,curragain+1,allagain,action) + }else{ + handler.post { + callback(Result.failure(Exception("$actionName 失败: $errorMessage"))) + } + } + } + }) + } + override fun onError(errorCode: Int, errorMessage: String) { + if (allagain > 0 && curragain < allagain){ //重试机制 + executeSearchbackAction(actionName,curragain+1,allagain,action) + }else{ + handler.post { + callback(Result.failure(Exception("$actionName 失败: $errorMessage"))) + } + } + } + }) + + } + } + + + private fun executePlaybackAction( actionName: String, + curragain:Int, + allagain:Int, action: (QQMusicManager, QQMusicManager.PlayCallback) -> Unit ): (callback: (Result) -> Unit) -> Unit { return lambda@{ callback -> @@ -583,30 +491,42 @@ class QQMusicSingleton private constructor() { callback(Result.failure(Exception("Activity not available"))) return@lambda } - - ensureMusicManager(currentActivity) - action(musicManager!!, object : QQMusicManager.PlayCallback { - override fun onSuccess() { - handler.post { - Log.d(TAG, "${actionName}成功") - callback(Result.success(Unit)) - } - } + Log.d(TAG, "开始 ${actionName}") + ensureMusicManager(currentActivity,object: QQMusicManager. BindCallback{ + override fun onSuccess(){ + action(musicManager!!, object : QQMusicManager.PlayCallback { + override fun onSuccess() { + Log.d(TAG, "${actionName}成功") + handler.post { + callback(Result.success(Unit)) + } + } + + override fun onError(errorCode: Int, errorMessage: String) { + if (allagain > 0 && curragain < allagain){ //重试机制 + executePlaybackAction(actionName,curragain+1,allagain,action) + }else{ + handler.post { + Log.e(TAG, "${actionName}失败: $errorCode $errorMessage") + if (errorCode == -1){ + musicManager?.bindQQMusicApiService() + callback(Result.failure(Exception("播放失败: $errorMessage"))) + } else if (errorCode == 1030) { + callback(Result.success(Unit)) + }else{ + callback(Result.failure(Exception("播放失败: $errorMessage"))) + } + } + } - override fun onError(errorCode: Int, errorMessage: String) { - handler.post { - Log.e(TAG, "播放失败: $errorCode $errorMessage") - if (errorCode == -1){ - musicManager?.bindQQMusicApiService() - callback(Result.failure(Exception("播放失败: $errorMessage"))) - } else if (errorCode == 1030) { - callback(Result.success(Unit)) - }else{ - callback(Result.failure(Exception("播放失败: $errorMessage"))) } - } + }) + } + override fun onError(errorCode: Int, errorMessage: String) { + callback(Result.failure(Exception("$actionName 失败: $errorMessage"))) } }) + } } @@ -615,26 +535,36 @@ class QQMusicSingleton private constructor() { action: (QQMusicManager, QQMusicManager.InfoCallback) -> Unit ): (callback: (Result>) -> Unit) -> Unit { return lambda@{ callback -> + if (!isQQMusicInstalled()){ + callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件"))) + return@lambda + } val currentcontext = context if (currentcontext == null) { callback(Result.failure(Exception("Activity not available"))) return@lambda } - - ensureMusicManager(currentcontext) - action(musicManager!!, object : QQMusicManager.InfoCallback { - override fun onSuccess(data: Map) { - handler.post { - Log.d(TAG, "${actionName}成功") - callback(Result.success(data)) - } + + ensureMusicManager(currentcontext,object: QQMusicManager. BindCallback{ + override fun onSuccess(){ + action(musicManager!!, object : QQMusicManager.InfoCallback { + override fun onSuccess(data: Map) { + handler.post { + Log.d(TAG, "${actionName}成功") + callback(Result.success(data)) + } + } + + override fun onError(errorCode: Int, errorMessage: String) { + handler.post { + Log.e(TAG, "${actionName}失败: $errorMessage") + callback(Result.failure(Exception("${actionName}失败: $errorMessage"))) + } + } + }) } - override fun onError(errorCode: Int, errorMessage: String) { - handler.post { - Log.e(TAG, "${actionName}失败: $errorMessage") - callback(Result.failure(Exception("${actionName}失败: $errorMessage"))) - } + callback(Result.failure(Exception("$actionName 失败: $errorMessage"))) } }) } diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt index 62f8c40a2..726bcd451 100644 --- a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt @@ -203,22 +203,6 @@ class QqMusicPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { ) } } - "getPlayTime" -> { - qqMusicSingleton.getPlayTime { kotlinResult -> - kotlinResult.fold( - onSuccess = { playTimeInfo -> - result.success(mapOf( - "success" to true, - "totalTime" to playTimeInfo.totalTime, - "currentTime" to playTimeInfo.currentTime - )) - }, - onFailure = { error -> - result.error("GET_TIME_FAILED", error.message, null) - } - ) - } - } "syncCurrentPlayInfo" -> { qqMusicSingleton.syncCurrentPlayInfo { kotlinResult -> kotlinResult.fold( diff --git a/pubspec.yaml b/pubspec.yaml index e751796d5..0346ef700 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: voitrans description: "Voitrans - AI Voice Assistant." publish_to: "none" -version: 1.0.12+29 +version: 1.0.13+34 environment: sdk: ">=3.3.0 <4.0.0"