Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into new_dev

weicu
liwei1dao 1 year ago
parent
commit
0b09538b46
  1. 257
      lib/core/utils/permission_util.dart
  2. 21
      lib/modules/agent/views/agent_view.dart
  3. 246
      lib/modules/meeting/controllers/meeting_record_controller.dart
  4. 271
      lib/modules/meeting/views/meeting_record_view.dart
  5. 84
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  6. 40
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  7. 24
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AudioSessionHub.swift
  8. 24
      local_plugins/agent_service/lib/agent_service.dart
  9. 5
      local_plugins/azure_speech/ios/azure_speech/Package.swift
  10. 257
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  11. 40
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  12. 79
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift
  13. 201
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/MicrophoneCapture.swift
  14. 30
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift
  15. 4
      local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h
  16. 12
      local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m
  17. 23
      local_plugins/classic_bluetooth/ios/classic_bluetooth/Package.swift
  18. 164
      local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift
  19. 2
      local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift
  20. 2
      local_plugins/classic_bluetooth/pubspec.yaml

257
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`
@ -21,8 +23,29 @@ class PermissionUtil {
/// 私有构造函数
PermissionUtil._();
/// 权限解释横幅的叠加层入口
//OverlayEntry? _explanationOverlayEntry;
/// 引入 get_storage 用于持久化权限拒绝次数
final _storage = GetStorage();
/// 权限拒绝次数阈值(达到此次数后视为永久拒绝)
static const int _denialThreshold = 2;
/// 获取权限拒绝次数的存储键
String _getDenialCountKey(Permission permission) {
return 'permission_denial_count_${permission.toString()}';
}
/// 重置指定权限的拒绝次数
///
/// 在某些场景下可能需要手动重置拒绝次数,比如用户主动要求重新请求权限
Future<void> resetDenialCount(Permission permissionType) async {
await _storage.remove(_getDenialCountKey(permissionType));
Logger.d('PermissionUtil', '已重置 ${permissionType.toString()} 的拒绝次数');
}
/// 获取指定权限的拒绝次数
int getDenialCount(Permission permissionType) {
return _storage.read<int>(_getDenialCountKey(permissionType)) ?? 0;
}
/// 请求指定权限
///
@ -37,7 +60,6 @@ class PermissionUtil {
///
/// 返回值:`Future<bool>` 权限是否最终授予
Future<bool> requestPermission({
// required BuildContext context,
required Permission permissionType,
required String permissionName,
required String explanationText,
@ -49,103 +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 resetDenialCount(permissionType); // 清零拒绝记录
return true;
}
// 显示权限解释横幅(覆盖所有状态的处理)
// 2. <--- 熔断机制:检查我们自己的拒绝次数 --->
final denialCount = getDenialCount(permissionType);
Logger.d('PermissionUtil', '$permissionName 当前拒绝次数: $denialCount');
// 如果拒绝次数达到阈值或系统返回了 permanentlyDenied,直接视为永久拒绝
if (status.isPermanentlyDenied || denialCount >= _denialThreshold) {
Logger.w('PermissionUtil', '$permissionName 已被系统永久拒绝或自定义计数达到阈值($denialCount),触发设置引导');
await _showPermanentDenialDialog(
permissionName: permissionName,
permanentDenialText: permanentDenialText,
settingsButtonText: finalSettingsButtonText,
cancelButtonText: finalCancelButtonText,
);
// 引导用户后,返回false,因为权限当前未被授予
return false;
}
// 3. <--- 发起正常的权限请求 (此时 status 必然是 denied 或受限) --->
_showExplanationOverlay(
//context: context,
permissionName: permissionName,
explanationText: explanationText,
);
isOverlayShown = true;
final PermissionStatus newStatus = await permissionType.request();
Logger.d('PermissionUtil', '请求后 $permissionName 的新权限状态: $newStatus');
_hideExplanationOverlay();
isOverlayShown = false;
// 处理永久拒绝状态(先显示说明横幅,再引导用户去设置)
if (status == PermissionStatus.permanentlyDenied) {
Logger.w('PermissionUtil', '$permissionName 权限被永久拒绝,显示引导对话框');
// 4. <--- 处理请求后的结果 --->
if (newStatus.isGranted) {
Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予');
await resetDenialCount(permissionType); // 授权成功,清零
return true;
}
// 显示永久拒绝对话框(在解释横幅下方显示)
final result = await _showPermanentDenialDialog(
// context: context,
if (newStatus.isPermanentlyDenied) {
Logger.w('PermissionUtil', '$permissionName 权限在请求后被系统永久拒绝');
await _showPermanentDenialDialog(
permissionName: permissionName,
permanentDenialText: permanentDenialText,
settingsButtonText: finalSettingsButtonText,
cancelButtonText: finalCancelButtonText,
);
// 移除解释横幅
_hideExplanationOverlay();
return result;
}
// 处理初始拒绝/未决定状态
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();
// 处理请求后的状态
if (newStatus == PermissionStatus.granted ||
newStatus == PermissionStatus.provisional) {
Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予');
_hideExplanationOverlay();
return true;
}
// 处理请求后变为永久拒绝
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;
}
// 其他拒绝状态
_hideExplanationOverlay();
return false;
}
// 处理受限状态
if (status == PermissionStatus.restricted ||
status == PermissionStatus.limited) {
Logger.w('PermissionUtil', '$permissionName 权限受限或受限访问: $status');
await _showRestrictedPermissionDialog(permissionName);
_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;
}
}
@ -168,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(
@ -196,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<bool> _showPermanentDenialDialog({
//required BuildContext context,
Future<void> _showPermanentDenialDialog({
required String permissionName,
required String permanentDenialText,
required String settingsButtonText,
@ -287,59 +247,10 @@ class PermissionUtil {
barrierDismissible: false,
);
// bool? userChoice = await showDialog<bool>(
// context: context,
// barrierDismissible: false,
// builder: (context) =>
// );
// 用户选择前往设置
if (userChoice == true) {
AppSettings.openAppSettings();
return false; // 不能立即获取权限,需要用户手动设置
}
// 用户取消
return false;
}
/// 显示受限权限对话框
///
/// 在权限受限状态(如iOS上的受限照片访问)时调用
Future<void> _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,因为永久拒绝流程已能覆盖
}

21
lib/modules/agent/views/agent_view.dart

@ -613,31 +613,12 @@ class _AgentViewState extends State<AgentView> 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: (_) {

246
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<AsrService>();
final BleManager _bleManager = Get.find<BleManager>();
// Stream subscriptions
// 流订阅
StreamSubscription? _recognitionSubscription;
// Recording state
// 记录状态
final Rx<TimerStatus> 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<Uint8List>? _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<double> waveformAnimation;
// 设置最大点数(根据屏幕宽度)
void setMaxPoints(double screenWidth) {
maxPoints = screenWidth ~/ 3; // 增加数据点密度以填满整个区域
// 数据更新控制器 - 统一管理数据更新和动画
late AnimationController _dataUpdateController;
late Animation<double> _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<double>(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<void> _pauseRecording() async {
await _asrService.pauseRecord();
_stopWaveformAnimation(); // 暂停时停止动画
_stopAudioFrameTimer(); // 停止音频帧定时器
pauseTimer();
}
@ -318,88 +367,93 @@ class MeetingRecordController extends GetxController
Future<void> _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<double> _downsampleAudio(Float32List audio) {
if (audio.isEmpty) return [];
// 重置当前帧数据
_currentFrameAmplitude = 0.0;
_currentFrameSamples = 0;
}
final result = <double>[];
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<void> 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;
}

271
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<MeetingRecordController> {
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
// 在返回时执行保存操作
controller.saveTitleChange();
//controller.saveTitleChange();
//await controller.saveRecording();
}
},
@ -108,17 +108,15 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
// 使用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<MeetingRecordController> {
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<MeetingRecordController> {
_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<MeetingRecordController> {
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<MeetingRecordController> {
}
}
/// 实时流动波形绘制器
/// 简化的实时流动波形绘制器
class RealTimeWaveformPainter extends CustomPainter {
final List<double> 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<Color> _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<double> _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, // 数据不会频繁变化(我们已优化)
);
});
},
},
),
);
}
}

84
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()
}
}

40
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)

24
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)
// }
}
/// 获取当前音频路由信息

24
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<bool> 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<bool> 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;

5
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 目录

257
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
}
}

40
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)

79
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
}
}

201
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()
}
}

30
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()

4
local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h

@ -1,4 +1,4 @@
#import <Flutter/Flutter.h>
@interface ClassicBluetoothPlugin : NSObject<FlutterPlugin>
@end
@interface SwiftClassicBluetoothPlugin : NSObject<FlutterPlugin>
@end

12
local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m

@ -1,12 +0,0 @@
#import "ClassicBluetoothPlugin.h"
#if __has_include(<classic_bluetooth/classic_bluetooth-Swift.h>)
#import <classic_bluetooth/classic_bluetooth-Swift.h>
#else
#import "classic_bluetooth-Swift.h"
#endif
@implementation ClassicBluetoothPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
[SwiftClassicBluetoothPlugin registerWithRegistrar:registrar];
}
@end

23
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"
)
]
)

164
local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift → 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

2
local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift → 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())

2
local_plugins/classic_bluetooth/pubspec.yaml

@ -17,4 +17,4 @@ flutter:
package: com.yunqiinnovation.classic_bluetooth
pluginClass: ClassicBluetoothPlugin
ios:
pluginClass: ClassicBluetoothPlugin
pluginClass: SwiftClassicBluetoothPlugin
Loading…
Cancel
Save