From 6afbd5d622ed1336db3379fff078a7700aadba39 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 30 Aug 2025 00:00:44 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(permission):=20=EF=BC=88=E9=92=88?= =?UTF-8?q?=E5=AF=B9=E9=83=A8=E5=88=86=E5=8D=8E=E4=B8=BA=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=EF=BC=89=E6=B7=BB=E5=8A=A0=E6=9D=83=E9=99=90=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E6=AC=A1=E6=95=B0=E7=BB=9F=E8=AE=A1=E5=92=8C=E7=86=94=E6=96=AD?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加智能拒绝次数统计功能,当拒绝次数达到阈值时直接引导用户去设置 引入 GetStorage 持久化存储拒绝次数 新增重置拒绝次数和获取当前拒绝次数的方法 优化权限请求流程,在授权时自动清零拒绝记录 --- lib/core/utils/permission_util.dart | 120 ++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 26 deletions(-) diff --git a/lib/core/utils/permission_util.dart b/lib/core/utils/permission_util.dart index 1f19ff396..abe4cb80d 100644 --- a/lib/core/utils/permission_util.dart +++ b/lib/core/utils/permission_util.dart @@ -3,6 +3,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:app_settings/app_settings.dart'; import 'logger.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; /// 权限请求工具类 /// @@ -11,6 +12,7 @@ import 'package:get/get.dart'; /// 2. 权限解释横幅显示(悬浮在UI顶部的解释性UI) /// 3. 权限请求执行 /// 4. 永久拒绝后的设置引导 +/// 5. 智能拒绝次数统计和熔断机制 /// /// 使用单例模式:`PermissionUtil.instance` @@ -24,6 +26,30 @@ class PermissionUtil { /// 权限解释横幅的叠加层入口 //OverlayEntry? _explanationOverlayEntry; + /// 引入 get_storage 用于持久化权限拒绝次数 + final _storage = GetStorage(); + + /// 权限拒绝次数阈值(达到此次数后视为永久拒绝) + static const int _denialThreshold = 2; + + /// 获取权限拒绝次数的存储键 + String _getDenialCountKey(Permission permission) { + return 'permission_denial_count_${permission.toString()}'; + } + + /// 重置指定权限的拒绝次数 + /// + /// 在某些场景下可能需要手动重置拒绝次数,比如用户主动要求重新请求权限 + Future resetDenialCount(Permission permissionType) async { + await _storage.remove(_getDenialCountKey(permissionType)); + Logger.d('PermissionUtil', '已重置 ${permissionType.toString()} 的拒绝次数'); + } + + /// 获取指定权限的拒绝次数 + int getDenialCount(Permission permissionType) { + return _storage.read(_getDenialCountKey(permissionType)) ?? 0; + } + /// 请求指定权限 /// /// 参数: @@ -59,34 +85,75 @@ class PermissionUtil { if (status == PermissionStatus.granted || status == PermissionStatus.provisional) { Logger.i('PermissionUtil', '$permissionName 权限已授予或临时授权'); + // 如果已经授权,清零之前的拒绝记录,以防用户在设置中开启后又关闭 + await _storage.remove(_getDenialCountKey(permissionType)); return true; } - // 显示权限解释横幅(覆盖所有状态的处理) - _showExplanationOverlay( - //context: context, - permissionName: permissionName, - explanationText: explanationText, - ); + // 2. 读取历史拒绝次数 + final denialCountKey = _getDenialCountKey(permissionType); + int denialCount = _storage.read(denialCountKey) ?? 0; + Logger.d('PermissionUtil', '$permissionName 当前拒绝次数: $denialCount'); - // 处理永久拒绝状态(先显示说明横幅,再引导用户去设置) - if (status == PermissionStatus.permanentlyDenied) { - Logger.w('PermissionUtil', '$permissionName 权限被永久拒绝,显示引导对话框'); + // 3. 熔断机制:如果拒绝次数达到阈值或系统返回了 permanentlyDenied,直接视为永久拒绝 + if (denialCount >= _denialThreshold || + status == PermissionStatus.permanentlyDenied) { + Logger.w('PermissionUtil', + '$permissionName 已被拒绝 $denialCount 次或被系统永久拒绝,触发设置引导'); - // 显示永久拒绝对话框(在解释横幅下方显示) + // 显示权限解释横幅 + _showExplanationOverlay( + permissionName: permissionName, + explanationText: explanationText, + ); + + // 显示引导去设置的对话框 final result = await _showPermanentDenialDialog( - // context: context, permissionName: permissionName, permanentDenialText: permanentDenialText, settingsButtonText: finalSettingsButtonText, cancelButtonText: finalCancelButtonText, ); + // 移除解释横幅 _hideExplanationOverlay(); return result; } - // 处理初始拒绝/未决定状态 + // 显示权限解释横幅(覆盖所有状态的处理) + _showExplanationOverlay( + //context: context, + permissionName: permissionName, + explanationText: explanationText, + ); + + // 处理永久拒绝状态(先显示说明横幅,再引导用户去设置) + // if (status == PermissionStatus.permanentlyDenied) { + // Logger.w('PermissionUtil', '$permissionName 权限被永久拒绝,显示引导对话框'); + + // // 显示永久拒绝对话框(在解释横幅下方显示) + // final result = await _showPermanentDenialDialog( + // // context: context, + // permissionName: permissionName, + // permanentDenialText: permanentDenialText, + // settingsButtonText: finalSettingsButtonText, + // cancelButtonText: finalCancelButtonText, + // ); + // // 移除解释横幅 + // _hideExplanationOverlay(); + // return result; + // } + + // 处理受限状态 + if (status == PermissionStatus.restricted || + status == PermissionStatus.limited) { + Logger.w('PermissionUtil', '$permissionName 权限受限或受限访问: $status'); + await _showRestrictedPermissionDialog(permissionName); + _hideExplanationOverlay(); + return false; + } + + // 处理初始拒绝/未决定状态(真正的权限请求) if (status == PermissionStatus.denied) { Logger.d('PermissionUtil', '$permissionName 权限被拒绝或未决定,显示说明横幅并请求'); // _showExplanationOverlay( @@ -94,19 +161,22 @@ class PermissionUtil { // permissionName: permissionName, // explanationText: explanationText, // ); -//请求权限 + //请求权限 final PermissionStatus newStatus = await permissionType.request(); Logger.d('PermissionUtil', '请求后 $permissionName 的新权限状态: $newStatus'); //_hideExplanationOverlay(); - // 处理请求后的状态 + // 5. 根据结果更新状态和拒绝次数 if (newStatus == PermissionStatus.granted || newStatus == PermissionStatus.provisional) { Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予'); + // 授权成功,清零拒绝次数 + await _storage.remove(denialCountKey); _hideExplanationOverlay(); return true; } + // 处理请求后变为永久拒绝 else if (newStatus == PermissionStatus.permanentlyDenied) { Logger.w('PermissionUtil', '$permissionName 权限在请求后被永久拒绝'); @@ -120,18 +190,16 @@ class PermissionUtil { _hideExplanationOverlay(); return result; } - // 其他拒绝状态 - _hideExplanationOverlay(); - return false; - } - - // 处理受限状态 - if (status == PermissionStatus.restricted || - status == PermissionStatus.limited) { - Logger.w('PermissionUtil', '$permissionName 权限受限或受限访问: $status'); - await _showRestrictedPermissionDialog(permissionName); - _hideExplanationOverlay(); - return false; + // 如果执行到这里,说明权限被拒绝了 (denied) + else { + Logger.w('PermissionUtil', '$permissionName 权限被拒绝'); + // 拒绝次数 +1 + await _storage.write(denialCountKey, denialCount + 1); + Logger.d( + 'PermissionUtil', '$permissionName 拒绝次数更新为: ${denialCount + 1}'); + _hideExplanationOverlay(); + return false; + } } // 其他未知状态处理 From a592e5c6a3f014b0478e7620c10d351f4a7ac585 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 30 Aug 2025 00:07:36 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(permission):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=9D=83=E9=99=90=E8=AF=B7=E6=B1=82=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=86=97=E4=BD=99=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构权限请求流程,简化状态处理逻辑 - 移除未使用的注释代码和冗余方法 - 改进权限横幅显示和隐藏的可靠性 - 使用更清晰的权限状态检查方法 --- lib/core/utils/permission_util.dart | 263 ++++++---------------------- 1 file changed, 53 insertions(+), 210 deletions(-) diff --git a/lib/core/utils/permission_util.dart b/lib/core/utils/permission_util.dart index abe4cb80d..647dc3989 100644 --- a/lib/core/utils/permission_util.dart +++ b/lib/core/utils/permission_util.dart @@ -23,9 +23,6 @@ class PermissionUtil { /// 私有构造函数 PermissionUtil._(); - /// 权限解释横幅的叠加层入口 - //OverlayEntry? _explanationOverlayEntry; - /// 引入 get_storage 用于持久化权限拒绝次数 final _storage = GetStorage(); @@ -63,7 +60,6 @@ class PermissionUtil { /// /// 返回值:`Future` 权限是否最终授予 Future requestPermission({ - // required BuildContext context, required Permission permissionType, required String permissionName, required String explanationText, @@ -75,145 +71,82 @@ class PermissionUtil { final String finalSettingsButtonText = settingsButtonText ?? 'goToSettings'.tr; final String finalCancelButtonText = cancelButtonText ?? 'cancel'.tr; + bool isOverlayShown = false; try { // 检查初始权限状态 - final PermissionStatus status = await permissionType.status; + PermissionStatus status = await permissionType.status; Logger.d('PermissionUtil', '初始权限状态 [$permissionName]: $status'); - // 处理已授权状态(包括授予状态和临时通知权限) - if (status == PermissionStatus.granted || - status == PermissionStatus.provisional) { - Logger.i('PermissionUtil', '$permissionName 权限已授予或临时授权'); + // 1. <--- 已授权处理 --->(包括授予状态和临时通知权限) + if (status.isGranted) { + Logger.i('PermissionUtil', '$permissionName 权限已授予或临时授予'); // 如果已经授权,清零之前的拒绝记录,以防用户在设置中开启后又关闭 - await _storage.remove(_getDenialCountKey(permissionType)); + await resetDenialCount(permissionType); // 清零拒绝记录 return true; } - // 2. 读取历史拒绝次数 - final denialCountKey = _getDenialCountKey(permissionType); - int denialCount = _storage.read(denialCountKey) ?? 0; + // 2. <--- 熔断机制:检查我们自己的拒绝次数 ---> + final denialCount = getDenialCount(permissionType); Logger.d('PermissionUtil', '$permissionName 当前拒绝次数: $denialCount'); - - // 3. 熔断机制:如果拒绝次数达到阈值或系统返回了 permanentlyDenied,直接视为永久拒绝 - if (denialCount >= _denialThreshold || - status == PermissionStatus.permanentlyDenied) { - Logger.w('PermissionUtil', - '$permissionName 已被拒绝 $denialCount 次或被系统永久拒绝,触发设置引导'); - - // 显示权限解释横幅 - _showExplanationOverlay( - permissionName: permissionName, - explanationText: explanationText, - ); - - // 显示引导去设置的对话框 - final result = await _showPermanentDenialDialog( + // 如果拒绝次数达到阈值或系统返回了 permanentlyDenied,直接视为永久拒绝 + if (status.isPermanentlyDenied || denialCount >= _denialThreshold) { + Logger.w('PermissionUtil', '$permissionName 已被系统永久拒绝或自定义计数达到阈值($denialCount),触发设置引导'); + await _showPermanentDenialDialog( permissionName: permissionName, permanentDenialText: permanentDenialText, settingsButtonText: finalSettingsButtonText, cancelButtonText: finalCancelButtonText, ); - - // 移除解释横幅 - _hideExplanationOverlay(); - return result; + // 引导用户后,返回false,因为权限当前未被授予 + return false; } - // 显示权限解释横幅(覆盖所有状态的处理) + // 3. <--- 发起正常的权限请求 (此时 status 必然是 denied 或受限) ---> _showExplanationOverlay( - //context: context, permissionName: permissionName, explanationText: explanationText, ); + isOverlayShown = true; - // 处理永久拒绝状态(先显示说明横幅,再引导用户去设置) - // if (status == PermissionStatus.permanentlyDenied) { - // Logger.w('PermissionUtil', '$permissionName 权限被永久拒绝,显示引导对话框'); - // // 显示永久拒绝对话框(在解释横幅下方显示) - // final result = await _showPermanentDenialDialog( - // // context: context, - // permissionName: permissionName, - // permanentDenialText: permanentDenialText, - // settingsButtonText: finalSettingsButtonText, - // cancelButtonText: finalCancelButtonText, - // ); - // // 移除解释横幅 - // _hideExplanationOverlay(); - // return result; - // } - // 处理受限状态 - if (status == PermissionStatus.restricted || - status == PermissionStatus.limited) { - Logger.w('PermissionUtil', '$permissionName 权限受限或受限访问: $status'); - await _showRestrictedPermissionDialog(permissionName); - _hideExplanationOverlay(); - return false; - } + final PermissionStatus newStatus = await permissionType.request(); + Logger.d('PermissionUtil', '请求后 $permissionName 的新权限状态: $newStatus'); - // 处理初始拒绝/未决定状态(真正的权限请求) - if (status == PermissionStatus.denied) { - Logger.d('PermissionUtil', '$permissionName 权限被拒绝或未决定,显示说明横幅并请求'); - // _showExplanationOverlay( - // context: context, - // permissionName: permissionName, - // explanationText: explanationText, - // ); - //请求权限 - final PermissionStatus newStatus = await permissionType.request(); - Logger.d('PermissionUtil', '请求后 $permissionName 的新权限状态: $newStatus'); + _hideExplanationOverlay(); + isOverlayShown = false; - //_hideExplanationOverlay(); + // 4. <--- 处理请求后的结果 ---> + if (newStatus.isGranted) { + Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予'); + await resetDenialCount(permissionType); // 授权成功,清零 + return true; + } - // 5. 根据结果更新状态和拒绝次数 - if (newStatus == PermissionStatus.granted || - newStatus == PermissionStatus.provisional) { - Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予'); - // 授权成功,清零拒绝次数 - await _storage.remove(denialCountKey); - _hideExplanationOverlay(); - return true; - } + if (newStatus.isPermanentlyDenied) { + Logger.w('PermissionUtil', '$permissionName 权限在请求后被系统永久拒绝'); + await _showPermanentDenialDialog( + permissionName: permissionName, + permanentDenialText: permanentDenialText, + settingsButtonText: finalSettingsButtonText, + cancelButtonText: finalCancelButtonText, + ); + return false; + } - // 处理请求后变为永久拒绝 - else if (newStatus == PermissionStatus.permanentlyDenied) { - Logger.w('PermissionUtil', '$permissionName 权限在请求后被永久拒绝'); - final result = await _showPermanentDenialDialog( - // context: context, - permissionName: permissionName, - permanentDenialText: permanentDenialText, - settingsButtonText: finalSettingsButtonText, - cancelButtonText: finalCancelButtonText, - ); - _hideExplanationOverlay(); - return result; - } - // 如果执行到这里,说明权限被拒绝了 (denied) - else { - Logger.w('PermissionUtil', '$permissionName 权限被拒绝'); - // 拒绝次数 +1 - await _storage.write(denialCountKey, denialCount + 1); - Logger.d( - 'PermissionUtil', '$permissionName 拒绝次数更新为: ${denialCount + 1}'); - _hideExplanationOverlay(); - return false; - } + if (newStatus.isDenied) { + Logger.w('PermissionUtil', '$permissionName 权限被拒绝,增加拒绝计数'); + await _storage.write(_getDenialCountKey(permissionType), denialCount + 1); } - // 其他未知状态处理 - Logger.e('PermissionUtil', '$permissionName 处于未知状态: $status'); - _hideExplanationOverlay(); + // 所有未授权的情况最终都返回 false return false; } catch (e, stackTrace) { - Logger.e( - 'PermissionUtil', - '请求 $permissionName 权限失败: $e', - e, - stackTrace, - ); - _hideExplanationOverlay(); + Logger.e('PermissionUtil', '请求 $permissionName 权限失败: $e', e, stackTrace); + if (isOverlayShown) { + _hideExplanationOverlay(); + } return false; } } @@ -236,7 +169,7 @@ class PermissionUtil { borderRadius: BorderRadius.circular(12), elevation: 8, color: Colors.grey[300], - shadowColor: Colors.black.withOpacity(0.3), + shadowColor: Colors.black.withValues(alpha: 0.3), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -264,64 +197,23 @@ class PermissionUtil { ), ], ), + // 防止用户通过点击背景关闭解释横幅 + barrierDismissible: false, ); - // _hideExplanationOverlay(); // 确保移除旧叠加层 - - // _explanationOverlayEntry = OverlayEntry( - // builder: (context) => Positioned( - // top: MediaQuery.of(context).padding.top + 10, - // left: 16, - // right: 16, - // child: Material( - // borderRadius: BorderRadius.circular(12), - // elevation: 8, - // color: Theme.of(context).colorScheme.background, - // shadowColor: Colors.black.withOpacity(0.3), - // child: Padding( - // padding: const EdgeInsets.all(16), - // child: Column( - // mainAxisSize: MainAxisSize.min, - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text( - // '需要$permissionName权限', - // style: const TextStyle( - // fontWeight: FontWeight.bold, - // fontSize: 16, - // ), - // ), - // const SizedBox(height: 8), - // Text( - // explanationText, - // style: const TextStyle(fontSize: 14), - // ), - // ], - // ), - // ), - // ), - // ), - // ); - - // Future.delayed(Duration.zero, () { - // if (_explanationOverlayEntry != null) { - // Overlay.of(context).insert(_explanationOverlayEntry!); - // Logger.i('PermissionUtil', '权限说明叠加层已显示 ($permissionName)'); - // } - // }); } /// 隐藏权限解释横幅 void _hideExplanationOverlay() { - Get.back(); - //_explanationOverlayEntry!.remove(); - Logger.d('PermissionUtil', '权限说明叠加层已移除'); + if (Get.isDialogOpen ?? false) { + Get.back(); + Logger.d('PermissionUtil', '权限说明叠加层已移除'); + } } /// 显示永久拒绝后的设置引导对话框 /// /// 在权限被永久拒绝时调用,提示用户前往设置手动开启权限 - Future _showPermanentDenialDialog({ - //required BuildContext context, + Future _showPermanentDenialDialog({ required String permissionName, required String permanentDenialText, required String settingsButtonText, @@ -355,59 +247,10 @@ class PermissionUtil { barrierDismissible: false, ); - // bool? userChoice = await showDialog( - // context: context, - // barrierDismissible: false, - // builder: (context) => - // ); - - // 用户选择前往设置 if (userChoice == true) { AppSettings.openAppSettings(); - return false; // 不能立即获取权限,需要用户手动设置 } - - // 用户取消 - return false; } - /// 显示受限权限对话框 - /// - /// 在权限受限状态(如iOS上的受限照片访问)时调用 - Future _showRestrictedPermissionDialog(String permissionName) async { - Logger.w('PermissionUtil', '显示受限权限对话框 ($permissionName)'); - Get.dialog( - AlertDialog( - title: Text(permissionName + 'permissionUtilRestricted'.tr), - content: Text('permissionUtilRestrictedMessage'.tr), - actions: [ - TextButton( - onPressed: () { - Logger.d('PermissionUtil', '用户确认受限权限 ($permissionName)'); - // Navigator.pop(context); - Get.back(); - }, - child: Text('permissionUtilConfirm'.tr), - ), - ], - ), - ); - - // await showDialog( - // context: context, - // builder: (context) => AlertDialog( - // title: Text('$permissionName权限受限'), - // content: const Text('此功能在当前设备上不可用或受限,请检查设备设置或系统限制'), - // actions: [ - // TextButton( - // onPressed: () { - // Logger.d('PermissionUtil', '用户确认受限权限 ($permissionName)'); - // Navigator.pop(context); - // }, - // child: const Text('确定'), - // ), - // ], - // ), - // ); - } + // 移除了多余的_showRestrictedPermissionDialog,因为永久拒绝流程已能覆盖 } From 5f6bc8e249f15e9b3288f3a35398665d63b1e911 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 30 Aug 2025 15:10:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(meeting):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E8=AE=B0=E5=BD=95=E6=8E=A7=E5=88=B6=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E9=9F=B3=E9=A2=91=E5=A4=84=E7=90=86=E5=92=8C=E6=B3=A2?= =?UTF-8?q?=E5=BD=A2=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构音频数据处理逻辑,使用帧间隔和采样控制提升性能 - 改进波形显示算法,增加毫秒级时间精度 - 简化波形绘制逻辑,优化重绘性能 - 添加数据更新控制器统一管理动画和数据处理 --- .../meeting_record_controller.dart | 246 +++++++++------- .../meeting/views/meeting_record_view.dart | 271 +++++++++--------- 2 files changed, 290 insertions(+), 227 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index eea8c50d2..96bc62d21 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -13,27 +13,28 @@ import 'package:permission_handler/permission_handler.dart'; import '../../../data/services/asr_service.dart'; import '../../../data/services/ble_manager.dart'; -/// Enumerates the possible states of the recording timer +/// 会议记录状态枚举 enum TimerStatus { - stopped, // Timer is inactive - running, // Timer is actively counting - paused, // Timer is paused + stopped, // 停止 + running, // 运行中 + paused, // 暂停 } -/// Controller for meeting recording screen -/// Handles recording state, audio processing, transcription, and UI interactions +/// 会议记录控制器 +/// 负责会议记录的状态管理、音频处理、语音识别和UI交互 class MeetingRecordController extends GetxController - with GetSingleTickerProviderStateMixin { + with GetTickerProviderStateMixin { // Services final AsrService _asrService = Get.find(); final BleManager _bleManager = Get.find(); - // Stream subscriptions + // 流订阅 StreamSubscription? _recognitionSubscription; - // Recording state + // 记录状态 final Rx timerStatus = TimerStatus.stopped.obs; final RxInt seconds = 0.obs; + final RxInt milliseconds = 0.obs; final RxInt audioType = 0.obs; // 0=Live, 1=Media, 2=Call final RxBool isMarked = false.obs; final RxBool showTextContent = false.obs; @@ -60,19 +61,29 @@ class MeetingRecordController extends GetxController // 移除了未使用的变量和TabController StreamSubscription? _audioDataSubscription; - // 添加波形放大倍数变量 - final double waveAmplifyFactor = 250.0; // 增加波动幅度 - final RxDouble ursorPosition = 0.0.obs; // 当前播放位置 - final RxInt _currentPosition = 0.obs; // 原始位置索引 - int maxPoints = 300; // 最大显示点数(根据屏幕宽度优化) - int sampleRate = 16000; // 假设音频采样率(根据实际情况调整) + // 简化的音波数据管理 + final int frameIntervalMs = 300; // 300ms间隔,更稳定的帧率 + int maxVisibleBars = 60; // 最大可见波形条数量 + Timer? _audioFrameTimer; // 音频帧定时器 + double _currentFrameAmplitude = 0.0; // 当前帧累积振幅 + int _currentFrameSamples = 0; // 当前帧样本数量 + + // 性能优化:音频处理采样控制 + int _audioProcessCounter = 0; + static const int _audioProcessInterval = 3; // 每3个音频包处理1次 // 波形流动动画控制器 late AnimationController waveformAnimationController; late Animation waveformAnimation; - // 设置最大点数(根据屏幕宽度) - void setMaxPoints(double screenWidth) { - maxPoints = screenWidth ~/ 3; // 增加数据点密度以填满整个区域 + + // 数据更新控制器 - 统一管理数据更新和动画 + late AnimationController _dataUpdateController; + late Animation _dataUpdateAnimation; + // 设置最大可见波形条数量(根据屏幕宽度) + void setMaxVisibleBars(double screenWidth) { + const barWidth = 3.0; // 波形条宽度 + const barSpacing = 1.0; // 波形条间距 + maxVisibleBars = (screenWidth / (barWidth + barSpacing)).floor(); } @override @@ -87,7 +98,9 @@ class MeetingRecordController extends GetxController @override void onClose() { _cleanupResources(); + // 清理两个AnimationController waveformAnimationController.dispose(); + _dataUpdateController.dispose(); super.onClose(); } @@ -121,11 +134,36 @@ class MeetingRecordController extends GetxController titleFocusNode.addListener(_handleTitleFocusChange); } - /// 初始化波形流动动画 + /// 初始化波形流动动画和数据更新控制器 void _initializeWaveformAnimation() { - // 设置动画持续时间,控制流动速度 + // 1. 数据更新控制器 - 控制数据提交频率 + _dataUpdateController = AnimationController( + duration: Duration(milliseconds: frameIntervalMs), // 250ms 一次数据更新 + vsync: this, + ); + + _dataUpdateAnimation = + Tween(begin: 0.0, end: 1.0).animate(_dataUpdateController); + + // 监听数据更新动画,每个周期结束时提交数据 + _dataUpdateAnimation.addListener(() { + if (!isRecording.value) return; + + // 每个动画周期结束时,才提交一次数据 + if (_dataUpdateController.value >= 1.0) { + _commitAudioFrame(); + + // 重置动画,继续循环 + _dataUpdateController.reset(); + if (isRecording.value) { + _dataUpdateController.forward(); + } + } + }); + + // 2. 波形流动动画控制器 - 控制视觉流动效果 waveformAnimationController = AnimationController( - duration: const Duration(milliseconds: 100), // 更快的流动速度 + duration: Duration(milliseconds: frameIntervalMs), // 与数据更新同步 vsync: this, ); @@ -169,6 +207,8 @@ class MeetingRecordController extends GetxController /// Cleans up resources when controller is disposed void _cleanupResources() { _timer?.cancel(); + _audioFrameTimer?.cancel(); // 清理音频帧定时器 + // AnimationController在onClose中统一清理 _recognitionSubscription?.cancel(); _recognitionSubscription = null; _audioDataSubscription?.cancel(); @@ -217,10 +257,17 @@ class MeetingRecordController extends GetxController /// Toggles important mark status void toggleMarked() => isMarked.toggle(); - /// Formats seconds to MM:SS display format - String formatTime(int seconds) { - return '${(seconds ~/ 60).toString().padLeft(2, '0')}' - ':${(seconds % 60).toString().padLeft(2, '0')}'; + /// 格式化时间为 MM:SS.S 显示格式 + String formatTime(int milliseconds) { + // 使用毫秒级精度计算 + final totalMs = milliseconds; + final minutes = (totalMs ~/ 60000); + final secs = ((totalMs % 60000) ~/ 1000); + final centiseconds = ((totalMs % 1000) ~/ 100); // 取十分之一秒(一位小数) + + return '${minutes.toString().padLeft(2, '0')}' + ':${secs.toString().padLeft(2, '0')}' + '.${centiseconds.toString()}'; } /// Starts/resumes recording based on current state @@ -263,7 +310,7 @@ class MeetingRecordController extends GetxController await _asrService.enableRecord("${dir.path}$fullFileName.wav", acceptAudioData: true); - // 监听音频数据流 + // 通过 asrService 获取音频数据流 _audioDataSubscription = _asrService.getAudioDataStream()?.listen((audioData) { // 处理音频数据,例如: @@ -282,8 +329,9 @@ class MeetingRecordController extends GetxController newName.value = fullFileName; titleEditingController.text = fullFileName; - // 启动波形动画 + // 启动波形动画和音频帧定时器 _startWaveformAnimation(); + _startAudioFrameTimer(); // Start hardware components _startHardwareServices(); @@ -311,6 +359,7 @@ class MeetingRecordController extends GetxController Future _pauseRecording() async { await _asrService.pauseRecord(); _stopWaveformAnimation(); // 暂停时停止动画 + _stopAudioFrameTimer(); // 停止音频帧定时器 pauseTimer(); } @@ -318,88 +367,93 @@ class MeetingRecordController extends GetxController Future _resumeRecording() async { await _asrService.resumeRecord(); _startWaveformAnimation(); // 恢复时启动动画 + _startAudioFrameTimer(); // 重启音频帧定时器 startTimer(); } - /// 处理原始Uint8List音频数据 + /// 处理原始Uint8List音频数据 - 只累积,不更新UI,性能优化 void _processAudioData(Uint8List audioData) { - // 将Uint8List转换为Float32List(PCM16 -> 浮点) - final pcmData = _convertToPCM(audioData); + if (!isRecording.value) return; - // 降采样和添加数据 - _addAudioData(pcmData); + // 性能优化:不是每个音频包都处理,减少CPU占用 + _audioProcessCounter++; + if (_audioProcessCounter % _audioProcessInterval != 0) { + return; // 跳过这次处理 + } + + // 将音频数据转换为振幅值并累积到当前帧 + final amplitude = _calculateAmplitude(audioData); + _currentFrameAmplitude += amplitude; + _currentFrameSamples++; } - /// 转换Uint8List为PCM浮点数组 - Float32List _convertToPCM(Uint8List uint8List) { - // 假设音频为16位PCM小端格式 - final byteData = ByteData.sublistView(uint8List); - final length = uint8List.length ~/ 2; - final pcmData = Float32List(length); - - for (int i = 0; i < length; i++) { - // 读取16位有符号整数 (-32768 到 32767) - final int intValue = byteData.getInt16(i * 2, Endian.little); - // 转换为浮点数 (-1.0 到 1.0) - pcmData[i] = intValue / 32768.0; + /// 提交音频帧数据到波形显示 + void _commitAudioFrame() { + // 计算当前帧的平均振幅 + double frameAmplitude = 0.0; + if (_currentFrameSamples > 0) { + frameAmplitude = _currentFrameAmplitude / _currentFrameSamples; } - return pcmData; - } + // 转换为显示数据 + final newAmplitude = frameAmplitude * 800.0; + + // 更新波形数据 + if (waveData.length >= maxVisibleBars) { + waveData.removeAt(0); + } + waveData.add(newAmplitude); - /// 降采样音频数据 - List _downsampleAudio(Float32List audio) { - if (audio.isEmpty) return []; + // 重置当前帧数据 + _currentFrameAmplitude = 0.0; + _currentFrameSamples = 0; + } - final result = []; - final step = (audio.length / maxPoints).ceil(); + /// 计算音频数据的振幅 + double _calculateAmplitude(Uint8List audioData) { + if (audioData.isEmpty) return 0.0; - // 计算每个区间的峰值(保留更多细节) - for (int i = 0; i < audio.length; i += step) { - double maxVal = 0.0; - final end = min(i + step, audio.length); + // 使用RMS (Root Mean Square) 算法,更准确反映音频能量 + double sumSquares = 0.0; + int sampleCount = 0; - for (int j = i; j < end; j++) { - final absVal = audio[j].abs(); - if (absVal > maxVal) maxVal = absVal; + for (int i = 0; i < audioData.length; i += 4) { + // 跳跃采样,减少计算量 + if (i + 1 < audioData.length) { + final int sample = (audioData[i + 1] << 8) | audioData[i]; + final int signedSample = sample > 32767 ? sample - 65536 : sample; + sumSquares += (signedSample * signedSample); + sampleCount++; } - - // 应用放大倍数增强波动效果 - result.add(maxVal * waveAmplifyFactor); } - return result; + if (sampleCount == 0) return 0.0; + final rms = sqrt(sumSquares / sampleCount); + return (rms / 32768.0).clamp(0.0, 1.0); } - // 获取当前光标位置(0.0 - 1.0) - double get cursorPositionValue { - if (waveData.isEmpty) return 0.0; - return _currentPosition.value / waveData.length; - } + /// 启动数据更新控制器 - 替代Timer + void _startAudioFrameTimer() { + _audioFrameTimer?.cancel(); // 清理旧的Timer(如果有) + _audioFrameTimer = null; - /// 添加处理后的音频数据 - 优化全屏流动效果 - void _addAudioData(Float32List newAudio) { - if (!isRecording.value) return; - - // 1. 降采样处理 - final downsampled = _downsampleAudio(newAudio); - if (downsampled.isEmpty) return; + // 启动数据更新控制器 + _dataUpdateController.forward(); + } - // 2. 逐个添加数据点,确保平滑流动 - for (final sample in downsampled) { - waveData.add(sample); + /// 停止数据更新控制器 + void _stopAudioFrameTimer() { + _audioFrameTimer?.cancel(); + _audioFrameTimer = null; - // 维护合理的数据长度 - 保持足够的数据以填满整个屏幕 - if (waveData.length > maxPoints * 2) { - waveData.removeAt(0); // 移除最旧的数据 - } - } + // 停止数据更新控制器 + _dataUpdateController.stop(); + _dataUpdateController.reset(); - // 3. 确保动画持续运行 - _ensureAnimationRunning(); - - // 4. 更新位置信息 - _currentPosition.value = waveData.length; + // 重置当前帧数据和计数器 + _currentFrameAmplitude = 0.0; + _currentFrameSamples = 0; + _audioProcessCounter = 0; // 重置音频处理计数器 } /// 启动波形流动动画 @@ -415,17 +469,11 @@ class MeetingRecordController extends GetxController waveformAnimationController.reset(); } - /// 确保动画持续运行 - void _ensureAnimationRunning() { - if (isRecording.value && !waveformAnimationController.isAnimating) { - waveformAnimationController.forward(); - } - } - /// Saves recording and cleans up resources Future saveRecording() async { resetTimer(); _stopWaveformAnimation(); // 停止波形动画 + _stopAudioFrameTimer(); // 停止音频帧定时器 if (audioType.value != 0) { _bleManager.closeCodec(); } @@ -477,19 +525,22 @@ class MeetingRecordController extends GetxController /// TIMER CONTROL SECTION /// - /// Starts or resumes timer + /// 启动定时器 void startTimer() { if (timerStatus.value != TimerStatus.running) { timerStatus.value = TimerStatus.running; _lastStartTime = DateTime.now().millisecondsSinceEpoch; - _timer = Timer.periodic(const Duration(milliseconds: 200), (_) { + _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { + // 改为100ms更新频率 if (timerStatus.value != TimerStatus.running) return; final currentTime = DateTime.now().millisecondsSinceEpoch; _elapsedMilliseconds += currentTime - _lastStartTime!; _lastStartTime = currentTime; + seconds.value = (_elapsedMilliseconds / 1000).round(); + milliseconds.value = _elapsedMilliseconds; // 更新毫秒值 }); } } @@ -527,7 +578,4 @@ class MeetingRecordController extends GetxController return baseHeight + rand * 10; }); } - - /// Current playback position in waveform (0-1) - double get cursorPosition => _currentPosition / waveData.length; } diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index 5f453d387..16bd712d9 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -1,5 +1,5 @@ -import 'dart:math'; - +import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -17,7 +17,7 @@ class MeetingRecordView extends GetView { onPopInvokedWithResult: (didPop, result) async { if (didPop) { // 在返回时执行保存操作 - controller.saveTitleChange(); + //controller.saveTitleChange(); //await controller.saveRecording(); } }, @@ -108,17 +108,15 @@ class MeetingRecordView extends GetView { // 使用LayoutBuilder获取准确的屏幕宽度 final screenWidth = constraints.maxWidth; - // 初始化最大点数 - if (controller.maxPoints == 0) { - controller.setMaxPoints(screenWidth); - } + // 初始化最大可见波形条数量 + controller.setMaxVisibleBars(screenWidth); return Column( children: [ SizedBox(height: 24.h), - // 专业级实时流动波形显示区域 + // 实时流动波形显示区域 Container( - height: 120.h, + height: 200.h, margin: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( gradient: LinearGradient( @@ -136,7 +134,7 @@ class MeetingRecordView extends GetView { const Color(0xFFF8F9FA), ], ), - borderRadius: BorderRadius.circular(12.r), + //borderRadius: BorderRadius.circular(12.r), border: Border.all( color: isDarkMode ? Colors.white.withValues(alpha: 0.1) @@ -161,7 +159,7 @@ class MeetingRecordView extends GetView { _WaveformWidget( controller: controller, isDarkMode: isDarkMode, - size: Size(screenWidth - 32.w, 120.h), + size: Size(screenWidth - 32.w, 200.h), ), // 录音状态指示器 @@ -213,11 +211,14 @@ class MeetingRecordView extends GetView { SizedBox(height: 24.h), // 时间显示 - 只包装需要响应式更新的Text widget Obx(() => Text( - controller.formatTime(controller.seconds.value), + controller.formatTime(controller.milliseconds.value), style: TextStyle( fontSize: 48.sp, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.normal, color: isDarkMode ? Colors.white : Colors.black87, + fontFeatures: const [ + FontFeature.tabularFigures() + ], // 使用等宽数字,避免跳动 ), )), const Spacer(), @@ -449,7 +450,7 @@ class MeetingRecordView extends GetView { } } -/// 实时流动波形绘制器 +/// 简化的实时流动波形绘制器 class RealTimeWaveformPainter extends CustomPainter { final List waveData; final bool isDarkMode; @@ -466,22 +467,19 @@ class RealTimeWaveformPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final centerY = size.height / 2; - final centerX = size.width / 2; // 绘制中央基准线 - _drawCenterLine(canvas, size, centerX, centerY); + _drawCenterLine(canvas, size, centerY); // 只在录音状态下且有波形数据时才绘制波形 if (isRecording && waveData.isNotEmpty) { - _drawWaveform(canvas, size, centerX, centerY); + _drawWaveform(canvas, size, centerY); } } - /// 绘制中央时间参考线(只保留水平线) - void _drawCenterLine( - Canvas canvas, Size size, double centerX, double centerY) { - // 只绘制水平中线 - final horizontalLinePaint = Paint() + /// 绘制中央基准线 + void _drawCenterLine(Canvas canvas, Size size, double centerY) { + final linePaint = Paint() ..color = (isDarkMode ? Colors.white : Colors.black).withValues(alpha: 0.15) ..strokeWidth = 1.0; @@ -489,134 +487,109 @@ class RealTimeWaveformPainter extends CustomPainter { canvas.drawLine( Offset(0, centerY), Offset(size.width, centerY), - horizontalLinePaint, + linePaint, ); - - // 移除垂直时间参考线和三角形指示器 } - /// 绘制实时流动波形(扩散到整个区域) - void _drawWaveform(Canvas canvas, Size size, double centerX, double centerY) { - final barWidth = 2.5; // 波形条宽度 - final barSpacing = 0.5; // 波形条间距 - final totalBarWidth = barWidth + barSpacing; + /// 绘制简化的流动波形 + void _drawWaveform(Canvas canvas, Size size, double centerY) { + const double barWidth = 3.0; // 波形条宽度 + const double barSpacing = 1.0; // 波形条间距 + const double totalBarWidth = barWidth + barSpacing; - // 计算整个宽度可以显示多少个波形条 + // 计算可显示的波形条数量 final maxBars = (size.width / totalBarWidth).floor(); + final visibleBars = waveData.length > maxBars ? maxBars : waveData.length; - // 绘制扩散到整个区域的波形 - _drawFullWaveform( - canvas, size, centerY, barWidth, totalBarWidth, maxBars); - } - - /// 绘制扩散到整个区域的波形 - void _drawFullWaveform(Canvas canvas, Size size, double centerY, - double barWidth, double totalBarWidth, int maxBars) { - final dataLength = waveData.length; - if (dataLength == 0) return; - - // 计算流动偏移量 + // 计算流动偏移量(从右向左移动) final flowOffset = animationValue * totalBarWidth; - // 绘制波形条,从左到右填满整个区域 - final displayCount = min(dataLength, maxBars); - - for (int i = 0; i < displayCount; i++) { - // 计算波形条的x位置:从左边开始排列,加上流动偏移 - final x = i * totalBarWidth + flowOffset; - - // 如果波形条完全移出屏幕右侧,跳过绘制 - if (x > size.width) continue; - // 如果波形条完全移出屏幕左侧,跳过绘制 - if (x + barWidth < 0) continue; - - // 获取对应的音频数据(循环使用数据以填满整个区域) - final dataIndex = (dataLength - 1 - (i % dataLength)) % dataLength; + // 从右到左绘制波形条 + for (int i = 0; i < visibleBars; i++) { + final dataIndex = waveData.length - 1 - i; final amplitude = waveData[dataIndex]; - // 计算透明度:中央区域更亮,边缘渐暗 - final distanceFromCenter = (x - size.width / 2).abs() / (size.width / 2); - final opacity = (1.0 - distanceFromCenter * 0.4).clamp(0.3, 1.0); + // 计算波形条的x位置(从右侧开始,向左排列) + final x = size.width - (i + 1) * totalBarWidth + flowOffset; + + // 跳过屏幕外的波形条 + if (x < -barWidth || x > size.width) continue; - _drawWaveBar(canvas, x, centerY, barWidth, amplitude, opacity); + _drawWaveBar( + canvas, x, centerY, amplitude, i.toDouble(), visibleBars.toDouble()); } } - /// 绘制单个上下对称的波形条 - void _drawWaveBar(Canvas canvas, double x, double centerY, double barWidth, - double amplitude, double opacity) { - // 将振幅转换为显示高度(上下对称) - final maxHeight = 50.0; // 最大高度 - final barHeight = (amplitude / 100.0) * maxHeight; + /// 绘制单个波形条 + void _drawWaveBar(Canvas canvas, double x, double centerY, double amplitude, + double index, double totalBars) { + const double barWidth = 2.0; + const double maxHeight = 200.0; + const double minHeight = 4.0; // 最小基础高度 + const double containerPadding = 10.0; // 容器内边距,防止超出边界 - // 创建上下对称的圆角矩形 + // 计算可用的最大高度(考虑容器边界) + final availableHeight = (centerY - containerPadding) * 2; // 上下对称 + final effectiveMaxHeight = math.min(maxHeight, availableHeight); + + // 优化波形条高度计算:确保有基础高度 + 振幅变化 + final normalizedAmplitude = (amplitude / 100.0).clamp(0.0, 1.0); + final dynamicHeight = + normalizedAmplitude * (effectiveMaxHeight - minHeight); + final barHeight = minHeight + dynamicHeight; + + // 计算透明度(越新的波形条越不透明) + //final opacity = (1.0 - (index / totalBars) * 0.6).clamp(0.3, 1.0); + + // 创建波形条矩形 final rect = RRect.fromRectAndRadius( Rect.fromCenter( center: Offset(x + barWidth / 2, centerY), width: barWidth, - height: barHeight * 2, // 上下对称,总高度是barHeight的2倍 + height: barHeight, // 使用优化后的高度 ), - Radius.circular(barWidth / 2), + const Radius.circular(1.5), ); - // 创建渐变效果 - final gradient = LinearGradient( - colors: _getGradientColors(opacity), - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ); + // 创建渐变颜色 + final Color baseColor = isDarkMode + ? Colors.grey[500]! // 深灰色 + : Colors.grey[600]!; // 更深灰色 final paint = Paint() - ..shader = gradient.createShader(rect.outerRect) + ..color = baseColor ..style = PaintingStyle.fill; canvas.drawRRect(rect, paint); + } - // 可选:添加高光效果(仅对较新的波形条) - if (opacity > 0.7) { - final highlightRect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: Offset(x + barWidth / 2, centerY), - width: barWidth * 0.4, - height: barHeight * 2 * 0.8, - ), - Radius.circular(barWidth / 4), - ); - - final highlightPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.2 * opacity) - ..style = PaintingStyle.fill; + @override + bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) { + // 只有在以下情况才重绘 + if (oldDelegate.isDarkMode != isDarkMode || + oldDelegate.isRecording != isRecording) { + return true; + } - canvas.drawRRect(highlightRect, highlightPaint); + // 数据长度变化才重绘 + if (oldDelegate.waveData.length != waveData.length) { + return true; } - } - /// 获取渐变颜色 - List _getGradientColors(double opacity) { - return isDarkMode - ? [ - Colors.blue[400]!.withValues(alpha: opacity), - Colors.blue[200]!.withValues(alpha: opacity * 0.7), - Colors.blue[100]!.withValues(alpha: opacity * 0.4), - ] - : [ - Colors.blue[500]!.withValues(alpha: opacity), - Colors.blue[300]!.withValues(alpha: opacity * 0.7), - Colors.blue[100]!.withValues(alpha: opacity * 0.4), - ]; - } + // 数据内容变化(比较最后一个值) + if (waveData.isNotEmpty && + oldDelegate.waveData.isNotEmpty && + (waveData.last - oldDelegate.waveData.last).abs() > 0.1) { + return true; + } - @override - bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) { - return oldDelegate.waveData != waveData || - oldDelegate.isDarkMode != isDarkMode || - oldDelegate.isRecording != isRecording || - oldDelegate.animationValue != animationValue; + // 动画值每 0.1 变化才重绘(避免每帧都重绘) + return (animationValue - oldDelegate.animationValue).abs() > 0.1; } } -/// 专门的波形Widget,正确管理GetX响应式状态 -class _WaveformWidget extends StatelessWidget { +/// 优化的波形Widget,避免双重响应式更新 +class _WaveformWidget extends StatefulWidget { final MeetingRecordController controller; final bool isDarkMode; final Size size; @@ -627,25 +600,67 @@ class _WaveformWidget extends StatelessWidget { required this.size, }); + @override + State<_WaveformWidget> createState() => _WaveformWidgetState(); +} + +class _WaveformWidgetState extends State<_WaveformWidget> { + late List _waveData; + late bool _isRecording; + StreamSubscription? _waveDataSubscription; + StreamSubscription? _isRecordingSubscription; + + @override + void initState() { + super.initState(); + _waveData = List.from(widget.controller.waveData); + _isRecording = widget.controller.isRecording.value; + + // 监听 waveData 变化 + _waveDataSubscription = widget.controller.waveData.listen((data) { + if (mounted) { + setState(() { + _waveData = List.from(data); + }); + } + }); + + // 监听 isRecording 变化 + _isRecordingSubscription = widget.controller.isRecording.listen((value) { + if (mounted) { + setState(() { + _isRecording = value; + }); + } + }); + } + + @override + void dispose() { + _waveDataSubscription?.cancel(); + _isRecordingSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { - // 使用AnimatedBuilder监听动画变化 - return AnimatedBuilder( - animation: controller.waveformAnimation, - builder: (context, child) { - // 使用Obx只监听需要响应式更新的数据 - return Obx(() { + return RepaintBoundary( + child: AnimatedBuilder( + animation: widget.controller.waveformAnimation, + builder: (context, child) { return CustomPaint( + size: widget.size, painter: RealTimeWaveformPainter( - waveData: controller.waveData, - isDarkMode: isDarkMode, - isRecording: controller.isRecording.value, - animationValue: controller.waveformAnimation.value, + waveData: _waveData, + isDarkMode: widget.isDarkMode, + isRecording: _isRecording, + animationValue: widget.controller.waveformAnimation.value, ), - size: size, + isComplex: true, // 告诉 Flutter 这个绘制较复杂,可缓存 + willChange: false, // 数据不会频繁变化(我们已优化) ); - }); - }, + }, + ), ); } } From 80d5ff42e5380f320553dca9c8936828c57c35d3 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Sat, 30 Aug 2025 19:51:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8C=89=E4=BD=8F?= =?UTF-8?q?=E8=AF=B4=E8=AF=9D=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=B8=A6=E8=80=B3?= =?UTF-8?q?=E6=9C=BA=E5=A4=96=E6=94=BE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/views/agent_view.dart | 21 +- .../agent_service/AgentServiceImpl.swift | 84 +++++- .../agent_service/AgentServicePlugin.swift | 40 +++ .../agent_service/AudioSessionHub.swift | 24 +- .../agent_service/lib/agent_service.dart | 24 ++ .../ios/azure_speech/Package.swift | 5 +- .../Sources/azure_speech/AzureAsrHelper.swift | 257 +++++++++++++++--- .../azure_speech/AzureSpeechPlugin.swift | 40 +++ .../Sources/azure_speech/AzureTtsHelper.swift | 79 ++++++ .../Sources/tools/MicrophoneCapture.swift | 201 ++++++++++++-- .../Sources/tools/SimpleAudioReceiver.swift | 30 +- .../ios/Classes/ClassicBluetoothPlugin.h | 4 +- .../ios/Classes/ClassicBluetoothPlugin.m | 12 - .../ios/classic_bluetooth/Package.swift | 23 ++ .../ClassicBluetoothHelper.swift | 164 +++++++++-- .../SwiftClassicBluetoothPlugin.swift | 2 +- local_plugins/classic_bluetooth/pubspec.yaml | 2 +- 17 files changed, 850 insertions(+), 162 deletions(-) delete mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m create mode 100644 local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift rename local_plugins/classic_bluetooth/ios/{Classes => classic_bluetooth/Sources/classic_bluetooth}/ClassicBluetoothHelper.swift (61%) rename local_plugins/classic_bluetooth/ios/{Classes => classic_bluetooth/Sources/classic_bluetooth}/SwiftClassicBluetoothPlugin.swift (98%) diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 054a37ca0..b3a5846b1 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -613,31 +613,12 @@ class _AgentViewState extends State with WidgetsBindingObserver { // 语音输入模式 - 按住说话模式 return Listener( onPointerDown: (_) { - // 设置300ms延迟防止误触 - _holdTimer = Timer( - const Duration(milliseconds: 100), - () { - Haptics.vibrate(HapticsType.success); - controller.startPushToTalk(); - // if (activeSpeaker == 0) { - // if (speakerId == 1) { - // controller - // .restoreOriginalAudioState(); - // } else { - // controller - // .disableBluetoothAudio(); - // } - // controller - // .setActiveSpeaker(speakerId); - // } - }); + controller.startPushToTalk(); }, onPointerUp: (_) { - _holdTimer?.cancel(); controller.endPushToTalk(); }, onPointerCancel: (_) { - _holdTimer?.cancel(); controller.endPushToTalk(); }, // onLongPressStart: (_) { 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 06452fe1f..c2aa49785 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 @@ -32,6 +32,9 @@ class AgentServiceImpl: NSObject { private var azureSpeechKey: String = "" private var azureSpeechRegion: String = "" private var config: [String: Any] = [:] + + // 添加音乐播放状态记录 + private var wasMusicPlayingBeforeRecognition: Bool = false private var isSpeaking: Bool = false @@ -62,6 +65,11 @@ class AgentServiceImpl: NSObject { private var hasSpeechDetected: Bool = false internal var isAiStreaming: Bool = false + // 新增:识别"启动中"与"启动中收到停止请求"状态 + // 用途:解决 start 异步、stop 先于 onSessionStarted 到来时被忽略的问题 + private var isStartingRecognition: Bool = false + private var stopRequestedDuringStart: Bool = false + var currsessionId: String = "" var language: String = "" private var serverurl = "" @@ -69,7 +77,7 @@ class AgentServiceImpl: NSObject { private var idleTimer: DispatchSourceTimer? private let maxIdleSeconds: TimeInterval = 10 - + var micCapture: MicrophoneCapture! // 语音识别模式 private var currentRecognitionMode: String = "normal" @@ -81,8 +89,6 @@ class AgentServiceImpl: NSObject { private override init() { super.init() - audioPlayer = AudioPlayer() - azureAsrHelper = AzureAsrHelper() azureTtsHelper = AzureTtsHelper() @@ -92,6 +98,9 @@ class AgentServiceImpl: NSObject { UserPortrait.shared.initialize() BleService.shared.setDelegate(self) AudioSessionHub.initialize() + micCapture = MicrophoneCapture.shared + + audioPlayer = AudioPlayer(agentService: self) } func addListener(_ listener: AgentServiceListener) { @@ -383,7 +392,16 @@ class AgentServiceImpl: NSObject { if isRecognizing { stopRecognition() } - + + // 检查当前音乐播放状态并记录 + let currentPlayState = QPlayAutoManager.shared.currentPlayState + wasMusicPlayingBeforeRecognition = (currentPlayState == .playing) + + // 如果音乐正在播放,则暂停 + if wasMusicPlayingBeforeRecognition { + // QPlayAutoManager.shared.pause() + } + // 设置当前识别模式 currentRecognitionMode = mode os_log("设置语音识别模式: %{public}@", log: logger, type: .info, mode) @@ -395,16 +413,22 @@ class AgentServiceImpl: NSObject { } else { //AudioSessionHub.shared.begin(.voice) } + + // 新增:标记为"启动中",用于允许 stop 在启动未完成时也能生效 + isStartingRecognition = true + print("ai启动语音=\(audioSourceType)=\(useBle)") guard let success = azureAsrHelper?.startContinuousRecognition( // callback: self, audioSourceType: audioSourceType ), success else { + // 新增:启动失败时复位"启动中"状态 + isStartingRecognition = false sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") return false } - // 根据模式启动相应的空闲检测 + // 根据模式启动相应的空闲检测(此处 guard isRecognizing,会在 onSessionStarted 中再次启动) startIdleCheckForMode(mode: mode) return true @@ -421,22 +445,38 @@ class AgentServiceImpl: NSObject { } func stopRecognition() -> Bool { - if !isRecognizing { + /// 函数功能:停止语音识别 + /// 特性: + /// 1) 即便处于"启动中"(isStartingRecognition)也会打印日志并尝试停止 + /// 2) 如果底层尚未完全进入可停止状态,记录一次待停止请求,待会话启动后立即停止 + if !(isRecognizing || isStartingRecognition) { + // 既不在识别中,也不在启动中,视为幂等处理 return true } os_log("停止语音识别,当前模式: %{public}@", log: logger, type: .info, currentRecognitionMode) - guard let success = azureAsrHelper?.stopContinuousRecognition(), success else { - return false + let success = azureAsrHelper?.stopContinuousRecognition() ?? false + + if !success && isStartingRecognition { + // 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop + stopRequestedDuringStart = true } stopIdleCheck() -BleService.shared.closeCodec() + + // 只有之前在播放时才恢复播放 + if wasMusicPlayingBeforeRecognition { + // QPlayAutoManager.shared.play() + wasMusicPlayingBeforeRecognition = false + } + + BleService.shared.closeCodec() // 重置识别模式 currentRecognitionMode = "normal" - return true + // 启动中但已请求停止,也视为已接受停止请求 + return success || isStartingRecognition } func disableBluetoothAudio() { azureAsrHelper?.disableBluetoothAudio() @@ -1493,8 +1533,15 @@ class AudioPlayer { private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AudioPlayer") private let audioQueue = DispatchQueue(label: "agent.audio.player", qos: .userInitiated) + // 添加对AgentServiceImpl的弱引用 + private weak var agentService: AgentServiceImpl? // 等待音效超时时间(秒)- 改为 var 以支持动态修改 private var awaitTimeoutSeconds: TimeInterval = 30.0 + + // 修改初始化方法,接受AgentServiceImpl引用 + init(agentService: AgentServiceImpl) { + self.agentService = agentService + } private var resourcesBundle: Bundle { #if SWIFT_PACKAGE @@ -1548,6 +1595,8 @@ class AudioPlayer { awaitPlayer?.stop() awaitPlayer = nil + + // 停止超时定时器 stopAwaitTimeout() @@ -1711,12 +1760,19 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { sendEvent(name: "recognition_started", data: ["status": "started"]) isRecognizing = true hasSpeechDetected = false + // 新增:会话真正启动,复位"启动中"状态 + isStartingRecognition = false // 使用模式感知的空闲检测启动方法 startIdleCheckForMode(mode: currentRecognitionMode) if (currentRecognitionMode == "ble_wakeup") { audioPlayer?.playStartSound() } - + + // 新增:若在启动期间已经收到停止请求,则立即执行停止 + if stopRequestedDuringStart { + stopRequestedDuringStart = false + _ = stopRecognition() + } } func onSessionStopped() { @@ -1740,6 +1796,9 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { sendEvent(name: "recognition_canceled", data: data) isRecognizing = false + // 新增:取消时复位"启动中/待停止"状态 + isStartingRecognition = false + stopRequestedDuringStart = false stopIdleCheck() } @@ -1747,6 +1806,9 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { let data: [String: Any] = ["message": error.isEmpty ? "未知错误" : error] sendEvent(name: "error", data: data) isRecognizing = false + // 新增:出错时复位"启动中/待停止"状态 + isStartingRecognition = false + stopRequestedDuringStart = false stopIdleCheck() } } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index f113e5a3b..45457b848 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -24,6 +24,34 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { // MARK: - FlutterPlugin 实现 /// 处理Flutter发来的方法调用 + // MARK: - 防抖相关(iOS 原生层) + // 函数级注释:用于限制 startConversation/stopConversation 在极短时间内的重复触发 + private var lastStartConversationAt: TimeInterval = 0 + private var lastStopConversationAt: TimeInterval = 0 + private let conversationDebounceInterval: TimeInterval = 0.6 + + /// 判断是否允许处理“开始对话”的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStartConversation() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStartConversationAt < conversationDebounceInterval { + return false + } + lastStartConversationAt = now + return true + } + + /// 判断是否允许处理“停止对话”的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStopConversation() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStopConversationAt < conversationDebounceInterval { + return false + } + lastStopConversationAt = now + return true + } + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "startAgentService": @@ -41,6 +69,12 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { result(success) case "startConversation": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStartConversation() else { + result(true) + return + } + print("startConversation") let mode = (call.arguments as? [String: Any])?["mode"] as? String ?? "normal" impl.startRecognition(mode: mode) result(true) @@ -50,6 +84,12 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { result(true) case "stopConversation": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStopConversation() else { + result(true) + return + } + print("stopConversation") impl.stopRecognition() result(true) diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift index 75d886488..f9132ee1d 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift @@ -323,19 +323,19 @@ public class AudioSessionHub { public func appDidEnterBackground() { os_log("📱 应用进入后台,准备释放音频会话", log: logger, type: .info) - do { - try audioSession.setActive(false, options: .notifyOthersOnDeactivation) - os_log("✓ 音频会话已释放(应用进入后台)", log: logger, type: .info) - } catch let error as NSError { - let osStatusCode = error.code - let domain = error.domain - let description = error.localizedDescription + // do { + // try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + // os_log("✓ 音频会话已释放(应用进入后台)", log: logger, type: .info) + // } catch let error as NSError { + // let osStatusCode = error.code + // let domain = error.domain + // let description = error.localizedDescription - os_log("❌ 释放音频会话失败", log: logger, type: .error) - os_log(" - 错误域: %{public}@", log: logger, type: .error, domain) - os_log(" - OSStatus代码: %d (0x%X)", log: logger, type: .error, osStatusCode, osStatusCode) - os_log(" - 错误描述: %{public}@", log: logger, type: .error, description) - } + // os_log("❌ 释放音频会话失败", log: logger, type: .error) + // os_log(" - 错误域: %{public}@", log: logger, type: .error, domain) + // os_log(" - OSStatus代码: %d (0x%X)", log: logger, type: .error, osStatusCode, osStatusCode) + // os_log(" - 错误描述: %{public}@", log: logger, type: .error, description) + // } } /// 获取当前音频路由信息 diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 492c6ea2f..c2a051c5e 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -240,7 +240,22 @@ class AgentService { /// 启动语音识别,开始监听用户语音输入 /// [mode] 语音识别模式:'ble_wakeup'(BLE唤醒), 'phone_call'(通话模式), 'push_to_talk'(按住说话), 'normal'(普通模式) /// 返回是否成功开始对话 + // 防抖相关静态字段(全局统一防抖) + // 说明:用于限制 startConversation/stopConversation 在极短时间内的重复触发 + static DateTime? _lastStartConversationAt; + static DateTime? _lastStopConversationAt; + static const Duration _conversationDebounce = Duration(milliseconds: 600); + static Future startConversation({String mode = 'normal'}) async { + // 防抖:短时间内重复点击直接忽略(冷却式防抖) + final now = DateTime.now(); + if (_lastStartConversationAt != null && + now.difference(_lastStartConversationAt!) < _conversationDebounce) { + // 函数级注释:当在冷却时间内再次调用时,直接返回 false 表示此次调用被防抖拦截 + return false; + } + _lastStartConversationAt = now; + try { final bool result = await _channel.invokeMethod('startConversation', { 'mode': mode, @@ -296,6 +311,15 @@ class AgentService { /// 停止语音识别 /// 返回是否成功停止对话 static Future stopConversation() async { + // 防抖:短时间内重复点击直接忽略(冷却式防抖) + final now = DateTime.now(); + if (_lastStopConversationAt != null && + now.difference(_lastStopConversationAt!) < _conversationDebounce) { + // 函数级注释:当在冷却时间内再次调用时,直接返回 false 表示此次调用被防抖拦截 + return false; + } + _lastStopConversationAt = now; + try { final bool result = await _channel.invokeMethod('stopConversation'); return result; diff --git a/local_plugins/azure_speech/ios/azure_speech/Package.swift b/local_plugins/azure_speech/ios/azure_speech/Package.swift index ef7bf57ea..eaa511209 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Package.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Package.swift @@ -11,7 +11,9 @@ let package = Package( // 依赖speech接口模块 .package(path: "../../../speech/ios/speech"), // 添加对ble_service的依赖 - .package(path: "../../../ble_service/ios/ble_service") + .package(path: "../../../ble_service/ios/ble_service"), + // 添加对classic_bluetooth的依赖 + .package(path: "../../../classic_bluetooth/ios/classic_bluetooth") ], targets: [ .target( @@ -19,6 +21,7 @@ let package = Package( dependencies: [ .product(name: "speech", package: "speech"), .product(name: "ble-service", package: "ble_service"), + .product(name: "classic-bluetooth", package: "classic_bluetooth"), "MicrosoftCognitiveServicesSpeech" ], path: "Sources", // 修改为包含整个 Sources 目录 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 62783be72..3254e8a26 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 @@ -37,6 +37,11 @@ public class AzureAsrHelper: NSObject { private var lastNetworkLostTime: Int64 = 0 private let networkLostDebounceInterval: Int64 = 3000 // 3秒防抖间隔 + // 防抖机制相关变量 - 只保留开始操作的防抖 + private var lastAudioStartTime: Int64 = 0 + private let audioOperationDebounceInterval: Int64 = 500 // 500毫秒防抖间隔 + private var audioStartWorkItem: DispatchWorkItem? + // 状态管理 private var _isContinuousRecognitionActive = false @@ -216,11 +221,137 @@ public class AzureAsrHelper: NSObject { /** - * 开始连续语音识别 + * 异步启动音频录制(带防抖与状态二次校验) * - * @param callback 连续识别结果回调 * @param audioSourceType 音频源类型 - * @return 是否成功开始识别 + * @param audioDataCallback 音频数据回调 + * @param completion 完成回调(true/false) + */ + /// 异步启动音频录制(带防抖与状态二次校验) + /// - 参数: + /// - audioSourceType: 音频源类型 + /// - audioDataCallback: 音频数据回调 + /// - completion: 完成回调(true/false) + /// + /// 修复点: + /// 1) 新任务创建前,取消旧的未执行任务,确保只有“最后一次启动请求”会生效 + /// 2) 任务执行前二次校验:必须是当前挂起任务且 _isContinuousRecognitionActive 仍为 true 才执行启动 + private func startAudioRecordAsync( + audioSourceType: AudioSourceType, + audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil, + completion: @escaping (Bool) -> Void +) { + let currentTime = Int64(Date().timeIntervalSince1970 * 1000) + + // 防抖:距离上次启动过短,取消旧任务 + if currentTime - lastAudioStartTime < audioOperationDebounceInterval { + audioStartWorkItem?.cancel() + os_log("音频启动操作被防抖过滤,距离上次启动仅%{public}dms", log: log, type: .debug, currentTime - lastAudioStartTime) + } else { + // 即使未触发防抖,也保证旧任务不会残留 + audioStartWorkItem?.cancel() + } + lastAudioStartTime = currentTime + + // 创建新的启动任务 + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { + completion(false) + return + } + print("startAudioRecordAsync\(_isContinuousRecognitionActive)") + // 二次校验:stop 可能已发生,确认仍需启动 + guard self._isContinuousRecognitionActive else { + os_log("已收到停止请求,取消启动音频录制", log: self.log, type: .info) + completion(false) + return + } + + os_log("开始异步启动音频录制", log: self.log, type: .info) + + // 在后台队列执行耗时的音频启动操作 + DispatchQueue.global(qos: .userInitiated).async { + let success = self.performAudioStart(audioSourceType: audioSourceType, audioDataCallback: audioDataCallback) + + // 回到主队列执行完成回调 + DispatchQueue.main.async { + completion(success) + } + } + } + + // 保存"当前挂起任务" + audioStartWorkItem = workItem + + // 延迟执行以实现防抖 + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(Int(audioOperationDebounceInterval)), + execute: workItem + ) +} + + /** + * 执行音频启动操作 + */ + private func performAudioStart( + audioSourceType: AudioSourceType, + audioDataCallback: SimpleAudioReceiver.AudioDataCallback? + ) -> Bool { + do { + audioStream?.startAudioRecord( + audioSourceType: audioSourceType == .microphone ? .microphone : .external, + audioDataCallback: audioDataCallback + ) + os_log("音频录制启动成功", log: log, type: .info) + return true + } catch { + os_log("音频录制启动失败: %{public}@", log: log, type: .error, error.localizedDescription) + return false + } + } + + /** + * 异步停止音频录制(移除防抖机制,立即执行) + * + * @param completion 完成回调 + */ + private func stopAudioRecordAsync(completion: @escaping (Bool) -> Void) { + os_log("开始立即停止音频录制", log: log, type: .info) + + // 在后台队列执行耗时的音频停止操作 + DispatchQueue.global(qos: .userInitiated).async { + let success = self.performAudioStop() + + // 回到主队列执行完成回调 + DispatchQueue.main.async { + completion(success) + } + } + } + + /** + * 执行音频停止操作 + */ + private func performAudioStop() -> Bool { + do { + audioStream?.stopMicrophoneCapture() + os_log("音频录制停止成功", log: log, type: .info) + return true + } catch { + os_log("音频录制停止失败: %{public}@", log: log, type: .error, error.localizedDescription) + return false + } + } + + /** + * 开始连续语音识别(优化:在音频启动成功后再次校验活跃状态) + * + * @param audioSourceType 音频源类型 + * @param audioDataCallback 音频数据回调 + * @return 是否成功开始识别(调度成功即返回 true) + * + * 修复点:在音频启动成功后的闭包中,先判断 _isContinuousRecognitionActive 是否仍然为 true, + * 若 stop 已发生则直接跳过 recognizer 的启动,避免“已停止但仍启动识别”的情况。 */ public func startContinuousRecognition( audioSourceType: AudioSourceType = .microphone, @@ -241,45 +372,61 @@ public class AzureAsrHelper: NSObject { return true } - self.audioSourceType = audioSourceType + // 先取消任何挂起的启动任务 + cancelPendingAudioStart() + // 延迟设置状态,直到音频启动任务真正开始执行 + self.audioSourceType = audioSourceType + print("startContinuousRecognition:准备启动") do { - if recognizer == nil { + if recognizer == nil { // 检查continuousCallback是否为空,只有在不为空时才设置事件监听器 if let callback = self.continuousCallback { setupEventListeners(callback: callback) } } - - print("startContinuousRecognition:\(audioSourceType)") - // 启动音频处理 - audioStream?.startAudioRecord(audioSourceType: audioSourceType == .microphone ? .microphone : .external, audioDataCallback: audioDataCallback) - // 开始连续识别 - try recognizer?.startContinuousRecognition() - _isContinuousRecognitionActive = true - - os_log("连续识别已启动,音频源: %{public}@", log: log, type: .info, audioSourceType == .microphone ? "麦克风" : "外部") + print("startContinuousRecognition:\(audioSourceType)") + // 只有在音频启动成功后才设置状态为 true + self._isContinuousRecognitionActive = true + // 异步启动音频处理 + startAudioRecordAsync(audioSourceType: audioSourceType, audioDataCallback: audioDataCallback) { [weak self] (success: Bool) in + guard let self = self else { return } + + if success { + // 只有在音频启动成功后才设置状态为 true + self._isContinuousRecognitionActive = true + print("startContinuousRecognition:\(self._isContinuousRecognitionActive)") + + // 启动语音识别 + do { + try self.recognizer?.startContinuousRecognition() + os_log("连续识别已启动,音频源: %{public}@", log: self.log, type: .info, audioSourceType == .microphone ? "麦克风" : "外部") + } catch { + os_log("启动连续识别失败: %{public}@", log: self.log, type: .error, error.localizedDescription) + self.stopContinuousRecognition() + self._isContinuousRecognitionActive = false + } + } else { + os_log("音频启动失败,无法开始识别", log: self.log, type: .error) + self._isContinuousRecognitionActive = false + } + } return true } catch { stopContinuousRecognition() _isContinuousRecognitionActive = false - //callback.onError("启动连续识别失败: \(error.localizedDescription)") return false } } - + + /** - * 停止连续语音识别 - * - * @return 停止操作是否成功 - */ - /** - * 停止连续语音识别 - * @return 停止操作是否成功 + * 停止连续语音识别(优化:确保取消未执行的启动任务) + * - 返回: 停止操作是否成功 */ public func stopContinuousRecognition() -> Bool { guard speechConfig != nil else { @@ -288,17 +435,29 @@ public class AzureAsrHelper: NSObject { } guard let recognizer = recognizer else { os_log("识别器为空,重置状态", log: log, type: .info) - return true - } - - if !_isContinuousRecognitionActive { + // 仍需取消潜在的挂起启动任务,避免稍后被触发 + cancelPendingAudioStart() return true } do { - _isContinuousRecognitionActive = false + + + // 关键:取消尚未执行的启动任务,防止 stop 后音频又被启动 + cancelPendingAudioStart() + + // 异步停止音频处理 + stopAudioRecordAsync { [weak self] (success: Bool) in + guard let self = self else { return } + if success { + // 标记不再活跃,供启动任务二次校验 + self._isContinuousRecognitionActive = false + os_log("音频停止成功", log: self.log, type: .info) + } else { + os_log("音频停止失败", log: self.log, type: .error) + } + } - audioStream?.stopMicrophoneCapture() // 停止连续识别 try recognizer.stopContinuousRecognition() self.recognizer = nil @@ -307,24 +466,30 @@ public class AzureAsrHelper: NSObject { } catch { // 强制重置状态 _isContinuousRecognitionActive = false + + // 关键:即使异常,也取消未执行的启动任务 + cancelPendingAudioStart() + os_log("强制停止识别失败", log: log, type: .error) - // 停止音频处理 - audioStream?.stopMicrophoneCapture() + // 异步停止音频处理 + stopAudioRecordAsync { [weak self] (_: Bool) in + guard let self = self else { return } + os_log("强制停止音频处理完成", log: self.log, type: .info) + } - // 【修复】在 catch 块中安全地停止识别器,避免再次抛出异常 + // 尝试安全地停止识别器 do { try recognizer.stopContinuousRecognition() - self.recognizer = nil + self.recognizer = nil } catch { - // 如果强制停止也失败,记录错误但不再抛出异常 os_log("强制停止识别器失败: %@", log: log, type: .error, error.localizedDescription) } return false } } - + /** * 检查连续识别是否活跃 */ @@ -333,7 +498,7 @@ public class AzureAsrHelper: NSObject { } /** - * 释放所有资源 + * 释放资源 */ public func dispose() { do { @@ -344,7 +509,8 @@ public class AzureAsrHelper: NSObject { try? recognizer?.stopContinuousRecognition() _isContinuousRecognitionActive = false } - + // 取消防抖任务 - 只取消开始操作的防抖 + audioStartWorkItem?.cancel() // 停止音频处理 audioStream?.releaseAudioResources() // 释放网络监听资源 @@ -353,7 +519,7 @@ public class AzureAsrHelper: NSObject { recognizer = nil speechConfig = nil audioConfig = nil - audioStream = nil + audioStream = nil // 确保状态被重置 _isContinuousRecognitionActive = false //externalAudioStream = nil @@ -362,8 +528,8 @@ public class AzureAsrHelper: NSObject { _isContinuousRecognitionActive = false //externalAudioStream = nil audioConfig = nil - recognizer = nil - speechConfig = nil + recognizer = nil + speechConfig = nil audioStream = nil } } @@ -853,6 +1019,15 @@ extension AzureAsrHelper: NetworkStateMonitor.NetworkStateListener { self.handleNetworkLost() } - + // .MARK: - 启动任务管理 + /// 取消尚未执行的音频启动任务,防止 stop 后仍然触发启动 + private func cancelPendingAudioStart() { + audioStartWorkItem?.cancel() + audioStartWorkItem = nil + } } + + + + 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 9a772df97..2f5f25bd5 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 @@ -20,6 +20,34 @@ import ble_service // 日志对象 private let log = OSLog(subsystem: "com.azure.speech", category: "AzureSpeechPlugin") + // MARK: - 防抖相关(iOS 原生层) + // 函数级注释:用于限制 startContinuousRecognition/stopContinuousRecognition 在极短时间内的重复触发 + private var lastStartRecognitionAt: TimeInterval = 0 + private var lastStopRecognitionAt: TimeInterval = 0 + private let recognitionDebounceInterval: TimeInterval = 0.6 + + /// 判断是否允许处理"开始连续识别"的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStartRecognition() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStartRecognitionAt < recognitionDebounceInterval { + return false + } + lastStartRecognitionAt = now + return true + } + + /// 判断是否允许处理"停止连续识别"的调用(防抖) + /// 返回值:true 允许执行,false 表示在防抖冷却时间内被拦截 + private func shouldHandleStopRecognition() -> Bool { + let now = Date().timeIntervalSince1970 + if now - lastStopRecognitionAt < recognitionDebounceInterval { + return false + } + lastStopRecognitionAt = now + return true + } + // 替换原有的 StreamHandler 类 private typealias AsrEventStreamHandler = BaseEventStreamHandler private typealias TtsEventStreamHandler = BaseEventStreamHandler @@ -263,6 +291,12 @@ private func sendAstEvent(_ event: [String: Any]) { result(FlutterError(code: "NOT_SUPPORTED", message: "iOS版本不支持recognizeOnce", details: nil)) case "startContinuousRecognition": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStartRecognition() else { + result(true) + return + } + // 确保事件通道已准备好 guard asrEventSink != nil else { result(FlutterError(code: "EVENT_CHANNEL_NOT_READY", message: "事件通道未准备好,无法开始连续识别", details: nil)) @@ -311,7 +345,13 @@ private func sendAstEvent(_ event: [String: Any]) { return case "stopContinuousRecognition": + // 防抖:短时间重复调用直接忽略 + guard shouldHandleStopRecognition() else { + result(true) + return + } + print("stopContinuousRecognition") let success = azureAsrHelper.stopContinuousRecognition() currentAsrCallback = nil result(success) diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift index bd9ccfc32..dd6e17009 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift @@ -33,6 +33,11 @@ public class AzureTtsHelper: NSObject, ITtsService { private var streamBuffer = "" private var lastSpeakTime: TimeInterval = 0 + // 添加文本计数统计变量 + private var totalTextCount = 0 + private var currentSessionTextCount = 0 + private var pendingTextCount = 0 + // 内部异步处理队列 private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated) private var isProcessing = false // 添加处理状态标志 @@ -43,6 +48,7 @@ public class AzureTtsHelper: NSObject, ITtsService { // 自定义音频输出流 private var customAudioOutputStream: SPXPushAudioOutputStream? private var useInternalPlayer = true + private var micCapture: MicrophoneCapture! // 记录最后播放的文本 private var lastSpokenText: String? /** @@ -50,6 +56,7 @@ public class AzureTtsHelper: NSObject, ITtsService { */ public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool { do { + micCapture = MicrophoneCapture.shared // 创建语音配置 speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) speechConfig?.speechSynthesisLanguage = language @@ -110,10 +117,20 @@ public class AzureTtsHelper: NSObject, ITtsService { } } + /** + * 开始语音合成会话 + * @param sessionid 会话ID + * @return 是否成功启动 + */ public func startspeak(sessionid sessionid: String)-> Bool { self.sessionid = sessionid + + // 重置当前会话的文本计数 + self.currentSessionTextCount = 0 + self.pendingTextCount = 0 return true } + /** * 单次合成并播放 */ @@ -131,6 +148,7 @@ public class AzureTtsHelper: NSObject, ITtsService { return false } + // 清理文本 let cleanedText = cleanTextForTTS(text) if cleanedText.isEmpty { @@ -165,6 +183,12 @@ public class AzureTtsHelper: NSObject, ITtsService { return true } + // 增加文本计数统计 + totalTextCount += text.count + currentSessionTextCount += text.count + + print("流式合成文本\(text.count),总计文本数量:\(totalTextCount),当前会话:\(currentSessionTextCount)") + // 清理文本并添加到缓冲区 let cleanedText = cleanTextForTTS(text) streamBuffer.append(cleanedText) @@ -234,6 +258,8 @@ public class AzureTtsHelper: NSObject, ITtsService { sessionid = "" streamBuffer = "" lastSpokenText = "" + // 重置计数器 + pendingTextCount = 0 taskLock.lock() pendingTasks.removeAll() taskLock.unlock() @@ -242,6 +268,13 @@ public class AzureTtsHelper: NSObject, ITtsService { print("liwei-------------停止语音合成播放") try self.synthesizer?.stopSpeaking() self.notifyEvent(eventType: .synthesisCanceled) + + if !micCapture.isCapturing{ + // 停止时设置音频会话为非活跃状态 + try AVAudioSession.sharedInstance().setActive(false) + } + + print("音频会话已设置为非活跃状态") } catch { os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) self.notifyEvent(eventType: .error, params: [ @@ -429,6 +462,8 @@ public class AzureTtsHelper: NSObject, ITtsService { } } catch { speaking = false + // 减少待处理文本计数 + pendingTextCount = max(0, pendingTextCount - text.count) os_log("语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) notifyEvent(eventType: .error, params: [ "errorCode": "SYNTHESIS_FAILED", @@ -481,14 +516,31 @@ public class AzureTtsHelper: NSObject, ITtsService { synthesizer?.addSynthesisCompletedEventHandler { [weak self] _, _ in guard let self = self else { return } self.speaking = false + + // 减少待处理文本计数 + self.pendingTextCount = max(0, self.pendingTextCount - 1) + self.notifyEvent(eventType: .synthesisCompleted) self.notifyEvent(eventType: .playbackCompleted) + + // 检查是否为最后一个文本播放完毕 + if self.pendingTextCount == 0 && self.pendingTasks.isEmpty && !micCapture.isCapturing{ + do { + try AVAudioSession.sharedInstance().setActive(false) + print("最后一个文本播放完毕,音频会话已设置为非活跃状态") + } catch { + print("设置音频会话为非活跃状态失败: \(error.localizedDescription)") + } + } } synthesizer?.addSynthesisCanceledEventHandler { [weak self] _, event in guard let self = self else { return } self.speaking = false + // 减少待处理文本计数 + self.pendingTextCount = max(0, self.pendingTextCount - 1) + var params: [String: Any] = [:] if let details = try? SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: event.result) { params["errorCode"] = details.errorCode @@ -604,4 +656,31 @@ public class AzureTtsHelper: NSObject, ITtsService { public func isSpeaking() -> Bool { return speaking } + +/** + * 获取总计文本数量 + */ +public func getTotalTextCount() -> Int { + return totalTextCount +} + +/** + * 获取当前会话文本数量 + */ +public func getCurrentSessionTextCount() -> Int { + return currentSessionTextCount +} + +/** + * 重置文本计数统计 + */ +public func resetTextCount() { + totalTextCount = 0 + currentSessionTextCount = 0 + pendingTextCount = 0 +} + + } + + 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 d6479f87d..b888aa715 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 @@ -1,21 +1,23 @@ import AVFoundation +import classic_bluetooth // 添加这行导入语句 -class MicrophoneCapture: NSObject { +// 将 class 改为 public class +public class MicrophoneCapture: NSObject { // MARK: - 单例实例 /** * 单例实例,确保整个应用中只有一个音频捕获对象 * 避免多个实例同时访问麦克风资源导致冲突 */ - static let shared = MicrophoneCapture() + public static let shared = MicrophoneCapture() private var audioEngine: AVAudioEngine! private var audioSession: AVAudioSession! private var audioFormat: AVAudioFormat? private var audioInputNode: AVAudioInputNode! private var sampleRate: Double = 16000 // 修改为16000采样率,符合Microsoft要求 - private var isCapturing = false + public var isCapturing = false + public var hasBluetoothDevices = false - // 音频数据回调 var audioDataHandler: ((Data) -> Void)? /** @@ -25,6 +27,41 @@ class MicrophoneCapture: NSObject { private override init() { super.init() setupAudioSession() + + // 监听音频路由变化 + NotificationCenter.default.addObserver( + self, + selector: #selector(audioRouteChanged), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + } + + /** + * 音频路由变化处理 + * 当用户插拔耳机时自动调整音频路由 + */ + @objc private func audioRouteChanged(notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + switch reason { + case .newDeviceAvailable, .oldDeviceUnavailable: + // 设备连接或断开时重新配置音频路由 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.configureAudioRoute() + } + default: + break + } + } + + deinit { + stopCapture() + NotificationCenter.default.removeObserver(self) } /// 获取最佳音频格式 (iOS 通常支持标准采样率) @@ -48,23 +85,12 @@ class MicrophoneCapture: NSObject { audioSession = AVAudioSession.sharedInstance() do { - // 设置音频会话参数 - try audioSession.setCategory(.playAndRecord, - mode: .videoChat, - options: [//.allowBluetooth, // 允许蓝牙耳机,占用hfp链路 - // .allowAirPlay, - .allowBluetoothA2DP,// 允许蓝牙耳机,不占用hfp链路 - .mixWithOthers, - .defaultToSpeaker - ]) - + try audioSession.setPreferredSampleRate(sampleRate) try audioSession.setPreferredIOBufferDuration(0.005) // 5ms缓冲 - // 设置蓝牙设备优先级 - if let bluetoothInput = audioSession.availableInputs?.first(where: { $0.portType == .bluetoothHFP }) { - try audioSession.setPreferredInput(bluetoothInput) - } + // 优先设置蓝牙音频路由 + configureAudioRoute() try audioSession.setActive(true) print("音频会话配置成功") @@ -73,14 +99,107 @@ class MicrophoneCapture: NSObject { } } + /** + * 配置音频输入输出路由 + * 根据蓝牙连接状态自动配置音频路由 + */ + private func configureAudioRoute() { + // 检查是否有蓝牙音频设备连接 + let bluetoothHelper = ClassicBluetoothHelper.shared + self.hasBluetoothDevices = !bluetoothHelper.getConnectedAudioDevices().isEmpty + + if self.hasBluetoothDevices { + // 有蓝牙连接:末端在手机,输出端在耳机 + configureAudioForBluetoothMode() + } else { + // 无蓝牙连接:末端和输出端都在手机 + configureAudioForPhoneMode() + } + } + + /** + * 配置蓝牙模式的音频路由:末端在手机,输出端在耳机 + */ + private func configureAudioForBluetoothMode() { + guard let availableInputs = audioSession.availableInputs else { return } + + // 强制使用内置麦克风作为输入(手机) + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + do { + try audioSession.setPreferredInput(builtInMic) + print("蓝牙模式:音频输入设置为内置麦克风(手机)") + } catch { + print("设置内置麦克风失败: \(error.localizedDescription)") + } + } + + // 检查当前音频输出路由 + let currentRoute = audioSession.currentRoute + print("当前音频输出路由: \(currentRoute.outputs.map { $0.portType.rawValue })") + + // 确保音频输出通过蓝牙耳机 + let hasBluetoothOutput = currentRoute.outputs.contains { output in + [.bluetoothHFP, .bluetoothLE].contains(output.portType) + } + + if hasBluetoothOutput { + // 取消扬声器强制输出,让音频通过蓝牙耳机输出 + do { + // 设置音频会话参数 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路 + .mixWithOthers, + .allowBluetooth]) // 添加音频优先级控制 + + try audioSession.overrideOutputAudioPort(.none) + print("蓝牙模式:音频输出设置为蓝牙耳机") + } catch { + print("设置蓝牙音频输出失败: \(error.localizedDescription)") + } + } + } + + /** + * 配置手机模式的音频路由:末端和输出端都在手机 + */ + private func configureAudioForPhoneMode() { + guard let availableInputs = audioSession.availableInputs else { return } + + // 使用内置麦克风作为输入(手机) + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + do { + // 设置音频会话参数 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路 + .mixWithOthers, + .defaultToSpeaker]) // 添加音频优先级控制 + try audioSession.setPreferredInput(builtInMic) + print("手机模式:音频输入设置为内置麦克风(手机)") + } catch { + print("设置内置麦克风失败: \(error.localizedDescription)") + } + } + + // 强制音频输出通过扬声器(手机) + do { + try audioSession.overrideOutputAudioPort(.speaker) + print("手机模式:音频输出设置为扬声器(手机)") + } catch { + print("设置扬声器输出失败: \(error.localizedDescription)") + } + } + // MARK: - 开始捕获 /** * 开始音频捕获 * 检查麦克风权限并启动音频引擎 */ public func startCapture() throws { + print("开始捕获\(isCapturing)") guard !isCapturing else { return } - print("开始捕获") + // 检查麦克风权限 switch audioSession.recordPermission { case .granted: @@ -132,7 +251,7 @@ class MicrophoneCapture: NSObject { */ private func setupAudioEngine() throws { audioEngine = AVAudioEngine() - + audioInputNode = audioEngine.inputNode // 获取实际硬件采样率 @@ -149,6 +268,7 @@ class MicrophoneCapture: NSObject { try audioInputNode.setVoiceProcessingEnabled(true) } + // 检查输入格式是否符合Microsoft要求 let needsConversion = !isMicrosoftCompatibleFormat(inputFormat) @@ -295,12 +415,39 @@ class MicrophoneCapture: NSObject { */ func stopCapture() { guard isCapturing else { return } - - audioEngine.stop() - audioInputNode.removeTap(onBus: 0) - // try? audioSession.setActive(false) isCapturing = false - } + + // 安全地停止音频引擎 + if let engine = audioEngine { + engine.stop() + } + + // 安全地移除音频处理块 + if let inputNode = audioInputNode { + inputNode.removeTap(onBus: 0) + } + + 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) + } /** * 重置音频捕获状态 @@ -311,7 +458,5 @@ class MicrophoneCapture: NSObject { audioDataHandler = nil } - deinit { - stopCapture() - } + } \ No newline at end of file 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 628571c53..47cef8139 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 @@ -109,14 +109,14 @@ public class SimpleAudioReceiver: NSObject { public func startAudioRecord(audioSourceType: AudioSourceType = .microphone, audioDataCallback: AudioDataCallback?) { _isWriting=true self.audioSourceType = audioSourceType; - self.audioDataCallback = audioDataCallback - print("startAudioRecord=audioSourceType\(audioSourceType)") - switch audioSourceType { - case .microphone: + self.audioDataCallback = audioDataCallback + print("startAudioRecord=audioSourceType\(audioSourceType)") + switch audioSourceType { + case .microphone: runMicrophoneCapture() - case .external: + case .external: runExternalCapture() - } + } } /** @@ -183,6 +183,13 @@ public class SimpleAudioReceiver: NSObject { do { // 确保正在写入状态 guard self._isWriting else { return } + // 设置音频会话参数 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.allowBluetoothA2DP, // 允许蓝牙耳机,不占用hfp链路 + .mixWithOthers, + .defaultToSpeaker]) // 添加音频优先级控制 + try audioSession.setActive(true) try micCapture.startCapture() try micCapture.audioDataHandler = { data in self.writeQueue.put(data) @@ -203,6 +210,17 @@ public class SimpleAudioReceiver: NSObject { private func runExternalCapture() { if audioSourceType == .external { + do { + try audioSession.setCategory(.playback, + mode: .videoChat, + options: [ .mixWithOthers,.allowBluetoothA2DP // 允许蓝牙耳机,不占用hfp链路 + ]) // 添加音频优先级控制 + try audioSession.overrideOutputAudioPort(.none) + try audioSession.setActive(true) + } catch { + print("设置音频会话失败: \(error.localizedDescription)") + } + //pushAudioData(data: Data()) micCapture.stopCapture() diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h index 69a8a8d4e..dd9690df4 100644 --- a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h @@ -1,4 +1,4 @@ #import -@interface ClassicBluetoothPlugin : NSObject -@end \ No newline at end of file +@interface SwiftClassicBluetoothPlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m deleted file mode 100644 index 93666d6e1..000000000 --- a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m +++ /dev/null @@ -1,12 +0,0 @@ -#import "ClassicBluetoothPlugin.h" -#if __has_include() -#import -#else -#import "classic_bluetooth-Swift.h" -#endif - -@implementation ClassicBluetoothPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - [SwiftClassicBluetoothPlugin registerWithRegistrar:registrar]; -} -@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift new file mode 100644 index 000000000..c5a9d1c96 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "classic_bluetooth", + platforms: [.iOS("16.0")], + products: [ + .library( + name: "classic-bluetooth", + targets: ["classic_bluetooth"] + ) + ], + dependencies: [ + // 如果需要其他依赖,在这里添加 + ], + targets: [ + .target( + name: "classic_bluetooth", + dependencies: [], + path: "Sources" + ) + ] +) \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift similarity index 61% rename from local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift rename to local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift index 533482149..a2210ae40 100644 --- a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift @@ -3,14 +3,14 @@ import AVFoundation import CoreBluetooth /// 蓝牙事件类型 -enum BluetoothEventType { +public enum BluetoothEventType { case stateChanged case deviceConnected case deviceDisconnected } /// 蓝牙状态枚举 -enum BluetoothState: String { +public enum BluetoothState: String { case unknown = "unknown" case off = "off" case on = "on" @@ -19,7 +19,10 @@ enum BluetoothState: String { } /// 简化的蓝牙助手类,专注于iOS上的蓝牙耳机检测 -class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { +public class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { + /// 单例实例 + public static let shared = ClassicBluetoothHelper() + private var centralManager: CBCentralManager? private var bluetoothState: CBManagerState = .unknown @@ -30,16 +33,132 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { // 音频路由变化观察者 private var routeChangeObserver: NSObjectProtocol? - override init() { + // 添加自动启动标志 + private var autoStartEnabled: Bool = false + + /// 私有初始化方法,防止外部创建实例 + private override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil) + // 应用启动时自动开始监听 + enableAutoStart() + } + + /** + * 启用自动启动蓝牙监听功能 + * 应用启动时自动开始监听蓝牙状态和音频路由变化 + */ + func enableAutoStart() { + autoStartEnabled = true + // 自动开始蓝牙状态监听 + startBluetoothStateMonitoring { [weak self] eventType, data in + self?.handleBluetoothEvent(eventType: eventType, data: data) + } + print("蓝牙监听服务已自动启动") + } + + /** + * 处理蓝牙事件并自动配置音频路由 + */ + private func handleBluetoothEvent(eventType: BluetoothEventType, data: [String: Any]) { + switch eventType { + case .stateChanged: + if let state = data["state"] as? String { + print("蓝牙状态变化: \(state)") + configureAudioRouteBasedOnBluetoothState() + } + case .deviceConnected: + print("蓝牙设备已连接") + // 蓝牙设备连接时:末端在手机,输出端在耳机 + configureAudioForBluetoothConnected() + case .deviceDisconnected: + print("蓝牙设备已断开") + // 蓝牙设备断开时:末端和输出端都在手机 + configureAudioForBluetoothDisconnected() + } + } + + /** + * 根据蓝牙状态配置音频路由 + */ + private func configureAudioRouteBasedOnBluetoothState() { + let hasBluetoothDevices = !getConnectedAudioDevices().isEmpty + + if hasBluetoothDevices { + configureAudioForBluetoothConnected() + } else { + configureAudioForBluetoothDisconnected() + } + } + + /** + * 配置蓝牙连接时的音频路由:末端在手机,输出端在耳机 + */ + private func configureAudioForBluetoothConnected() { + let audioSession = AVAudioSession.sharedInstance() + + do { + // 设置音频会话类别,允许蓝牙音频 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.allowBluetoothA2DP]) + + // 设置音频输入为内置麦克风(手机) + if let availableInputs = audioSession.availableInputs { + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + try audioSession.setPreferredInput(builtInMic) + print("音频输入设置为内置麦克风(手机)") + } + } + + // 音频输出自动路由到蓝牙耳机 + try audioSession.overrideOutputAudioPort(.none) // 不强制扬声器输出,让系统自动选择蓝牙 + print("音频输出设置为蓝牙耳机") + + try audioSession.setActive(true) + + } catch { + print("配置蓝牙连接音频路由失败: \(error.localizedDescription)") + } + } + + /** + * 配置蓝牙断开时的音频路由:末端和输出端都在手机 + */ + private func configureAudioForBluetoothDisconnected() { + let audioSession = AVAudioSession.sharedInstance() + + do { + // 设置音频会话类别,不使用蓝牙 + try audioSession.setCategory(.playAndRecord, + mode: .videoChat, + options: [.mixWithOthers]) + + // 设置音频输入为内置麦克风(手机) + if let availableInputs = audioSession.availableInputs { + if let builtInMic = availableInputs.first(where: { $0.portType == .builtInMic }) { + try audioSession.setPreferredInput(builtInMic) + print("音频输入设置为内置麦克风(手机)") + } + } + + // 设置音频输出为扬声器(手机) + try audioSession.overrideOutputAudioPort(.speaker) + print("音频输出设置为扬声器(手机)") + + try audioSession.setActive(true) + + } catch { + print("配置蓝牙断开音频路由失败: \(error.localizedDescription)") + } } deinit { stopBluetoothStateMonitoring() } - func centralManagerDidUpdateState(_ central: CBCentralManager) { + /// 蓝牙状态更新回调 + public func centralManagerDidUpdateState(_ central: CBCentralManager) { bluetoothState = central.state print("lxm---蓝牙状态更新: \(bluetoothState.rawValue)") @@ -50,7 +169,8 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } } - func isBluetoothEnabled() -> Bool { + /// 检查蓝牙是否可用 + public func isBluetoothEnabled() -> Bool { switch bluetoothState { case .poweredOn: return true @@ -83,7 +203,7 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } // 获取连接的蓝牙音频设备 - private func getConnectedAudioDevices() -> [[String: String]] { + public func getConnectedAudioDevices() -> [[String: String]] { var result: [[String: String]] = [] // 获取当前音频路由 @@ -103,7 +223,7 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } // 检查是否为蓝牙输出 - private func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { + public func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { return portType == .bluetoothA2DP || portType == .bluetoothHFP || portType == .bluetoothLE } @@ -116,8 +236,10 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { return address } - /// 开始监听蓝牙状态变化 - func startBluetoothStateMonitoring(callback: @escaping (BluetoothEventType, [String: Any]) -> Void) { + /** + * 开始监听蓝牙状态变化 + */ + public func startBluetoothStateMonitoring(callback: @escaping (BluetoothEventType, [String: Any]) -> Void) { self.stateCallback = callback if !isMonitoring { @@ -133,9 +255,6 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { self?.handleAudioRouteChange(notification) } - // 激活音频会话 - activateAudioSession() - // 报告当前状态 reportCurrentBluetoothState() } @@ -186,19 +305,10 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } } - // 激活音频会话 - private func activateAudioSession() { - do { - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playback, mode: .default) - try audioSession.setActive(true) - } catch { - print("设置音频会话失败: \(error.localizedDescription)") - } - } - - /// 停止监听蓝牙状态变化 - func stopBluetoothStateMonitoring() { + /** + * 停止监听蓝牙状态变化 + */ + public func stopBluetoothStateMonitoring() { if isMonitoring { isMonitoring = false @@ -226,7 +336,7 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { } /// 获取蓝牙可用性状态(优化后的统一状态判断方法) - func getBluetoothAvailability() -> BluetoothState { + public func getBluetoothAvailability() -> BluetoothState { switch bluetoothState { case .poweredOn: return .on diff --git a/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift similarity index 98% rename from local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift rename to local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift index 9257d4c16..6f445ea10 100644 --- a/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift @@ -10,7 +10,7 @@ public class SwiftClassicBluetoothPlugin: NSObject, FlutterPlugin { private var methodChannel: FlutterMethodChannel? private var eventChannel: FlutterEventChannel? private var eventSink: FlutterEventSink? - private let bluetoothHelper = ClassicBluetoothHelper() + private let bluetoothHelper = ClassicBluetoothHelper.shared public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "com.yunqiinnovation.classic_bluetooth/methods", binaryMessenger: registrar.messenger()) diff --git a/local_plugins/classic_bluetooth/pubspec.yaml b/local_plugins/classic_bluetooth/pubspec.yaml index 6e4012e2f..1c7381c84 100644 --- a/local_plugins/classic_bluetooth/pubspec.yaml +++ b/local_plugins/classic_bluetooth/pubspec.yaml @@ -17,4 +17,4 @@ flutter: package: com.yunqiinnovation.classic_bluetooth pluginClass: ClassicBluetoothPlugin ios: - pluginClass: ClassicBluetoothPlugin \ No newline at end of file + pluginClass: SwiftClassicBluetoothPlugin \ No newline at end of file