Browse Source

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

newdev_shunjiawei
liwei1dao 1 year ago
parent
commit
e9d8fee031
  1. 3
      lib/core/translations/language/en_us.dart
  2. 2
      lib/core/translations/language/zh_cn.dart
  3. 68
      lib/core/widgets/intro_overlay_builder.dart
  4. 71
      lib/data/services/novice_guide_service.dart
  5. 63
      lib/modules/agent/controllers/agent_controller.dart
  6. 163
      lib/modules/agent/views/agent_view.dart
  7. 101
      lib/modules/agent/views/weather_card.dart
  8. 122
      lib/modules/login/controllers/login_controller.dart
  9. 28
      lib/modules/meeting/controllers/meeting_record_controller.dart
  10. 872
      lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart
  11. 99
      lib/modules/ota_upgrade/services/device_checker.dart
  12. 153
      lib/modules/ota_upgrade/services/download_manager.dart
  13. 43
      lib/modules/ota_upgrade/services/ota_status_manager.dart
  14. 117
      lib/modules/ota_upgrade/services/ui_manager.dart
  15. 114
      lib/modules/ota_upgrade/services/version_manager.dart
  16. 23
      lib/modules/translation/controllers/translation_controller.dart
  17. 76
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  18. 3
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  19. 8
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt
  20. 86
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  21. 3
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  22. 7
      local_plugins/agent_service/lib/agent_service.dart
  23. 343
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt
  24. 5
      local_plugins/azure_speech/ios/azure_speech/Package.swift
  25. 644
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  26. 54
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  27. 0
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift
  28. 534
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

3
lib/core/translations/language/en_us.dart

@ -518,6 +518,9 @@ const Map<String, String> enUS = {
'smsPermissionDetailDesc': 'SMS functionality requires SMS permission',
'alarmPermissionDetailDesc':
'This permission is required to set exact alarms and ensure reminders trigger on time',
'contactsPermissionDesc': 'Used for accessing contact information',
'contactsPermissionDetailDesc':
'Phone call and SMS functionality requires contacts permission to select contacts',
'calendarWritePermissionDetailDesc':
'Creating calendar events requires calendar write permission',
'calendarFullAccessPermissionDetailDesc':

2
lib/core/translations/language/zh_cn.dart

@ -484,6 +484,8 @@ const Map<String, String> zhCN = {
'calendarWritePermissionDetailDesc': '创建日历事件需要日历写入权限',
'calendarFullAccessPermissionDetailDesc': '管理日历事件需要完整日历权限',
'locationPermissionDetailDesc': '获取位置信息需要位置权限',
'contactsPermissionDesc': '用于访问联系人信息',
'contactsPermissionDetailDesc': '拨打电话和发送短信功能需要联系人权限以选择联系人',
'appNeedsPermissionForFullFunction': '应用需要此权限以提供完整功能',
'permanentDenialText': '您已永久拒绝{permission}权限,请前往应用设置开启',
'goToSettings': '去设置',

68
lib/core/widgets/intro_overlay_builder.dart

@ -7,6 +7,7 @@ class IntroOverlayBuilder {
static Widget homeBuilder(
StepWidgetParams params,
bool isWakeup, {
String title = '对着麦克风说:“小言小言”',
Function? onNext,
}) {
return Column(
@ -21,7 +22,7 @@ class IntroOverlayBuilder {
Padding(
padding: EdgeInsets.only(bottom: 20.w),
child: Text(
isWakeup ? '我在呢~' : '对着麦克风说:“小言小言”',
isWakeup ? '我在呢~' : title,
style: TextStyle(
fontSize: 15.sp,
color: Colors.white,
@ -30,12 +31,27 @@ class IntroOverlayBuilder {
),
),
if (onNext != null)
_button(
'下一步',
() {
params.onNext!();
onNext();
},
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.only(right: 50.w),
child: _button(
'下一步',
() {
params.onNext!();
onNext();
},
),
),
_button(
'跳过',
() {
params.onFinish();
},
isSkip: true,
),
],
),
],
);
@ -82,17 +98,43 @@ class IntroOverlayBuilder {
onFinish();
},
),
if (onNext != null || onFinish != null) const Spacer(),
_button(
'跳过',
() {
params.onFinish();
},
isSkip: true,
),
],
),
],
);
}
static Widget _button(String text, Function() onTap) {
return _AnimatedBorderButton(
text: text,
onTap: onTap,
);
static Widget _button(String text, Function() onTap, {bool isSkip = false}) {
return isSkip
? GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 5.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
border: Border.all(color: Colors.white),
),
child: Text(
text,
style: TextStyle(
fontSize: 14.sp,
color: Colors.white,
),
),
),
)
: _AnimatedBorderButton(
text: text,
onTap: onTap,
);
}
}
@ -207,7 +249,7 @@ class _AnimatedBorderButtonState extends State<_AnimatedBorderButton>
borderRadius: 20.r,
),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w),
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 5.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
border: Border.all(color: Colors.transparent),

71
lib/data/services/novice_guide_service.dart

@ -4,6 +4,8 @@ import 'package:ble_service/ble_service.dart';
import 'package:flutter_intro/flutter_intro.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/utils/permission_util.dart';
class NoviceGuideService extends GetxService {
final GetStorage _storage = GetStorage();
@ -24,44 +26,109 @@ class NoviceGuideService extends GetxService {
'input': '今天天气怎么样',
'send': '小言会为你实时为你播报天气',
'end': '小言能实时为你播报天气,试试其他指令吧',
'method': () async {
// 天气查询需要位置权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.location,
permissionName: 'locationInfo'.tr, // 位置信息
explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息
permanentDenialText:
'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限
);
},
},
{
'start': '对小言说:“请播放一组摇滚乐”',
'input': '请播放一组摇滚乐',
'send': '小言会为你搜寻到一组音乐',
'end': '小言已成功为您搜寻到一组音乐,试试其他指令吧',
'method': () async {},
},
{
'start': '对小言说:“导航到深圳机场”',
'input': '导航到深圳机场',
'send': '小言会为你规划路线',
'end': '小言已成功为您导航到深圳机场,试试其他指令吧',
'method': () async {
// 导航功能需要位置权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.location,
permissionName: 'locationInfo'.tr, // 位置信息
explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息
permanentDenialText:
'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限
);
},
},
{
'start': '对小言说:“明天下午3点创建会议提醒”',
'input': '明天下午3点创建会议提醒',
'send': '小言会为你创建会议提醒',
'end': '小言已成功为您创建会议提醒,试试其他指令吧',
'method': () async {
//创建会议提醒需要日历权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.calendarWriteOnly,
permissionName: 'calendarWrite'.tr, // 日历写入
explanationText: 'calendarWritePermissionDesc'.tr, // 用于创建日历事件和提醒
permanentDenialText:
'calendarWritePermissionDetailDesc'.tr, // 创建日历事件需要日历写入权限
);
},
},
{
'start': '对小言说:“拨打妈妈的电话”',
'input': '拨打妈妈的电话',
'send': '小言会为你拨打电话',
'end': '小言已成功为您拨打电话,试试其他指令吧',
'method': () async {
// 拨打电话需要电话权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.phone,
permissionName: 'phone'.tr, // 电话
explanationText: 'phonePermissionDesc'.tr, // 用于拨打电话功能
permanentDenialText: 'phonePermissionDetailDesc'.tr, // 拨打电话功能需要电话权限
);
//拨打电话还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 拨打电话功能需要联系人权限
);
},
},
{
'start': '对小言说:“给爸爸发短信说晚上回家吃饭’”',
'input': '给爸爸发短信说晚上回家吃饭’',
'send': '小言会为你发送短信',
'end': '短信已发送,语音操控更便捷',
'method': () async {
// 发送短信需要短信权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.sms,
permissionName: 'sms'.tr, // 短信
explanationText: 'smsPermissionDesc'.tr, // 用于发送短信功能
permanentDenialText: 'smsPermissionDetailDesc'.tr, // 发送短信功能需要短信权限
);
//发送短信还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 发送短信功能需要联系人权限
);
},
},
];
@override
void onInit() {
super.onInit();
// isHomeGuide = _storage.read('home_guide') ?? false;
// isAIGuide = _storage.read('ai_guide') ?? false;
isHomeGuide = _storage.read('home_guide') ?? false;
isAIGuide = _storage.read('ai_guide') ?? false;
}
@override

63
lib/modules/agent/controllers/agent_controller.dart

@ -7,6 +7,9 @@ import 'package:voitrans/data/models/song_model.dart';
import 'package:voitrans/data/services/ble_manager.dart';
import 'package:voitrans/data/services/bluetooth_manager.dart';
import 'package:voitrans/data/services/qq_musoc.dart';
import 'package:flutter_intro/flutter_intro.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/bluetooth_manager.dart';
import '../../../data/services/novice_guide_service.dart';
import '/core/utils/upload_oss.dart';
import '/data/models/user_Info.dart';
@ -125,6 +128,7 @@ class AgentController extends GetxController {
Timer? _progressTimer;
// 用于跟踪AI回复状态
bool _isNewAssistantResponse = true;
RxBool newAIReplyCompleted = true.obs;
String _currentResponseId = '';
final TAG = 'AgentController';
@ -160,6 +164,7 @@ class AgentController extends GetxController {
_setupScrollListener(); //设置滚动监听器
_loadChatHistory(); //加载聊天历史记录
_subscribeToEvents(); //订阅代理服务事件
// _subscribeToBleEvents(); //订阅BLE事件
_initMusicStatus(); //初始化音乐状态
_setupMediaSessionCallbacks(); //设置媒体会话回调
_setupTextInputListener(); //设置文本输入监听器
@ -211,6 +216,7 @@ class AgentController extends GetxController {
_eventSubscription?.cancel();
_amapeventSubscription?.cancel();
_bleEventSubscription?.cancel();
Intro.of(Get.context!).dispose();
super.onClose();
}
@ -639,6 +645,7 @@ class AgentController extends GetxController {
isImageProcessing.value = false;
// 标记当前回复完成
_isNewAssistantResponse = true;
newAIReplyCompleted.value = true;
break;
case AgentServiceEventType.error:
@ -906,6 +913,7 @@ class AgentController extends GetxController {
isProcessing.value = true;
// 标记为新的AI回复
_isNewAssistantResponse = true;
newAIReplyCompleted.value = false;
// 根据TTS状态决定是否朗读
await AgentService.processTextInput(text,
speakResponse: isTtsEnabled.value);
@ -1350,7 +1358,7 @@ class AgentController extends GetxController {
// 打电话模式
isTextInputMode.value = false;
Logger.i(TAG, '打电话模式已启动,开始语音输入');
await AgentService.startConversation();
await AgentService.startConversation(mode: 'phone_call');
}
// 结束打电话模式
@ -1402,7 +1410,7 @@ class AgentController extends GetxController {
} else {
// 当前未录音,开始录音
Logger.i(TAG, '开始打电话模式录音');
final result = await AgentService.startConversation();
final result = await AgentService.startConversation(mode: 'phone_call');
// 手动更新状态,确保UI同步
if (result) {
@ -1506,7 +1514,7 @@ class AgentController extends GetxController {
isPushToTalkActive.value = true;
//await AgentService.recognizeCallback();
// 开始语音识别
await AgentService.startConversation();
await AgentService.startConversation(mode: 'push_to_talk');
// await startVoiceInput();
}
@ -1770,21 +1778,52 @@ class AgentController extends GetxController {
// 打开图片查看器
void openImageViewer(BuildContext context, String imagePath) {
// 构建完整的图片路径
String fullImagePath;
if (imagePath.startsWith('/')) {
// 如果是相对路径(以/开头),需要拼接AppDir.value
fullImagePath = '${AppDir.value}$imagePath';
} else {
// 如果已经是完整路径,直接使用
fullImagePath = imagePath;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
iconTheme: IconThemeData(color: Colors.white),
),
// appBar: AppBar(
// backgroundColor: Colors.black,
// leading: IconButton(
// icon: Icon(Icons.arrow_back_ios, color: Colors.white),
// onPressed: () => Navigator.of(context).pop(),
// ),
// iconTheme: IconThemeData(color: Colors.white),
// //title: Text('图片预览', style: TextStyle(color: Colors.white)),
// ),
body: Container(
color: Colors.black,
child: PhotoView(
imageProvider: FileImage(File(imagePath)),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
backgroundDecoration: BoxDecoration(color: Colors.black),
child: GestureDetector(
onTap: () => Navigator.of(context).pop(), // 点击退出
child: PhotoView(
imageProvider: FileImage(File(fullImagePath)),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
backgroundDecoration: BoxDecoration(color: Colors.black),
// errorBuilder: (context, error, stackTrace) {
// return const Center(
// child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Icon(Icons.error, color: Colors.white, size: 64),
// SizedBox(height: 16),
// Text('图片加载失败', style: TextStyle(color: Colors.white)),
// ],
// ),
// );
// },
),
),
),
),

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

@ -193,62 +193,108 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
),
body: Stack(
children: [
IntroStepBuilder(
group: 'ai',
order: 3,
getOverlayPosition: ({
required Offset offset,
required Size screenSize,
required Size size,
}) {
return OverlayPosition(
top: 200.h,
width: screenSize.width,
crossAxisAlignment: CrossAxisAlignment.end,
);
},
overlayBuilder: (params) {
return Obx(
() => IntroOverlayBuilder.homeBuilder(
params,
false,
title: controller.newAIReplyCompleted.value
? '小言已经完成了回答!'
: '小言正在思考中~',
onNext: controller.newAIReplyCompleted.value ? () {} : null,
),
);
},
builder: (BuildContext context, GlobalKey key) => SizedBox(
key: key,
),
),
// 主要聊天界面
Column(
children: [
// 聊天内容
Expanded(
child: Stack(
children: [
IntroStepBuilder(
group: 'ai',
order: 3,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
controller.noviceGuideService.aiStepsList[
controller.noviceGuideService.aiStep]['end'],
onFinish: () async {
if (controller.noviceGuideService.aiStep < 5) {
await Future.delayed(
const Duration(milliseconds: 300));
controller.noviceGuideService.aiStep++;
Intro.of(Get.context!)
.start(group: 'ai', reset: true);
}
},
);
},
builder: (BuildContext context, GlobalKey key) => Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
key: key,
width: double.infinity,
height: 430.w,
),
),
),
Obx(
() => Align(
alignment: Alignment.topCenter,
child: ListView.builder(
controller: controller.scrollController,
shrinkWrap: true,
reverse: true,
padding: EdgeInsets.symmetric(
vertical: 16.h, horizontal: 16.w),
itemCount: controller.messages.length +
(controller.hasMoreMessages.value ? 1 : 0),
itemBuilder: (context, index) {
// 如果是最后一项且有更多消息,显示加载指示器
if (index == controller.messages.length &&
controller.hasMoreMessages.value) {
return _buildLoadMoreIndicator();
}
child: Obx(() {
return Align(
alignment: Alignment.topCenter,
child: ListView.builder(
controller: controller.scrollController,
shrinkWrap: true,
reverse: true,
padding: EdgeInsets.symmetric(
vertical: 16.h, horizontal: 16.w),
itemCount: controller.messages.length +
(controller.hasMoreMessages.value ? 1 : 0),
itemBuilder: (context, index) {
// 如果是最后一项且有更多消息,显示加载指示器
if (index == controller.messages.length &&
controller.hasMoreMessages.value) {
return _buildLoadMoreIndicator();
}
final message = controller.messages[
controller.messages.length - 1 - index];
return Padding(
final message = controller
.messages[controller.messages.length - 1 - index];
return index == 0
? IntroStepBuilder(
group: 'ai',
order: 4,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
controller.noviceGuideService.aiStepsList[
controller
.noviceGuideService.aiStep]['end'],
onFinish: () async {
if (controller.noviceGuideService.aiStep <
5) {
await Future.delayed(
const Duration(milliseconds: 300));
controller.noviceGuideService.aiStep++;
Intro.of(Get.context!)
.start(group: 'ai', reset: true);
}
},
);
},
builder:
(BuildContext context, GlobalKey key) =>
Padding(
key: key,
padding: EdgeInsets.only(bottom: 8.h),
child: GestureDetector(
onLongPressStart:
(LongPressStartDetails details) {
messageActionController
.onLongPressMessage(context,
isDarkMode, details, message);
},
child: MessageBubble(
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
),
),
),
)
: Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: GestureDetector(
onLongPressStart:
@ -265,13 +311,12 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
imagePath: message.imagePath,
meta: message.meta,
),
));
},
),
),
),
);
},
),
],
),
);
}),
),
// 输入区域
@ -685,7 +730,11 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
return IntroStepBuilder(
group: 'ai',
order: 2,
onHighlightWidgetTap: () {
onHighlightWidgetTap: () async {
await controller
.noviceGuideService.aiStepsList[
controller.noviceGuideService
.aiStep]['method']();
widgetParams!.onNext!();
controller.sendMessage();
},

101
lib/modules/agent/views/weather_card.dart

@ -25,17 +25,7 @@ Widget buildWeatherContent(BuildContext context, String city,
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
gradient: isDarkMode
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1E40AF), Color(0xFF1E3A8A)],
)
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)],
),
gradient: _getWeatherGradient(now['dayweather'] ?? '', isDarkMode),
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
@ -63,7 +53,7 @@ Widget buildWeatherContent(BuildContext context, String city,
color: Colors.white,
),
),
SizedBox(height: 4),
SizedBox(height: 4.w),
Text(
_getWeatherDescriptionTranslation(now["dayweather"]),
style: TextStyle(
@ -483,3 +473,90 @@ IconData getWeatherIcon(String description) {
// 默认或未知天气
return Icons.help_outline;
}
/// 根据天气类型获取背景渐变颜色
LinearGradient _getWeatherGradient(String weather, bool isDarkMode) {
final weatherType = weather.trim();
// 晴天 - 温暖的橙黄色
if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色
: [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色
);
}
// 多云/阴天 - 灰蓝色
if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝
: [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色
);
}
// 雨天 - 深蓝色
if (weatherType.contains('雨') ||
['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨']
.any((rain) => weatherType.contains(rain))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色
: [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色
);
}
// 雪天 - 冷色调蓝白色
if (weatherType.contains('雪') ||
['阵雪', '小雪', '中雪', '大雪', '暴雪']
.any((snow) => weatherType.contains(snow))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝
: [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色
);
}
// 雾霾天 - 暗黄灰色
if (weatherType.contains('雾') ||
weatherType.contains('霾') ||
['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色
: [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色
);
}
// 大风天 - 青绿色
if (weatherType.contains('风') ||
['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色
: [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色
);
}
// 默认 - 原来的蓝色
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1E40AF), Color(0xFF1E3A8A)]
: [Color(0xFF60A5FA), Color(0xFF3B82F6)],
);
}

122
lib/modules/login/controllers/login_controller.dart

@ -26,22 +26,6 @@ enum LoginViewState {
verification // 验证码页面
}
// 用户渠道枚举
// enum UserChannel {
// UNKNOWN(0), // 未知渠道
// CHINA_HUAWEI(1), // 中国-华为
// CHINA_XIAOMI(2), // 中国-小米
// CHINA_OPPO(3), // 中国-OPPO
// CHINA_VIVO(4), // 中国-VIVO
// CHINA_HONOR(5), // 中国-荣耀
// OFFICIAL(100), // 官方渠道
// GLOBAL_APPLE(101), // 全球-苹果
// GLOBAL_GOOGLE(102); // 全球-谷歌
// const UserChannel(this.value);
// final int value;
// }
class LoginController extends GetxController {
static LoginController get to => Get.find();
final GetStorage _storage = GetStorage();
@ -354,9 +338,32 @@ class LoginController extends GetxController {
// 获取验证码
void getVerificationCode() {
if (contactController.text.isEmpty) {
Get.snackbar(
'tip'.tr, //提示
'pleaseEnterContactInfo'.tr, //请输入手机号或邮箱
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.withOpacity(0.1),
colorText: Colors.red,
);
return;
}
// 根据登录类型验证格式
if (loginType == 0 && !isEmailContactController()) {
Get.snackbar(
'tip'.tr,
'pleaseEnterContactInfo'.tr,
'invalidEmailFormat'.tr,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.withOpacity(0.1),
colorText: Colors.red,
);
return;
}
if (loginType == 1 && !isPhoneContactController()) {
Get.snackbar(
'tip'.tr,
'invalidPhoneFormat'.tr,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.withOpacity(0.1),
colorText: Colors.red,
@ -371,40 +378,6 @@ class LoginController extends GetxController {
// 使用验证码直接登录
void loginWithVerificationCode() {
//测试期间暂时关闭验证码登录
// if (contactController.text.isEmpty) {
// Get.snackbar(
// '提示',
// '请输入手机号码或邮箱',
// snackPosition: SnackPosition.TOP,
// backgroundColor: Colors.red.withOpacity(0.1),
// colorText: Colors.red,
// );
// return;
// }
// if (verificationCodeController.text.isEmpty) {
// Get.snackbar(
// '提示',
// '请输入验证码',
// snackPosition: SnackPosition.TOP,
// backgroundColor: Colors.red.withOpacity(0.1),
// colorText: Colors.red,
// );
// return;
// }
// if (verificationCodeController.text.length < 6) {
// Get.snackbar(
// '提示',
// '请输入完整的验证码',
// snackPosition: SnackPosition.TOP,
// backgroundColor: Colors.red.withOpacity(0.1),
// colorText: Colors.red,
// );
// return;
// }
_isLoading.value = true;
// 模拟验证过程
@ -481,9 +454,8 @@ class LoginController extends GetxController {
// 发送验证码
Future<void> _sendVerificationCode() async {
// 这里应该是实际发送验证码的逻辑
// 为了演示,我们只是模拟发送
if (isEmialContactController() || isPhoneContactController()) {
// 实际发送验证码的逻辑
if (isEmailContactController() || isPhoneContactController()) {
_startCountdown();
Logger().d(contactController.text);
var response = await Api.getCode({
@ -497,8 +469,8 @@ class LoginController extends GetxController {
}
} else {
Get.snackbar(
'tip'.tr,
'pleaseEnterContactInfo'.tr,
'tip'.tr, //提示
'pleaseEnterContactInfo'.tr, //请输入手机号或邮箱
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.withOpacity(0.1),
colorText: Colors.red,
@ -530,19 +502,39 @@ class LoginController extends GetxController {
}
// 检查邮箱格式
bool isEmialContactController() {
bool isEmailContactController() {
// 检查每个元素是否为单个字母或数字字符
final text = contactController.text;
return (text.length > 5 &&
RegExp(r'^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
.hasMatch(text));
final text = contactController.text.trim();
if (text.length < 6) return false;
// 更严格的邮箱验证
final emailRegex =
RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
return emailRegex.hasMatch(text);
}
// 检查手机号码格式
// 检查手机号码格式
bool isPhoneContactController() {
// 检查每个元素是否为单个字母或数字字符
final text = contactController.text;
return RegExp(r'^[0-9]+$').hasMatch(text);
final text = contactController.text.trim();
if (text.isEmpty) return false;
// 根据国家代码验证手机号长度
// 长度检查
if (text.length < 7 || text.length > 15) return false;
// 必须是纯数字
if (!RegExp(r'^[0-9]+$').hasMatch(text)) return false;
// 特定国家的特殊验证
switch (_selectedCountryCode.value) {
case '+86': // 中国
return text.length == 11 && text.startsWith('1');
case '+1': // 美国/加拿大
return text.length == 10;
case '+81': // 日本
return text.length == 10 || text.length == 11;
default:
return true; // 其他国家只做基本验证
}
}
// 检查验证码是否完整
@ -570,7 +562,7 @@ class LoginController extends GetxController {
}
// 验证邮箱格式
if (loginType == 0 && !isEmialContactController()) {
if (loginType == 0 && !isEmailContactController()) {
Get.snackbar(
'error'.tr,
'invalidEmailFormat'.tr,

28
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -78,7 +78,6 @@ class MeetingRecordController extends GetxController
_initializeComponents();
_setupFromArguments();
generateWaveData();
_reinitializeAsrService();
}
@override
@ -87,18 +86,6 @@ class MeetingRecordController extends GetxController
super.onClose();
}
// 重新初始化ASR服务以支持新的语言
Future<void> _reinitializeAsrService() async {
try {
// 重新初始化ASR服务
await _asrService.initialize();
Logger.info('ASR服务重新初始化成功');
} catch (e) {
Logger.error('重新初始化ASR服务失败: ${e.toString()}');
}
}
// 请求录音权限
Future<bool> _requestRecordPermission() async {
try {
@ -164,7 +151,6 @@ class MeetingRecordController extends GetxController
_bleManager.closeCodec();
}
_asrService.stopRecord(true);
_asrService.stopContinuousRecognition();
isRecording.value = false;
}
@ -197,12 +183,6 @@ class MeetingRecordController extends GetxController
/// Toggles visibility of transcribed text content
Future<void> toggleTextDisplay() async {
final recognitionStream = await _asrService.recognizeCallback();
_recognitionSubscription?.cancel();
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
await _asrService.startContinuousRecognition(_audioSourceType);
showTextContent.toggle();
}
@ -242,10 +222,6 @@ class MeetingRecordController extends GetxController
/// Starts recording session
Future<void> _startRecording() async {
// Initialize speech recognition
final recognitionStream =
await _asrService.startContinuousRecognition(_audioSourceType);
// Prepare storage directory
final appDir = await appDocDir;
final dir = Directory("${appDir.path}/MeetingAudio/");
@ -256,7 +232,7 @@ class MeetingRecordController extends GetxController
final fullFileName = "${fileName.value}_$formattedTime";
// Start audio recording
await _asrService.enableRecord("${dir.path}/$fullFileName.wav",
await _asrService.enableRecord("${dir.path}$fullFileName.wav",
acceptAudioData: true);
// 监听音频数据流
@ -302,7 +278,6 @@ class MeetingRecordController extends GetxController
/// Pauses current recording session
Future<void> _pauseRecording() async {
await _asrService.stopContinuousRecognition();
await _asrService.pauseRecord();
pauseTimer();
}
@ -475,7 +450,6 @@ class MeetingRecordController extends GetxController
}
}
await _asrService.stopContinuousRecognition();
_resetToDefaultState();
isRecording.value = false;
}

872
lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart

@ -1,747 +1,293 @@
import 'package:classic_bluetooth/classic_bluetooth.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:voitrans/data/models/appconfig_model.dart';
import '/routes/app_routes.dart';
import 'package:logger/web.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:path/path.dart';
import 'dart:async';
import '../../../data/services/network/api.dart';
import '../model/ota_upgrade_model.dart';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:logger/web.dart';
import 'package:ota/ota.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/models/appconfig.dart';
import '../services/version_manager.dart';
import '../services/download_manager.dart';
import '../services/device_checker.dart';
import '../services/ui_manager.dart';
import '../services/ota_status_manager.dart';
/// OTA升级主控制器 - 重构后的简化版本
/// 负责协调各个服务,管理升级流程的整体状态
class OtaUpgradeController extends GetxController {
static const int NOT_STARTED = -1; // 未开始
static const int VALIDATION_FILE = 0; // 验证文件
static const int ONUPGRADE = 1; // 升级中
static const int START_UPGRADING = 2; // 开始升级
static const int UPGRADE_SUCCESS = 3; // 升级成功
static const int UPGRADE_FAILED = 4; // 升级失败
static const int CONNECTION_DISCONNECTION = 5; // 连接断开
static const int CONNECTION_SUCCESS = 6; // 连接成功
static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件
// 错误信息,用于存储错误提示
final RxString errorMessage = ''.obs;
// 服务依赖
final _versionManager = VersionManager();
final _downloadManager = DownloadManager();
final _deviceChecker = DeviceChecker();
final _uiManager = UiManager();
final _bleManager = Get.find<BleManager>();
final _jlota = Ota.instance;
// 最新版本号
// 状态管理
final RxString errorMessage = ''.obs;
final RxString latestVersion = ''.obs;
// 最新更新内容
final RxString updatedescription = ''.obs;
// 当前设备固件版本号
final RxString currentVersion = ''.obs;
String _address = '';
// 更新包地址,用于下载更新文件
String updatepackageaddress = '';
// 下载进度,范围为 0.0 到 1.0
final RxString detailedStatus = ''.obs;
final RxString updatedescription = ''.obs; // 添加缺失的更新描述属性
final RxDouble downloadProgress = 0.0.obs;
// 是否有可用更新的状态
final isUpdateAvailable = true.obs;
// 当前升级进度,范围为 0.0 到 1.0
// final RxDouble progress = 0.0.obs;
// OTA 单例实例,用于处理升级相关逻辑
final jlota = Ota.instance;
// 是否正在下载更新包的状态
final RxDouble progress = 0.0.obs;
final RxInt type = OtaStatusManager.NOT_STARTED.obs;
final RxBool isUpdateAvailable = true.obs;
final RxBool isDownloading = false.obs;
// 蓝牙管理服务,用于获取设备信息和通信
final _bleManager = Get.find<BleManager>();
final maxRetryAttempts = 3; // 最大重试次数
int currentRetryCount = 0;
final RxBool isFetchingDeviceInfo = true.obs;
final RxDouble progress = 0.0.obs; // 控制器自己的可观察属性
final RxInt type = OtaEvent.NOT_STARTED.obs;
final RxBool isUpgrade = false.obs;
// 新增:详细状态提示
final RxString detailedStatus = ''.obs;
final RxBool isFetchingDeviceInfo = true.obs;
final RxBool showDetailedStatus = false.obs;
String _address = '';
String _updatePackageAddress = '';
@override
void onInit() {
super.onInit();
_getCurrentVersion();
_initializeController();
}
@override
void onClose() {
jlota.onOtaClose();
_jlota.onOtaClose();
_bleManager.setEnterOta(false);
isUpgrade.value = false;
super.onClose();
}
// 添加重试方法
void retryConnection() {
errorMessage.value = '';
_getCurrentVersion();
}
String getStatusMessage(int type) {
switch (type) {
case NOT_STARTED: // -1
return "未开始".tr;
case VALIDATION_FILE: // 0
return "验证文件中".tr;
case ONUPGRADE: // 1
return "升级中".tr;
case START_UPGRADING: // 2
return "开始升级".tr;
case UPGRADE_SUCCESS: // 3
return "升级成功".tr;
case UPGRADE_FAILED: // 4
return "升级失败".tr;
case CONNECTION_DISCONNECTION: // 5
return "连接断开".tr;
case CONNECTION_SUCCESS: // 6
return "连接成功".tr;
case DOWNLOADING_UP_FILES: // 7 (新增)
return "下载升级文件".tr;
default:
return "未知状态".tr; // 更安全的默认值
}
}
// 添加更新方法
Future<void> startUpdate() async {
try {
checkUpdate();
// 实现更新逻辑...
} catch (e) {
errorMessage.value = e.toString();
Get.snackbar(
'更新失败',
e.toString(),
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[600],
colorText: Colors.white,
icon: const Icon(
Icons.error_outline,
color: Colors.white,
),
);
}
/// 初始化控制器
/// 设置事件监听并加载版本信息
Future<void> _initializeController() async {
_listenToOtaEvents();
await _loadVersionInfo();
}
Future<void> _getCurrentVersion() async {
_listenToEvents();
/// 加载版本信息
/// 获取当前设备版本和最新版本信息,检查是否有更新
Future<void> _loadVersionInfo() async {
try {
isFetchingDeviceInfo.value = true;
errorMessage.value = '';
// 添加获取设备信息的重试机制
DeviceInfo? deviceInfo = _bleManager.deviceInfo.value;
// // 第一次检查设备信息
// if (deviceInfo == null) {
// // 尝试重新获取设备信息
// await _bleManager.fetchDeviceInfo();
// }
while (currentRetryCount < maxRetryAttempts) {
deviceInfo = _bleManager.deviceInfo.value;
// 检查信息有效性
if (deviceInfo != null &&
deviceInfo.firmwareVersion.isNotEmpty &&
deviceInfo.firmwareProductId.isNotEmpty) {
break;
}
// 获取设备信息
await _bleManager.fetchDeviceInfo();
await Future.delayed(const Duration(seconds: 5));
currentRetryCount++;
}
// 最终验证
if (deviceInfo == null ||
deviceInfo.firmwareVersion.isEmpty ||
deviceInfo.firmwareProductId.isEmpty) {
throw "获取设备信息失败,请检查连接";
}
// 拆分 firmwareVersion 并判断两边是否一致
final firmwareParts = deviceInfo.firmwareVersion.split('.');
// 从第三个 `.` 开始分割
final firstPart = firmwareParts.sublist(0, 3).join('.');
final secondPart = firmwareParts.sublist(3).join('.');
Logger().d("firstPart${firstPart},secondPart=${secondPart}");
// 判断两部分是否一致
if (firstPart != secondPart) {
throw "固件版本格式错误:${deviceInfo.firmwareVersion}";
}
// 正常流程
currentVersion.value = firstPart;
Logger().d('getequipmentinfo,id: ${deviceInfo.firmwareProductId}');
// 获取当前版本信息
final currentInfo = await _versionManager.getCurrentVersionInfo();
currentVersion.value = currentInfo['currentVersion'];
// 获取蓝牙地址
final connectedDevices = await ClassicBluetooth.getConnectedDevices();
if (connectedDevices.isNotEmpty) {
_address = connectedDevices.first.address;
}
DBProduct? product = AppConfig.getproduct("DeapSound");
// // API请求
// final response = await Api.getproduct({
// "id": deviceInfo.firmwareProductId,
// });
// // 处理API响应
// if (response['product'] == null) {
// throw const FormatException('API 响应格式错误');
// }
// final resp = OtaConfigResp.fromJson(response);
// Logger().d('getequipmentinfo,resp.systemversion: ${resp.systemversion}');
latestVersion.value = product?.version ?? "";
// updatedescription.value = resp.updatedescription!;
updatepackageaddress = product!.updatepackageaddress!;
// 获取最新版本信息
final latestInfo = await _versionManager.getLatestVersionInfo();
latestVersion.value = latestInfo['latestVersion'];
_updatePackageAddress = latestInfo['updatePackageAddress'];
// 版本检查
bool hasUpdate = _isNewVersionAvailable(product.version!);
// 设置更新描述信息
updatedescription.value =
latestInfo['updateDescription'] ?? '本次更新包含性能优化和错误修复。';
isUpdateAvailable.value = hasUpdate;
} catch (e) {
// 统一错误处理
errorMessage.value = e.toString();
Get.snackbar(
'错误',
e.toString(),
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[600],
colorText: Colors.white,
icon: const Icon(
Icons.error_outline,
color: Colors.white,
),
// 检查是否有更新
isUpdateAvailable.value = _versionManager.isNewVersionAvailable(
currentVersion.value,
latestVersion.value,
);
// 重要:重新抛出异常以便上层处理(可选)
} catch (e) {
_handleError('获取版本信息失败', e.toString());
} finally {
isFetchingDeviceInfo.value = false;
}
}
// 版本比较方法优化 - 支持升级和降级
bool _isNewVersionAvailable(String latestVersion) {
try {
List<int> currentParts = currentVersion.value
.split('.')
.map((e) => int.parse(e.trim()))
.toList();
List<int> latestParts =
latestVersion.split('.').map((e) => int.parse(e.trim())).toList();
// 确保两个版本号长度一致,短的用0补齐
int maxLength = currentParts.length > latestParts.length
? currentParts.length
: latestParts.length;
while (currentParts.length < maxLength) {
currentParts.add(0);
}
while (latestParts.length < maxLength) {
latestParts.add(0);
}
// 比较每一段,只要有不同就返回true(支持升级和降级)
for (int i = 0; i < maxLength; i++) {
if (latestParts[i] != currentParts[i]) {
return true; // 版本不同,允许更新(升级或降级)
}
}
return false; // 版本完全相同,无需更新
} catch (e) {
Logger().d('版本比较错误: $e');
return false;
}
}
// 版本更新
/// 检查更新
/// 显示更新确认对话框或错误提示
Future<void> checkUpdate() async {
try {
if (latestVersion.value != "") {
_showUpdateDialog(latestVersion.value);
if (latestVersion.value.isNotEmpty) {
_uiManager.showUpdateDialog(latestVersion.value, _startUpdateProcess);
} else {
Get.snackbar(
'错误',
'获取最新版本信息失败',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[800],
colorText: Colors.white,
icon: const Icon(
Icons.error_outline,
color: Colors.white,
),
);
_uiManager.showErrorSnackbar('错误', '获取最新版本信息失败',
backgroundColor: Colors.orange[800]);
}
} catch (e) {
Get.snackbar(
'错误',
'获取版本信息失败',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[800],
colorText: Colors.white,
icon: const Icon(
Icons.error_outline,
color: Colors.white,
),
);
_uiManager.showErrorSnackbar('错误', '获取版本信息失败',
backgroundColor: Colors.orange[800]);
}
}
void _showUpdateDialog(String latest) async {
Logger().d('显示更新对话框');
Get.dialog(
AlertDialog(
title: Text('发现新版本 $latest'),
content: Text('是否立即更新?'),
actions: [
TextButton(
onPressed: () async {
Logger().d('取消按钮被点击');
Get.back();
Future.delayed(Duration(milliseconds: 300));
},
child: Text('取消'),
),
TextButton(
onPressed: () async {
Logger().d('更新按钮被点击');
Get.back();
Future.delayed(Duration(milliseconds: 300));
// 检查设备电量
if (!_checkBatteryLevel()) {
return; // 电量不足,不执行升级
}
startDownload();
},
child: Text('更新'),
),
],
),
);
/// 开始更新流程的公共方法
/// 供视图调用的更新入口点
Future<void> startUpdate() async {
await _startUpdateProcess();
}
// 修改后的startDownload方法
Future<void> startDownload() async {
await _bleManager.setEnterOta(true);
await Future.delayed(const Duration(seconds: 2));
jlota.init();
isDownloading.value = true;
showDetailedStatus.value = true;
detailedStatus.value = '准备下载固件包...';
int lastIndex = updatepackageaddress.lastIndexOf('/');
String fileName = updatepackageaddress.substring(lastIndex + 1);
String directoryName = "ota_upgrades";
/// 检查设备状态后开始下载和升级
Future<void> _startUpdateProcess() async {
// 检查设备电量
if (!_deviceChecker.checkBatteryLevel()) {
return;
}
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: Duration.zero,
sendTimeout: const Duration(seconds: 5),
headers: {'Accept-Encoding': 'gzip'},
));
await _startDownload();
}
type.value = DOWNLOADING_UP_FILES;
/// 开始下载固件
/// 初始化OTA环境并下载固件包
Future<void> _startDownload() async {
try {
detailedStatus.value = '正在获取下载目录...';
final Directory? downloadsDir = await getApplicationSupportDirectory();
if (downloadsDir == null) {
throw FileSystemException('无法访问下载目录');
}
final targetDir =
Directory('${downloadsDir.path}/Download/$directoryName');
if (!await targetDir.exists()) {
detailedStatus.value = '创建下载目录...';
await targetDir.create(recursive: true);
}
final savePath = '${targetDir.path}/$fileName';
// 新增:检查同名文件逻辑
detailedStatus.value = '检查本地文件...';
bool existingSameFile = false;
try {
var files = await targetDir.list().toList();
for (var entity in files) {
if (await FileSystemEntity.isFile(entity.path)) {
String name = basename(entity.path); // 使用path包中的basename
if (name == fileName) {
existingSameFile = true;
break;
}
}
}
} catch (e) {
Get.snackbar(
'错误',
'检查本地文件失败: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[600],
colorText: Colors.white,
icon: Icon(
Icons.error_outline,
color: Colors.white,
),
);
return;
}
if (existingSameFile) {
//Get.snackbar('文件已存在', '跳过下载,直接更新');
detailedStatus.value = '文件已存在,准备升级...';
startUpgrading(savePath);
return;
}
// 新增:删除其他文件逻辑
detailedStatus.value = '清理旧文件...';
try {
var files = await targetDir.list().toList();
for (var entity in files) {
if (await FileSystemEntity.isFile(entity.path)) {
await entity.delete();
}
}
} catch (e) {
Get.snackbar(
'错误',
'删除旧文件失败: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[600],
colorText: Colors.white,
icon: Icon(
Icons.error_outline,
color: Colors.white,
),
);
//isUpgrade.value = false;
return;
}
Logger().d('文件保存路径: $savePath');
detailedStatus.value = '开始下载固件包...';
final options = Options(
receiveTimeout: Duration.zero,
followRedirects: true,
maxRedirects: 5,
);
// 修改下载部分
await dio.download(
updatepackageaddress,
savePath,
options: options,
onReceiveProgress: (received, total) {
if (total != -1) {
downloadProgress.value = received / total;
detailedStatus.value =
'正在下载固件包... ${(received / total * 100).toStringAsFixed(1)}%';
}
await _bleManager.setEnterOta(true);
await Future.delayed(const Duration(seconds: 2));
_jlota.init();
isDownloading.value = true;
showDetailedStatus.value = true;
type.value = OtaStatusManager.DOWNLOADING_UP_FILES;
final filePath = await _downloadManager.downloadFirmware(
downloadUrl: _updatePackageAddress,
onProgress: (progress) {
downloadProgress.value = progress;
},
onStatusUpdate: (status) {
detailedStatus.value = status;
},
);
File file = File(savePath);
if (await file.exists()) {
// Get.snackbar('下载完成', '文件已保存至: $savePath');
detailedStatus.value = '下载完成,准备升级...';
} else {
Get.snackbar(
'错误',
'文件未找到,请检查写入权限',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: Icon(
Icons.error_outline,
color: Colors.white,
),
);
detailedStatus.value = '下载失败';
showDetailedStatus.value = false;
return;
}
isDownloading.value = false;
startUpgrading(savePath);
return;
} on DioException catch (e) {
Get.snackbar(
'下载失败',
'错误: ${e.message}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: Icon(
Icons.error_outline,
color: Colors.white,
),
);
} on FileSystemException catch (e) {
Get.snackbar(
'存储失败',
'无法写入文件: ${e.message}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: Icon(
Icons.error_outline,
color: Colors.white,
),
);
await _startUpgrading(filePath);
} catch (e) {
isDownloading.value = false;
detailedStatus.value = '下载失败,请检查网络连接';
showDetailedStatus.value = false;
Get.snackbar(
'下载失败',
'请检查网络连接: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: Icon(
Icons.error_outline,
color: Colors.white,
),
);
_handleError('下载失败', e.toString());
Logger().d('下载失败: $e');
}
}
// 开始升级
void startUpgrading(String path) async {
/// 开始升级
/// [filePath] 固件文件路径
/// 启动OTA升级过程
Future<void> _startUpgrading(String filePath) async {
detailedStatus.value = '准备设备连接...';
await Future.delayed(const Duration(milliseconds: 500));
detailedStatus.value = '正在连接设备...';
jlota.startUpgrading(address: _address, filePath: path);
return;
// isUpgrade.value = false;
_jlota.startUpgrading(address: _address, filePath: filePath);
}
// 停止升级
void stopUpgrading() async {
jlota.onOtaClose();
return;
// isUpgrade.value = false;
/// 停止升级
/// 关闭OTA连接
void stopUpgrading() {
_jlota.onOtaClose();
}
// 检查设备电量是否满足升级条件
bool _checkBatteryLevel() {
try {
final deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo == null) {
Get.snackbar(
'电量检查失败',
'无法获取设备信息,请检查设备连接',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: Icon(
Icons.warning_amber,
color: Colors.white,
),
);
return false;
}
/// 重试连接
/// 清除错误信息并重新加载版本信息
void retryConnection() {
errorMessage.value = '';
_loadVersionInfo();
}
final leftBattery = deviceInfo.leftEarBattery;
final rightBattery = deviceInfo.rightEarBattery;
// 检查左右耳电量是否都大于30%
if (leftBattery <= 30 || rightBattery <= 30) {
String message = '设备电量不足,无法进行固件升级\n';
message += '升级要求:左右耳电量均需大于30%\n';
message += '当前电量:左耳 ${leftBattery}%,右耳 ${rightBattery}%\n';
message += '请充电后再试';
Get.dialog(
AlertDialog(
title: Text(
'电量不足',
style: TextStyle(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
message,
style: TextStyle(
color: Colors.orange[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.orange[700],
),
child: const Text('确定'),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.orange[50],
),
);
return false;
}
/// 获取状态描述
/// [type] 状态类型
/// 返回对应的状态描述文本
String getStatusMessage(int type) {
return OtaStatusManager.getStatusMessage(type);
}
return true;
} catch (e) {
Get.snackbar(
'电量检查失败',
'检查设备电量时发生错误:$e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: const Icon(
Icons.warning_amber,
color: Colors.white,
),
);
return false;
}
/// 监听OTA事件
/// 设置OTA事件流的监听器
void _listenToOtaEvents() {
_jlota.eventStream.listen((event) {
_handleOtaEvent(event);
});
}
void _listenToEvents() {
Ota.instance.eventStream.listen((event) {
if (event.event == 'onProgress' &&
event.type != null &&
event.progress != null) {
progress.value = event.progress!;
type.value = event.type!;
isUpgrade.value = true; // 开始升级
detailedStatus.value =
'升级进度: ${(event.progress! * 100).toStringAsFixed(1)}%';
}
if (event.event == 'onStartOTA') {
type.value = START_UPGRADING;
isUpgrade.value = true; // 开始升级
/// 处理OTA事件
/// [event] OTA事件对象
/// 根据不同的事件类型执行相应的处理逻辑
void _handleOtaEvent(dynamic event) {
switch (event.event) {
case 'onProgress':
if (event.type != null && event.progress != null) {
progress.value = event.progress!;
type.value = event.type!;
isUpgrade.value = true;
detailedStatus.value =
'升级进度: ${(event.progress! * 100).toStringAsFixed(1)}%';
}
break;
case 'onStartOTA':
type.value = OtaStatusManager.START_UPGRADING;
isUpgrade.value = true;
detailedStatus.value = 'OTA升级已开始...';
}
if (event.event == 'onStopOTA') {
type.value = UPGRADE_SUCCESS;
isUpgrade.value = false;
detailedStatus.value = '升级成功完成';
showDetailedStatus.value = false;
// 升级成功,更新版本信息显示
currentVersion.value = latestVersion.value;
isUpdateAvailable.value = false;
// 显示升级成功对话框,引导用户重新配对
Get.dialog(
AlertDialog(
title: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green[600],
size: 28,
),
SizedBox(width: 12.w),
Text(
'升级成功',
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.w600,
),
),
],
),
content: Text(
'固件升级已完成!\n\n设备需要重新配对才能正常使用,现在跳转到配对页面吗?',
style: TextStyle(
fontSize: 16,
height: 1.4,
),
),
actions: [
TextButton(
onPressed: () {
Get.back(); // 关闭对话框
},
child: Text(
'稍后配对',
style: TextStyle(color: Colors.grey[600]),
),
),
ElevatedButton(
onPressed: () {
Get.back(); // 关闭对话框
Get.toNamed(Routes.pairing);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[600],
foregroundColor: Colors.white,
),
child: Text('立即配对'),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
barrierDismissible: false, // 防止用户意外关闭
);
}
if (event.event == 'onError') {
type.value = UPGRADE_FAILED;
isUpgrade.value = false;
detailedStatus.value = '升级失败,错误码:${event.code}';
showDetailedStatus.value = false;
Get.snackbar(
'升级失败',
'请检查设备连接或升级包,错误码:${event.code}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[600],
colorText: Colors.white,
icon: Icon(
Icons.error_outline,
color: Colors.white,
),
);
}
if (event.event == 'NOCONNECTION') {
type.value = CONNECTION_DISCONNECTION;
isUpgrade.value = false;
detailedStatus.value = '设备连接断开';
showDetailedStatus.value = false;
}
if (event.event == 'CONNECTION_SUCCESS') {
type.value = CONNECTION_SUCCESS;
isUpgrade.value = true; // 开始升级
detailedStatus.value = '设备连接成功';
}
});
break;
case 'onStopOTA':
_handleUpgradeSuccess();
break;
case 'onError':
_handleUpgradeError(event.code);
break;
case 'NOCONNECTION':
_handleConnectionLost();
break;
case 'CONNECTION_SUCCESS':
_handleConnectionSuccess();
break;
}
}
/// 处理升级成功
/// 更新状态并显示成功对话框
void _handleUpgradeSuccess() {
type.value = OtaStatusManager.UPGRADE_SUCCESS;
isUpgrade.value = false;
detailedStatus.value = '升级成功完成';
showDetailedStatus.value = false;
// 更新版本信息
currentVersion.value = latestVersion.value;
isUpdateAvailable.value = false;
// 显示成功对话框
_uiManager.showUpgradeSuccessDialog();
}
/// 处理升级错误
/// [errorCode] 错误代码
/// 更新状态并显示错误提示
void _handleUpgradeError(dynamic errorCode) {
type.value = OtaStatusManager.UPGRADE_FAILED;
isUpgrade.value = false;
detailedStatus.value = '升级失败,错误码:$errorCode';
showDetailedStatus.value = false;
_uiManager.showErrorSnackbar(
'升级失败',
'请检查设备连接或升级包,错误码:$errorCode',
);
}
/// 处理连接断开
/// 更新连接状态
void _handleConnectionLost() {
type.value = OtaStatusManager.CONNECTION_DISCONNECTION;
isUpgrade.value = false;
detailedStatus.value = '设备连接断开';
showDetailedStatus.value = false;
}
/// 处理连接成功
/// 更新连接状态
void _handleConnectionSuccess() {
type.value = OtaStatusManager.CONNECTION_SUCCESS;
isUpgrade.value = true;
detailedStatus.value = '设备连接成功';
}
/// 统一错误处理
/// [title] 错误标题
/// [message] 错误信息
/// 设置错误状态并显示错误提示
void _handleError(String title, String message) {
errorMessage.value = message;
_uiManager.showErrorSnackbar(title, message);
}
}

99
lib/modules/ota_upgrade/services/device_checker.dart

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../data/services/ble_manager.dart';
/// 设备检查服务
/// 负责检查设备状态,如电量检查等
class DeviceChecker {
final BleManager _bleManager = Get.find<BleManager>();
/// 检查设备电量是否满足升级条件
/// 返回true表示电量充足,可以进行升级
/// 返回false表示电量不足,不能进行升级
bool checkBatteryLevel() {
try {
final deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo == null) {
_showBatteryCheckFailedSnackbar('无法获取设备信息,请检查设备连接');
return false;
}
final leftBattery = deviceInfo.leftEarBattery;
final rightBattery = deviceInfo.rightEarBattery;
// 检查左右耳电量是否都大于30%
if (leftBattery <= 30 || rightBattery <= 30) {
_showLowBatteryDialog(leftBattery, rightBattery);
return false;
}
return true;
} catch (e) {
_showBatteryCheckFailedSnackbar('检查设备电量时发生错误:$e');
return false;
}
}
/// 显示电量不足对话框
/// [leftBattery] 左耳电量
/// [rightBattery] 右耳电量
void _showLowBatteryDialog(int leftBattery, int rightBattery) {
String message = '设备电量不足,无法进行固件升级\n';
message += '升级要求:左右耳电量均需大于30%\n';
message += '当前电量:左耳 ${leftBattery}%,右耳 ${rightBattery}%\n';
message += '请充电后再试';
Get.dialog(
AlertDialog(
title: Text(
'电量不足',
style: TextStyle(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
message,
style: TextStyle(
color: Colors.orange[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.orange[700],
),
child: const Text('确定'),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.orange[50],
),
);
}
/// 显示电量检查失败提示
/// [message] 错误信息
void _showBatteryCheckFailedSnackbar(String message) {
Get.snackbar(
'电量检查失败',
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: const Icon(
Icons.warning_amber,
color: Colors.white,
),
);
}
}

153
lib/modules/ota_upgrade/services/download_manager.dart

@ -0,0 +1,153 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart';
import 'package:logger/web.dart';
/// 文件下载管理器
/// 负责处理固件包的下载、文件管理等功能
class DownloadManager {
late final Dio _dio;
/// 构造函数
/// 初始化Dio实例并配置SSL证书处理
DownloadManager() {
_dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30), // 增加连接超时时间
receiveTimeout: Duration.zero,
sendTimeout: const Duration(seconds: 30), // 增加发送超时时间
headers: {'Accept-Encoding': 'gzip'},
));
// 配置SSL证书验证
(_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
// 处理SSL证书验证
client.badCertificateCallback =
(X509Certificate cert, String host, int port) {
// 在生产环境中,应该进行适当的证书验证
// 这里为了解决证书过期问题,暂时跳过验证
// 建议:在生产环境中实现更安全的证书验证逻辑
Logger().w('SSL证书验证跳过: $host:$port');
return true;
};
return client;
};
}
/// 下载固件包
/// [downloadUrl] 下载地址
/// [onProgress] 下载进度回调
/// [onStatusUpdate] 状态更新回调
/// 返回下载文件的本地路径
/// 抛出异常如果下载失败
Future<String> downloadFirmware({
required String downloadUrl,
required Function(double) onProgress,
required Function(String) onStatusUpdate,
}) async {
onStatusUpdate('准备下载固件包...');
final fileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
const directoryName = "ota_upgrades";
// 获取下载目录
onStatusUpdate('正在获取下载目录...');
final Directory? downloadsDir = await getApplicationSupportDirectory();
if (downloadsDir == null) {
throw FileSystemException('无法访问下载目录');
}
final targetDir = Directory('${downloadsDir.path}/Download/$directoryName');
if (!await targetDir.exists()) {
onStatusUpdate('创建下载目录...');
await targetDir.create(recursive: true);
}
final savePath = '${targetDir.path}/$fileName';
// 检查是否已存在同名文件
onStatusUpdate('检查本地文件...');
if (await _checkExistingFile(targetDir, fileName)) {
onStatusUpdate('文件已存在,准备升级...');
return savePath;
}
// 清理旧文件
onStatusUpdate('清理旧文件...');
await _cleanOldFiles(targetDir);
// 开始下载
onStatusUpdate('开始下载固件包...');
Logger().d('文件保存路径: $savePath');
final options = Options(
receiveTimeout: Duration.zero,
followRedirects: true,
maxRedirects: 5,
);
await _dio.download(
downloadUrl,
savePath,
options: options,
onReceiveProgress: (received, total) {
if (total != -1) {
final progress = received / total;
onProgress(progress);
onStatusUpdate('正在下载固件包... ${(progress * 100).toStringAsFixed(1)}%');
}
},
);
// 验证文件是否下载成功
final file = File(savePath);
if (!await file.exists()) {
throw FileSystemException('文件下载失败,请检查写入权限');
}
onStatusUpdate('下载完成,准备升级...');
return savePath;
}
/// 检查是否存在同名文件
/// [targetDir] 目标目录
/// [fileName] 文件名
/// 返回true表示文件已存在
Future<bool> _checkExistingFile(Directory targetDir, String fileName) async {
try {
final files = await targetDir.list().toList();
for (var entity in files) {
if (await FileSystemEntity.isFile(entity.path)) {
final name = basename(entity.path);
if (name == fileName) {
return true;
}
}
}
return false;
} catch (e) {
Logger().d('检查本地文件失败: $e');
return false;
}
}
/// 清理旧文件
/// [targetDir] 目标目录
/// 抛出异常如果清理失败
Future<void> _cleanOldFiles(Directory targetDir) async {
try {
final files = await targetDir.list().toList();
for (var entity in files) {
if (await FileSystemEntity.isFile(entity.path)) {
await entity.delete();
}
}
} catch (e) {
throw Exception('删除旧文件失败: $e');
}
}
}

43
lib/modules/ota_upgrade/services/ota_status_manager.dart

@ -0,0 +1,43 @@
import 'package:get/get.dart';
/// OTA升级状态管理器
/// 负责管理OTA升级过程中的各种状态常量和状态描述
class OtaStatusManager {
static const int NOT_STARTED = -1; // 未开始
static const int VALIDATION_FILE = 0; // 验证文件
static const int ONUPGRADE = 1; // 升级中
static const int START_UPGRADING = 2; // 开始升级
static const int UPGRADE_SUCCESS = 3; // 升级成功
static const int UPGRADE_FAILED = 4; // 升级失败
static const int CONNECTION_DISCONNECTION = 5; // 连接断开
static const int CONNECTION_SUCCESS = 6; // 连接成功
static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件
/// 获取状态描述信息
/// [type] 状态类型
/// 返回对应的状态描述文本
static String getStatusMessage(int type) {
switch (type) {
case NOT_STARTED:
return "未开始".tr;
case VALIDATION_FILE:
return "验证文件中".tr;
case ONUPGRADE:
return "升级中".tr;
case START_UPGRADING:
return "开始升级".tr;
case UPGRADE_SUCCESS:
return "升级成功".tr;
case UPGRADE_FAILED:
return "升级失败".tr;
case CONNECTION_DISCONNECTION:
return "连接断开".tr;
case CONNECTION_SUCCESS:
return "连接成功".tr;
case DOWNLOADING_UP_FILES:
return "下载升级文件".tr;
default:
return "未知状态".tr;
}
}
}

117
lib/modules/ota_upgrade/services/ui_manager.dart

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:logger/web.dart';
import '/routes/app_routes.dart';
/// UI交互管理器
/// 负责处理所有与用户界面交互相关的功能
class UiManager {
/// 显示更新确认对话框
/// [latestVersion] 最新版本号
/// [onConfirm] 确认更新的回调函数
void showUpdateDialog(String latestVersion, VoidCallback onConfirm) {
Logger().d('显示更新对话框');
Get.dialog(
AlertDialog(
title: Text('发现新版本 $latestVersion'),
content: const Text('是否立即更新?'),
actions: [
TextButton(
onPressed: () {
Logger().d('取消按钮被点击');
Get.back();
},
child: const Text('取消'),
),
TextButton(
onPressed: () {
Logger().d('更新按钮被点击');
Get.back();
Future.delayed(const Duration(milliseconds: 300));
onConfirm();
},
child: const Text('更新'),
),
],
),
);
}
/// 显示升级成功对话框
/// 引导用户进行设备重新配对
void showUpgradeSuccessDialog() {
Get.dialog(
AlertDialog(
title: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green[600],
size: 28,
),
SizedBox(width: 12.w),
Text(
'升级成功',
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.w600,
),
),
],
),
content: const Text(
'固件升级已完成!\n\n设备需要重新配对才能正常使用,现在跳转到配对页面吗?',
style: TextStyle(
fontSize: 16,
height: 1.4,
),
),
actions: [
TextButton(
onPressed: () {
Get.back();
},
child: Text(
'稍后配对',
style: TextStyle(color: Colors.grey[600]),
),
),
ElevatedButton(
onPressed: () {
Get.back();
Get.toNamed(Routes.pairing);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[600],
foregroundColor: Colors.white,
),
child: const Text('立即配对'),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
barrierDismissible: false,
);
}
/// 显示错误提示
/// [title] 错误标题
/// [message] 错误信息
/// [backgroundColor] 背景颜色,可选
void showErrorSnackbar(String title, String message, {Color? backgroundColor}) {
Get.snackbar(
title,
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: backgroundColor ?? Colors.red[600],
colorText: Colors.white,
icon: const Icon(
Icons.error_outline,
color: Colors.white,
),
);
}
}

114
lib/modules/ota_upgrade/services/version_manager.dart

@ -0,0 +1,114 @@
import 'package:logger/web.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
import 'package:get/get.dart';
/// 版本管理服务
/// 负责处理设备版本信息获取、版本比较等功能
class VersionManager {
final BleManager _bleManager = Get.find<BleManager>();
final int maxRetryAttempts = 3;
int currentRetryCount = 0;
/// 获取当前设备版本信息
/// 返回包含当前版本、产品ID和设备信息的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getCurrentVersionInfo() async {
DeviceInfo? deviceInfo = _bleManager.deviceInfo.value;
// 重试机制获取设备信息
while (currentRetryCount < maxRetryAttempts) {
deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo != null &&
deviceInfo.firmwareVersion.isNotEmpty &&
deviceInfo.firmwareProductId.isNotEmpty) {
break;
}
await _bleManager.fetchDeviceInfo();
await Future.delayed(const Duration(seconds: 5));
currentRetryCount++;
}
if (deviceInfo == null ||
deviceInfo.firmwareVersion.isEmpty ||
deviceInfo.firmwareProductId.isEmpty) {
throw "获取设备信息失败,请检查连接";
}
// 验证固件版本格式
final firmwareParts = deviceInfo.firmwareVersion.split('.');
final firstPart = firmwareParts.sublist(0, 3).join('.');
final secondPart = firmwareParts.sublist(3).join('.');
Logger().d("firstPart: $firstPart, secondPart: $secondPart");
if (firstPart != secondPart) {
throw "固件版本格式错误:${deviceInfo.firmwareVersion}";
}
return {
'currentVersion': firstPart,
'productId': deviceInfo.firmwareProductId,
'deviceInfo': deviceInfo,
};
}
/// 获取最新版本信息
/// 返回包含最新版本、更新描述和更新包地址的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getLatestVersionInfo() async {
DBProduct? product = AppConfig.getproduct("DeapSound");
if (product == null) {
throw "无法获取产品信息";
}
return {
'latestVersion': product.version ?? "",
'updateDescription': product.updatedescription ?? "",
'updatePackageAddress': product.updatepackageaddress ?? "",
};
}
/// 比较版本号,判断是否有新版本可用
/// [currentVersion] 当前版本号
/// [latestVersion] 最新版本号
/// 返回true表示有新版本可用(支持升级和降级)
bool isNewVersionAvailable(String currentVersion, String latestVersion) {
try {
List<int> currentParts = currentVersion
.split('.')
.map((e) => int.parse(e.trim()))
.toList();
List<int> latestParts =
latestVersion.split('.').map((e) => int.parse(e.trim())).toList();
// 确保两个版本号长度一致,短的用0补齐
int maxLength = currentParts.length > latestParts.length
? currentParts.length
: latestParts.length;
while (currentParts.length < maxLength) {
currentParts.add(0);
}
while (latestParts.length < maxLength) {
latestParts.add(0);
}
// 比较每一段,只要有不同就返回true(支持升级和降级)
for (int i = 0; i < maxLength; i++) {
if (latestParts[i] != currentParts[i]) {
return true;
}
}
return false;
} catch (e) {
Logger().d('版本比较错误: $e');
return false;
}
}
}

23
lib/modules/translation/controllers/translation_controller.dart

@ -587,6 +587,7 @@ class TranslationController extends GetxController {
scrollController.dispose();
restoreOtherServices();
_asrService.dispose();
_astService.dispose();
super.onClose();
}
@ -753,7 +754,9 @@ class TranslationController extends GetxController {
try {
await stopRecording();
await _asrService.stopContinuousRecognition();
await _astService.stopContinuousTranslation();
if (currentMode.value == "call") {
await _astService.stopContinuousTranslation();
}
// 停止ASR活跃时长计时
_stopAsrActiveTracking();
@ -1214,23 +1217,19 @@ class TranslationController extends GetxController {
await _asrService.initialize(
supportedLanguages: asrSupportedLanguages,
);
// Future<bool> initialize({
// required String subscriptionKey,
// required String region,
// required List<String> supportedLanguages,
// required String audioSourceType,
// required String translationAccessKey,
// required String translationSecretKey,
// String translationRegion = 'cn-north-1',
// });
// 重新初始化ASR服务以支持通话音频源
await _astService.initialize(supportedLanguages: asrSupportedLanguages);
// 获取识别事件流 - 这启动了异步识别过程
var recognitionStream = await _asrService.recognizeCallback();
// 监听识别结果
_recognitionSubscription?.cancel();
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
//判断是否进入通话
if (currentMode.value == "call") {
// 重新初始化ASR服务以支持通话音频源
_initializeCallModeTranslationService();
}
Logger.info('ASR服务重新初始化成功');
} catch (e) {
Logger.error('重新初始化ASR服务失败: ${e.toString()}');

76
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -120,7 +120,10 @@ object AgentService : CoroutineScope {
// 空闲检测相关
private var idleCheckJob: Job? = null
private val maxIdleSeconds = 10 // 最大空闲秒数
private val maxIdleSeconds = 10 // 最大空闲秒数(默认值)
// 语音识别模式
private var currentRecognitionMode = "normal" // 当前识别模式:normal, ble_wakeup, phone_call, push_to_talk
// 打断防抖相关
private var lastInterruptTime = 0L
@ -412,22 +415,63 @@ object AgentService : CoroutineScope {
listeners.clear()
}
/**
* 根据模式启动空闲检测
*/
private fun startIdleCheckForMode(mode: String) {
stopIdleCheck() // 先停止现有的检查
if (!isRecognitionActive) return
// 根据模式决定空闲检测策略
when (mode) {
"phone_call" -> {
// 通话模式:禁用空闲检测,保持持续激活
Log.d(TAG, "通话模式:禁用空闲检测")
return
}
"ble_wakeup" -> {
// BLE唤醒模式:使用默认的空闲检测
startIdleCheck(maxIdleSeconds)
}
"push_to_talk" -> {
// 按住说话模式:使用较长的空闲检测时间
startIdleCheck(maxIdleSeconds)
}
"normal" -> {
// 普通模式:使用默认的空闲检测
startIdleCheck(maxIdleSeconds)
}
else -> {
// 未知模式:使用默认的空闲检测
Log.w(TAG, "未知识别模式: $mode,使用默认空闲检测")
startIdleCheck(maxIdleSeconds)
}
}
}
/**
* 启动空闲检测
*/
private fun startIdleCheck() {
private fun startIdleCheck(idleSeconds: Int = maxIdleSeconds) {
stopIdleCheck() // 先停止现有的检查
if (!isRecognitionActive) return
Log.d(TAG, "启动空闲检测,超时时间: ${idleSeconds}秒,模式: $currentRecognitionMode")
idleCheckJob = launch {
try {
delay(maxIdleSeconds * 1000L)
delay(idleSeconds * 1000L)
if (isActive && isRecognitionActive && !isTtsSpeaking && !isAiStreaming) {
Log.d(TAG, "空闲检测触发自动停止,模式: $currentRecognitionMode")
stopRecognition()
sendEvent(
"auto_stop",
mapOf("reason" to "idle_timeout", "seconds" to maxIdleSeconds)
mapOf(
"reason" to "idle_timeout",
"seconds" to idleSeconds,
"mode" to currentRecognitionMode
)
)
}
} catch (e: CancellationException) {
@ -452,14 +496,16 @@ object AgentService : CoroutineScope {
*/
private fun restartIdleCheck() {
if (isRecognitionActive) {
startIdleCheck()
startIdleCheckForMode(currentRecognitionMode)
}
}
/**
* 开始语音识别
* @param isExternalActive 是否使用外部音频源(蓝牙)
* @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk
*/
fun startRecognition(isExternalActive: Boolean): Boolean {
fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean {
if (!isInitialized) {
Log.d(TAG, "服务未初始化")
return false
@ -491,6 +537,10 @@ object AgentService : CoroutineScope {
return false
}
// 设置当前识别模式
currentRecognitionMode = mode
Log.d(TAG, "设置语音识别模式: $mode")
_isRecognitionActive.set(true)
_hasSpeechDetected.set(false)
@ -503,6 +553,9 @@ object AgentService : CoroutineScope {
Log.d("TAG", "azureAsrHelper?.startContinuousRecognition")
azureAsrHelper?.startContinuousRecognition(audioSourceType)
// 根据模式启动相应的空闲检测
startIdleCheckForMode(mode)
return true
} catch (e: Exception) {
_isRecognitionActive.set(false)
@ -589,7 +642,8 @@ object AgentService : CoroutineScope {
override fun onSessionStarted() {
sendEvent("recognition_started", mapOf("status" to "started"))
startIdleCheck()
// 使用模式感知的空闲检测启动方法
startIdleCheckForMode(currentRecognitionMode)
audioPlayer?.playAudio(R.raw.start)
}
@ -647,16 +701,24 @@ object AgentService : CoroutineScope {
fun stopRecognition() {
if (!isRecognitionActive) return
Log.d(TAG, "停止语音识别,当前模式: $currentRecognitionMode")
try {
azureAsrHelper?.stopContinuousRecognition()
BleService.closeCodec()
_isRecognitionActive.set(false)
stopIdleCheck()
// 重置识别模式
currentRecognitionMode = "normal"
return
} catch (e: Exception) {
Log.e(TAG, "停止语音识别异常: ${e.message}")
_isRecognitionActive.set(false)
stopIdleCheck()
// 重置识别模式
currentRecognitionMode = "normal"
return
}
}

3
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt

@ -115,7 +115,8 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
"startConversation" -> {
try {
val isStarted = AgentService.startRecognition(false)
val mode = call.argument<String>("mode") ?: "normal"
val isStarted = AgentService.startRecognition(false, mode)
result.success(isStarted)
} catch (e: Exception) {
Log.e(TAG, "启动对话失败", e)

8
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt

@ -66,7 +66,7 @@ object BleAgent : BleService.Callback, AgentServiceListener {
return false
}
Log.d(TAG, "手动启动语音识别: ")
return AgentService.startRecognition(true)
return AgentService.startRecognition(true, "ble_wakeup")
}
/**
@ -148,11 +148,11 @@ Log.d(TAG, "手动启动语音识别: ")
// 停止当前TTS,避免冲突
AgentService.interruptCurrentResponse()
// AgentService.speakText("我在!")
// 启动语音识别
// 启动语音识别,使用BLE唤醒模式
// 如果已经在识别,则重新启动
//stopListening()
//stopListening()
startListening()

86
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -66,6 +66,9 @@ class AgentServiceImpl: NSObject {
private var idleTimer: DispatchSourceTimer?
private let maxIdleSeconds: TimeInterval = 10
// 语音识别模式
private var currentRecognitionMode: String = "normal"
internal var audioPlayer: AudioPlayer?
// private var locationInfo:[String: String]?
@ -281,26 +284,56 @@ class AgentServiceImpl: NSObject {
return true
}
private func startIdleCheck() {
private func startIdleCheckForMode(mode: String) {
stopIdleCheck()
guard isRecognizing else { return }
// 根据模式决定空闲检测策略
switch mode {
case "phone_call":
// 通话模式:禁用空闲检测,保持持续激活
os_log("通话模式:禁用空闲检测", log: logger, type: .info)
return
case "ble_wakeup":
// BLE唤醒模式:使用默认的空闲检测
startIdleCheck(idleSeconds: maxIdleSeconds)
case "push_to_talk":
// 按住说话模式:使用默认的空闲检测
startIdleCheck(idleSeconds: maxIdleSeconds)
case "normal":
// 普通模式:使用默认的空闲检测
startIdleCheck(idleSeconds: maxIdleSeconds)
default:
// 未知模式:使用默认的空闲检测
os_log("未知识别模式: %{public}@,使用默认空闲检测", log: logger, type: .error, mode)
startIdleCheck(idleSeconds: maxIdleSeconds)
}
}
private func startIdleCheck(idleSeconds: TimeInterval = 10) {
stopIdleCheck()
guard isRecognizing else { return }
os_log("启动空闲检测,超时时间: %.0f秒,模式: %{public}@", log: logger, type: .info, idleSeconds, currentRecognitionMode)
// 使用全局队列而不是主队列,避免后台挂起问题
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
idleTimer = timer
timer.schedule(deadline: .now() + maxIdleSeconds)
timer.schedule(deadline: .now() + idleSeconds)
timer.setEventHandler { [weak self] in
// 使用更安全的方式检查self是否存在
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.isRecognizing && !self.isSpeaking && !self.isAiStreaming {
os_log("空闲检测触发自动停止,模式: %{public}@", log: self.logger, type: .info, self.currentRecognitionMode)
self.stopRecognition()
self.sendEvent(name: "auto_stop", data: [
"reason": "idle_timeout",
"seconds": self.maxIdleSeconds
"reason": "idle_timeout",
"seconds": idleSeconds,
"mode": self.currentRecognitionMode
])
}
}
@ -315,10 +348,10 @@ class AgentServiceImpl: NSObject {
private func restartIdleCheck() {
if isRecognizing {
startIdleCheck()
startIdleCheckForMode(mode: currentRecognitionMode)
}
}
func startRecognition(useBle: Bool = false) -> Bool {
func startRecognition(useBle: Bool = false, mode: String = "normal") -> Bool {
if !isInitialized {
sendError("服务未初始化", code: "NOT_INITIALIZED")
return false
@ -334,9 +367,13 @@ class AgentServiceImpl: NSObject {
if isRecognizing {
stopRecognition()
}
// 设置当前识别模式
currentRecognitionMode = mode
os_log("设置语音识别模式: %{public}@", log: logger, type: .info, mode)
let audioSourceType: AzureAsrHelper.AudioSourceType = useBle ? .external : .microphone
if useBle {
AudioSessionHub.shared.begin(.playback)
} else {
@ -350,7 +387,10 @@ print("ai启动语音=\(audioSourceType)=\(useBle)")
sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR")
return false
}
// 根据模式启动相应的空闲检测
startIdleCheckForMode(mode: mode)
return true
}
func recognizeCallback() -> Bool {
@ -368,11 +408,18 @@ print("ai启动语音=\(audioSourceType)=\(useBle)")
if !isRecognizing {
return true
}
os_log("停止语音识别,当前模式: %{public}@", log: logger, type: .info, currentRecognitionMode)
guard let success = azureAsrHelper?.stopContinuousRecognition(), success else {
return false
}
stopIdleCheck()
// 重置识别模式
currentRecognitionMode = "normal"
return true
}
func disableBluetoothAudio() {
@ -1422,10 +1469,10 @@ extension AgentServiceImpl: BleService.Callback {
func onWakeupSignalReceived() {
stopTts()
stopRecognition()
startRecognition(useBle: true)
startRecognition(useBle: true, mode: "ble_wakeup")
}
func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) {
@ -1654,7 +1701,8 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
sendEvent(name: "recognition_started", data: ["status": "started"])
isRecognizing = true
hasSpeechDetected = false
startIdleCheck()
// 使用模式感知的空闲检测启动方法
startIdleCheckForMode(mode: currentRecognitionMode)
audioPlayer?.playStartSound()
}

3
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift

@ -41,7 +41,8 @@ public class AgentServicePlugin: NSObject, FlutterPlugin {
result(success)
case "startConversation":
impl.startRecognition()
let mode = (call.arguments as? [String: Any])?["mode"] as? String ?? "normal"
impl.startRecognition(mode: mode)
result(true)
case "recognizeCallback":

7
local_plugins/agent_service/lib/agent_service.dart

@ -238,10 +238,13 @@ class AgentService {
/// 开始对话
///
/// 启动语音识别,开始监听用户语音输入
/// [mode] 语音识别模式:'ble_wakeup'(BLE唤醒), 'phone_call'(通话模式), 'push_to_talk'(按住说话), 'normal'(普通模式)
/// 返回是否成功开始对话
static Future<bool> startConversation() async {
static Future<bool> startConversation({String mode = 'normal'}) async {
try {
final bool result = await _channel.invokeMethod('startConversation');
final bool result = await _channel.invokeMethod('startConversation', {
'mode': mode,
});
return result;
} on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '启动对话失败', e.details);

343
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt

@ -735,6 +735,107 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) {
eventCallback?.onStateChanged("AudioPlayback", enabled)
}
/**
* 动态修改语音识别语言
* @param newLanguage 新的识别语言代码,如 "zh-CN", "en-US", "ja-JP" 等
* @param restartRecognition 是否重启识别服务,默认为true
*/
fun changeRecognitionLanguage(newLanguage: String, restartRecognition: Boolean = true) {
try {
val wasRecognizing = serviceState.isRecognizing.get()
// 如果正在识别,先停止
if (wasRecognizing && restartRecognition) {
stopContinuousTranslation()
}
// 更新配置
serviceConfig.sourceLanguage = newLanguage
speechConfig?.speechRecognitionLanguage = newLanguage
// 重新设置识别器
setupSpeechRecognizer()
Log.d(TAG, "识别语言已更改为: $newLanguage")
eventCallback?.onStateChanged("LanguageChanged", true)
// 如果之前在识别且需要重启,则重新开始
if (wasRecognizing && restartRecognition) {
startContinuousTranslation()
}
} catch (e: Exception) {
Log.e(TAG, "更改识别语言失败", e)
eventCallback?.onError("LanguageChange", "更改识别语言失败: ${e.message}")
}
}
/**
* 批量设置多语言识别
* @param languages 支持的语言列表
* @param enableAutoDetection 是否启用自动语言检测
*/
fun setupMultiLanguageRecognition(languages: List<String>, enableAutoDetection: Boolean = true) {
try {
serviceConfig.enableAutoLanguageDetection = enableAutoDetection
if (enableAutoDetection && languages.isNotEmpty()) {
// 设置自动语言检测的候选语言
speechConfig?.setProperty("SpeechServiceConnection_LanguageIdMode", "Continuous")
// 构建语言候选列表
val languageList = languages.joinToString(",")
speechConfig?.setProperty("SpeechServiceConnection_ContinuousLanguageIdPriority", languageList)
Log.d(TAG, "多语言识别已设置,支持语言: $languageList")
}
// 重新设置识别器
setupSpeechRecognizer()
} catch (e: Exception) {
Log.e(TAG, "设置多语言识别失败", e)
eventCallback?.onError("MultiLanguageSetup", "设置多语言识别失败: ${e.message}")
}
}
/**
* 获取当前支持的语言列表
*/
fun getSupportedLanguages(): List<String> {
return listOf(
"zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR",
"fr-FR", "de-DE", "es-ES", "ru-RU", "ar-SA", "pt-BR",
"it-IT", "th-TH", "vi-VN", "hi-IN"
)
}
/**
* 设置识别参数
* @param endSilenceTimeout 结束静音超时时间(毫秒)
* @param segmentationTimeout 分段静音超时时间(毫秒)
* @param initialSilenceTimeout 初始静音超时时间(毫秒)
*/
fun setRecognitionParameters(
endSilenceTimeout: Int = 300,
segmentationTimeout: Int = 300,
initialSilenceTimeout: Int = 200
) {
try {
speechConfig?.apply {
setProperty("SpeechServiceConnection_EndSilenceTimeoutMs", endSilenceTimeout.toString())
setProperty("Speech_SegmentationSilenceTimeoutMs", segmentationTimeout.toString())
setProperty("SpeechServiceConnection_InitialSilenceTimeoutMs", initialSilenceTimeout.toString())
}
Log.d(TAG, "识别参数已更新: 结束静音=${endSilenceTimeout}ms, 分段静音=${segmentationTimeout}ms, 初始静音=${initialSilenceTimeout}ms")
} catch (e: Exception) {
Log.e(TAG, "设置识别参数失败", e)
eventCallback?.onError("ParameterSetup", "设置识别参数失败: ${e.message}")
}
}
/**
* 获取当前音频播放状态
* @return true表示启用音频播放,false表示禁用
@ -743,6 +844,54 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) {
return serviceConfig.enableAudioPlayback
}
/**
* 暂停服务(保留资源)
*/
fun pause() {
try {
if (serviceState.isRecognizing.get()) {
recognizer?.stopContinuousRecognitionAsync()
}
audioProcessor?.stopRecording()
Log.d(TAG, "服务已暂停")
} catch (e: Exception) {
Log.e(TAG, "暂停服务失败", e)
}
}
/**
* 恢复服务
*/
fun resume() {
try {
if (serviceState.isInitialized.get() && serviceConfig.enableContinuousRecognition) {
startContinuousTranslation()
Log.d(TAG, "服务已恢复")
}
} catch (e: Exception) {
Log.e(TAG, "恢复服务失败", e)
}
}
/**
* 检查服务健康状态
*/
fun checkServiceHealth(): Map<String, Any> {
return mapOf(
"initialized" to serviceState.isInitialized.get(),
"recognizing" to serviceState.isRecognizing.get(),
"translating" to serviceState.isTranslating.get(),
"synthesizing" to serviceState.isSynthesizing.get(),
"speechConfig" to (speechConfig != null),
"recognizer" to (recognizer != null),
"synthesizer" to (synthesizer != null),
"audioProcessor" to (audioProcessor != null),
"translationService" to (translationService != null),
"currentLanguage" to serviceConfig.sourceLanguage,
"targetLanguage" to serviceConfig.targetLanguage
)
}
/**
* 单独的语音合成方法,只合成不播放,返回音频数据
* @param text 要合成的文本
@ -801,37 +950,125 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) {
}
/**
* 释放资源
* 完善的资源释放方法
*/
fun dispose() {
try {
// 停止所有活动
launch {
stopContinuousTranslation()
Log.d(TAG, "开始释放服务资源...")
// 1. 设置释放标志,防止新的操作
serviceState.isInitialized.set(false)
// 2. 停止所有活动(使用runBlocking确保同步完成)
runBlocking {
try {
withTimeout(5000) { // 5秒超时
stopContinuousTranslation()
}
} catch (e: TimeoutCancellationException) {
Log.w(TAG, "停止识别超时,强制继续释放资源")
}
}
// 释放Azure资源
recognizer?.close()
synthesizer?.close()
speechConfig?.close()
// 释放音频处理器
audioProcessor?.dispose()
// 释放翻译服务
translationService?.dispose()
// 取消协程
job.cancel()
// 清理缓存
// 3. 等待当前处理完成(带超时)
val maxWaitTime = 3000L // 3秒
val startTime = System.currentTimeMillis()
while ((serviceState.isTranslating.get() || serviceState.isSynthesizing.get() || serviceState.isRecognizing.get())
&& (System.currentTimeMillis() - startTime) < maxWaitTime) {
Thread.sleep(100)
}
// 4. 强制停止识别器
try {
recognizer?.stopContinuousRecognitionAsync()?.get(2, TimeUnit.SECONDS)
} catch (e: Exception) {
Log.w(TAG, "停止识别器超时,强制关闭")
}
// 5. 释放Azure资源(按顺序释放)
try {
recognizer?.close()
recognizer = null
Log.d(TAG, "语音识别器已释放")
} catch (e: Exception) {
Log.e(TAG, "释放识别器失败", e)
}
try {
synthesizer?.close()
synthesizer = null
Log.d(TAG, "语音合成器已释放")
} catch (e: Exception) {
Log.e(TAG, "释放合成器失败", e)
}
try {
audioConfig?.close()
audioConfig = null
Log.d(TAG, "音频配置已释放")
} catch (e: Exception) {
Log.e(TAG, "释放音频配置失败", e)
}
try {
speechConfig?.close()
speechConfig = null
Log.d(TAG, "语音配置已释放")
} catch (e: Exception) {
Log.e(TAG, "释放语音配置失败", e)
}
// 6. 释放音频处理器
try {
audioProcessor?.dispose()
audioProcessor = null
Log.d(TAG, "音频处理器已释放")
} catch (e: Exception) {
Log.e(TAG, "释放音频处理器失败", e)
}
// 7. 释放录音文件
try {
recordfile1?.closeFile(true)
recordfile1 = null
Log.d(TAG, "录音文件已释放")
} catch (e: Exception) {
Log.e(TAG, "释放录音文件失败", e)
}
// 8. 释放翻译服务
try {
translationService?.dispose()
translationService = null
Log.d(TAG, "翻译服务已释放")
} catch (e: Exception) {
Log.e(TAG, "释放翻译服务失败", e)
}
// 9. 取消协程(使用cancelAndJoin确保完全停止)
try {
runBlocking {
job.cancelAndJoin()
}
Log.d(TAG, "协程已取消")
} catch (e: Exception) {
Log.e(TAG, "取消协程失败", e)
}
// 10. 清理缓存和回调
voiceCache.clear()
serviceState.isInitialized.set(false)
Log.d(TAG, "服务资源已释放")
eventCallback = null
filePath = null
// 11. 重置状态
serviceState.isRecognizing.set(false)
serviceState.isSynthesizing.set(false)
serviceState.isTranslating.set(false)
Log.d(TAG, "服务资源释放完成")
} catch (e: Exception) {
Log.e(TAG, "释放资源失败", e)
Log.e(TAG, "释放资源过程中发生异常", e)
}
}
@ -895,11 +1132,59 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) {
}
}
/**
* 改进的资源释放方法
*/
fun dispose() {
isRunning.set(false)
processingThread?.interrupt()
pushAudioStream?.close()
audioQueue.clear()
try {
Log.d(TAG, "开始释放音频处理器资源...")
// 1. 停止运行标志
isRunning.set(false)
// 2. 中断处理线程
processingThread?.let { thread ->
if (thread.isAlive) {
thread.interrupt()
try {
// 等待线程结束,最多等待2秒
thread.join(2000)
if (thread.isAlive) {
Log.w(TAG, "音频处理线程未能正常结束")
} else {
Log.d(TAG, "音频处理线程已结束")
}
} catch (e: InterruptedException) {
Log.w(TAG, "等待音频处理线程结束时被中断")
Thread.currentThread().interrupt()
}
}
}
processingThread = null
// 3. 关闭音频流
try {
pushAudioStream?.close()
pushAudioStream = null
Log.d(TAG, "音频流已关闭")
} catch (e: Exception) {
Log.e(TAG, "关闭音频流失败", e)
}
// 4. 清理音频队列
try {
val remainingData = audioQueue.size
audioQueue.clear()
Log.d(TAG, "音频队列已清理,丢弃 $remainingData 个数据包")
} catch (e: Exception) {
Log.e(TAG, "清理音频队列失败", e)
}
Log.d(TAG, "音频处理器资源释放完成")
} catch (e: Exception) {
Log.e(TAG, "释放音频处理器资源时发生异常", e)
}
}
}
}

5
local_plugins/azure_speech/ios/azure_speech/Package.swift

@ -18,7 +18,8 @@ let package = Package(
.product(name: "speech", package: "speech"),
"MicrosoftCognitiveServicesSpeech"
],
path: "Sources/azure_speech"
path: "Sources", // 修改为包含整个 Sources 目录
sources: ["azure_speech/", "tools/"] // 明确指定包含的子目录
),
// 使用binaryTarget引用Azure Speech SDK的.xcframework
.binaryTarget(
@ -26,4 +27,4 @@ let package = Package(
path: "MicrosoftCognitiveServicesSpeech.xcframework"
)
]
)
)

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

@ -1,7 +1,6 @@
import Foundation
import AVFoundation
import MicrosoftCognitiveServicesSpeech
// 自定义语音处理组件,提供音频流处理等功能
import speech
import os.log
@ -32,7 +31,6 @@ public class AzureAsrHelper: NSObject {
private var subscriptionKey = ""
private var region = ""
// 音频源配置
public enum AudioSourceType {
/** 使用设备麦克风 */
@ -48,9 +46,8 @@ public class AzureAsrHelper: NSObject {
// private var externalAudioStream: ExternalAudioPullStream?
// 音频处理
public var audioStream: AudioStream?
//录音文件
public var recordfile: RecordFile?
public var audioStream: SimpleAudioReceiver?
/**
* 初始化Azure语音服务
*
@ -110,11 +107,6 @@ public class AzureAsrHelper: NSObject {
os_log("固定语言模式,当前语言: %{public}@", log: log, type: .info, currentLanguage)
}
// 录音文件类
recordfile = RecordFile()
// 预初始化音频组件
preInitializeAudioComponents()
@ -131,18 +123,16 @@ public class AzureAsrHelper: NSObject {
*/
private func preInitializeAudioComponents() {
if audioStream == nil {
audioStream = AudioStream(parentHelper: self)
audioStream = SimpleAudioReceiver(parentHelper: self)
audioStream?.initAudioRecord()
audioStream?.onAudioData = { [weak self] data in
self?.recordfile?.saveAudioDataToWav(data)
}
}
// 预创建音频配置
if let pushStream = audioStream?.pushAudioStream {
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("预初始化音频组件完成", log: log, type: .info)
}
}
}
@ -154,7 +144,8 @@ public class AzureAsrHelper: NSObject {
* @return 是否成功开始识别
*/
public func startContinuousRecognition(
audioSourceType: AudioSourceType = .microphone
audioSourceType: AudioSourceType = .microphone,
audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil
) -> Bool {
guard speechConfig != nil else {
print("语音服务未初始化")
@ -180,7 +171,7 @@ public class AzureAsrHelper: NSObject {
return false
}
// 启动音频处理
audioStream?.startAudioInput(audioSourceType: audioSourceType)
audioStream?.startAudioRecord(audioSourceType: audioSourceType == .microphone ? .microphone : .external, audioDataCallback: audioDataCallback)
// 开始连续识别
try recognizer?.startContinuousRecognition()
@ -190,7 +181,7 @@ public class AzureAsrHelper: NSObject {
return true
} catch {
audioStream?.stopAudioCapture()
audioStream?.stopMicrophoneCapture()
_isContinuousRecognitionActive = false
//callback.onError("启动连续识别失败: \(error.localizedDescription)")
return false
@ -200,8 +191,12 @@ public class AzureAsrHelper: NSObject {
/**
* 停止连续语音识别
*
* @return 是否成功停止
*
* @return 停止操作是否成功
*/
/**
* 停止连续语音识别
* @return 停止操作是否成功
*/
public func stopContinuousRecognition() -> Bool {
guard speechConfig != nil else {
@ -224,11 +219,10 @@ public class AzureAsrHelper: NSObject {
//pushAudioData(data: Data())
}
audioStream?.stopAudioCapture()
audioStream?.stopMicrophoneCapture()
// 停止连续识别
try recognizer.stopContinuousRecognition()
// 会话结束事件会设置_isContinuousRecognitionActive = false
print("stopContinuousRecognition")
return true
} catch {
// 强制重置状态
@ -236,7 +230,15 @@ public class AzureAsrHelper: NSObject {
os_log("强制停止识别失败", log: log, type: .error)
// 停止音频处理
audioStream?.stopAudioCapture()
audioStream?.stopMicrophoneCapture()
// 【修复】在 catch 块中安全地停止识别器,避免再次抛出异常
do {
try recognizer.stopContinuousRecognition()
} catch {
// 如果强制停止也失败,记录错误但不再抛出异常
os_log("强制停止识别器失败: %@", log: log, type: .error, error.localizedDescription)
}
return false
}
@ -264,8 +266,6 @@ public class AzureAsrHelper: NSObject {
// 停止音频处理
audioStream?.releaseAudioResources()
// 停止录音
recordfile?.closeFile(isSave: true)
// 释放资源
recognizer = nil
speechConfig = nil
@ -295,12 +295,9 @@ public class AzureAsrHelper: NSObject {
do {
print("设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)")
// 设置音频流的配置
// 设置音频接收器的配置
audioStream?.setAudioConfig(sampleRate: sampleRate, channels: channels)
// 设置录音文件的配置
// recordfile?.setAudioConfig(sampleRate: sampleRate, channels: channels)
print("音频配置设置成功")
return true
} catch {
@ -313,10 +310,6 @@ public class AzureAsrHelper: NSObject {
/**
* 设置识别器
*/
/**
* 设置识别器
* 优化:避免重复创建音频流,复用已初始化的组件
*/
private func setupRecognizer() -> Bool {
do {
print("设置识别器\(_isContinuousRecognitionActive)")
@ -332,6 +325,17 @@ public class AzureAsrHelper: NSObject {
setupMicrophoneStream()
}
// 安全检查:确保 audioConfig 不为 nil
guard let audioConfig = audioConfig else {
os_log("音频配置未初始化,无法创建识别器", log: log, type: .error)
return false
}
guard let speechConfig = speechConfig else {
os_log("语音配置未初始化,无法创建识别器", log: log, type: .error)
return false
}
// 清理旧的识别器
recognizer = nil
@ -342,22 +346,21 @@ public class AzureAsrHelper: NSObject {
print("创建自动语言检测配置失败,使用默认语言配置")
// 如果创建失败,使用默认的单语言配置
recognizer = try SPXSpeechRecognizer(
speechConfiguration: speechConfig!,
audioConfiguration: audioConfig!
speechConfiguration: speechConfig,
audioConfiguration: audioConfig
)
return true
}
print("autoDetectConfig: \(autoDetectSourceLanguageConfig)")
print("创建自动语言识别器")
recognizer = try SPXSpeechRecognizer(
speechConfiguration: speechConfig!,
speechConfiguration: speechConfig,
autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig,
audioConfiguration: audioConfig!
audioConfiguration: audioConfig
)
} else {
recognizer = try SPXSpeechRecognizer(
speechConfiguration: speechConfig!,
audioConfiguration: audioConfig!
speechConfiguration: speechConfig,
audioConfiguration: audioConfig
)
}
@ -374,27 +377,20 @@ public class AzureAsrHelper: NSObject {
* 设置麦克风流 - 使用推流方式
* 优化:避免重复初始化已存在的音频流
*/
/**
* 设置麦克风音频流
*/
private func setupMicrophoneStream() {
do {
// 【优化】检查是否已经初始化,避免重复创建
if audioStream == nil {
audioStream = AudioStream(parentHelper: self)
audioStream?.initAudioRecord()
audioStream?.onAudioData = { [weak self] data in
self?.recordfile?.saveAudioDataToWav(data)
}
}
// 【优化】检查音频配置是否已存在
if audioConfig == nil, let pushStream = audioStream?.pushAudioStream {
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("设置麦克风流完成", log: log, type: .info)
}
} catch {
os_log("设置麦克风流失败: %{public}@", log: log, type: .error, error.localizedDescription)
audioStream = nil
}
audioStream = SimpleAudioReceiver(parentHelper: self)
// 【优化】检查音频配置是否已存在
if audioConfig == nil, let pushStream = audioStream?.pushAudioStream {
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("设置麦克风流完成", log: log, type: .info)
}
}
/**
* 从语音识别结果中获取检测到的语言
* @param result 语音识别结果
@ -409,26 +405,12 @@ public class AzureAsrHelper: NSObject {
// 备用方案:从属性中获取语言
if let properties = result.properties,
let language = properties.getPropertyByName("SpeechServiceResponse_RecognitionLanguage"),
let language = properties.getPropertyByName("speechServiceResponse_RecognitionLanguage"),
!language.isEmpty {
print("从属性中检测到语言: \(language)")
return language
}
// 最后的备用方案:从JSON响应中解析
if let properties = result.properties,
let jsonString = properties.getPropertyByName("SpeechServiceResponse_Json"),
let jsonData = jsonString.data(using: .utf8) {
do {
if let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any],
let language = json["Language"] as? String {
print("从JSON中检测到语言: \(language)")
return language
}
} catch {
print("JSON解析失败: \(error)")
}
}
// 使用正确的SPXAutoDetectSourceLanguageResult初始化方法
do {
let langResult = try SPXAutoDetectSourceLanguageResult(result)
@ -499,11 +481,6 @@ public class AzureAsrHelper: NSObject {
let text = result.text
print("正在识别事件=检测到语言: \(detectedLanguage), 识别中: \(text)")
// // 检测文本语言,避免重复调用
// let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN"
// // 输出检测到的语言信息
// print("检测到的语言: \(detectedLanguage)")
DispatchQueue.main.async {
callback.onRecognizing(result.text ?? "", detectedLanguage)
@ -522,11 +499,6 @@ public class AzureAsrHelper: NSObject {
let text = result.text
print("识别完成事件=检测到语言: \(detectedLanguage), 识别中: \(text)")
// 检测文本语言,避免重复调用
// let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN"
// // 输出检测到的语言信息
// print("检测到的语言: \(detectedLanguage)")
// 在主线程回调结果
DispatchQueue.main.async {
@ -573,7 +545,7 @@ public class AzureAsrHelper: NSObject {
do {
print("禁用蓝牙音频功能,切换回正常音频模式")
audioStream?.setAudioOutputRoute(.speaker)
// audioStream?.disableBluetoothAudio()
} catch {
print("disableBluetoothAudio")
}
@ -585,7 +557,7 @@ public class AzureAsrHelper: NSObject {
do {
print("恢复原始音频设备状态(通常是重新启用蓝牙)")
audioStream?.setAudioOutputRoute(.receiver)
//audioStream?.restoreOriginalAudioState()
} catch {
print("restoreOriginalAudioState")
}
@ -595,13 +567,31 @@ public class AzureAsrHelper: NSObject {
/**
* 开启录音
* @param filePath 录音文件路径
* @param audioDataCallback 音频数据回调接口
*/
public func enableRecord(filePath: String) {
public func enableRecord(filePath: String, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil) {
os_log("开启录音: %{public}@", log: log, type: .info, filePath)
if audioStream == nil {
// 创建外部音频拉流对象
audioStream = SimpleAudioReceiver(parentHelper: self)
audioStream?.initAudioRecord()
}
if audioStream?.recordfile == nil {
audioStream?.recordfile = RecordFile()
}
recordfile?.closeFile(isSave: true)
recordfile?.creatingFiles(atPath: filePath) // Fixed method call
// 修复:移除多余的 audioDataCallback 参数
audioStream?.startAudioRecord(
audioSourceType: audioSourceType == .microphone ? .microphone : .external,
audioDataCallback: audioDataCallback
)
audioStream?.recordfile?.closeFile(isSave: true)
audioStream?.recordfile?.creatingFiles(atPath: filePath)
}
/**
@ -610,7 +600,7 @@ public class AzureAsrHelper: NSObject {
public func moveFile(sourcePath: String, destPath: String)-> Bool {
recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call
audioStream?.recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call
return true
}
@ -622,461 +612,47 @@ public class AzureAsrHelper: NSObject {
recordfile?.renameFile(at: filePath, to: newName) // Fixed method call
audioStream?.recordfile?.renameFile(at: filePath, to: newName) // Fixed method call
return true
}
/**
* 停止录音
/**
* 暂停录音
*/
public func pauseRecord() {
recordfile?.isPause = true
os_log("暂停录音", log: log, type: .info)
guard let audioStream = audioStream, audioStream.recordfile != nil else {
return
}
audioStream.stopMicrophoneCapture()
}
/**
* 关闭录音
* 继续录音
*/
public func stopRecord(isSave: Bool) {
recordfile?.isPause = false
recordfile?.closeFile(isSave: true)
}
// MARK: - 音频流类
public class AudioStream: NSObject {
public private(set) var pushAudioStream: SPXPushAudioInputStream?
private let writeQueue = LinkedBlockingQueue<Data>()
private var audioEngine: AVAudioEngine?
private var audioFormat: AVAudioFormat?
private let audioSession = AVAudioSession.sharedInstance()
private var audioSourceType = AudioSourceType.microphone
private var isRunning = false
public var isWriting = false
private let bufferSize: Int = 4096
private var writeThread: DispatchQueue?
private var currentRoute: AudioOutputRoute?
public var onAudioData: ((Data) -> Void)?
// 添加对外部类的弱引用
private weak var parentHelper: AzureAsrHelper?
// 添加初始化方法,接收外部类引用
init(parentHelper: AzureAsrHelper) {
self.parentHelper = parentHelper
super.init()
}
/**
* 初始化
*/
/**
* 初始化音频录制组件
* 包括音频格式、推流、音频引擎等核心组件的初始化
*/
public func initAudioRecord() {
print("初始化了")
audioFormat = getOptimalAudioFormat()
pushAudioStream = SPXPushAudioInputStream()
// 初始化音频引擎
audioEngine = AVAudioEngine()
isRunning = true
// 创建新的写线程
writeThread = DispatchQueue(label: "audio.stream.writer")
startWriteThread()
//self.setAudioOutputRoute(.receiver)
}
/// 获取最佳音频格式 (iOS 通常支持标准采样率)
public func getOptimalAudioFormat() -> AVAudioFormat? {
let sampleRate: Double = 16000 // iOS 通常支持 16kHz
return AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: sampleRate,
channels: 1,
interleaved: true
)
}
/**
* 设置音频配置
* @param sampleRate 采样率,默认16000
* @param channels 声道数,默认1
*/
public func setAudioConfig(sampleRate: Int, channels: Int) {
print("AudioStream设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)")
// 更新音频格式
audioFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: Double(sampleRate),
channels: AVAudioChannelCount(channels),
interleaved: true
)
// 如果已经有推流,重新创建
// if pushAudioStream != nil {
// let audioStreamFormat = SPXAudioStreamFormat()
// audioStreamFormat?.initUsingPCM(withSampleRate: UInt(sampleRate), bitsPerSample: 16, channels: UInt(channels))
// pushAudioStream = SPXPushAudioInputStream(audioFormat: audioStreamFormat)
// }
print("AudioStream音频配置设置完成")
}
/**
* 开始音频输入
*/
public func startAudioInput(audioSourceType: AudioSourceType = .microphone) {
isWriting=true
self.audioSourceType = audioSourceType;
print("startAudioInput=audioSourceType\(audioSourceType)")
switch audioSourceType {
case .microphone:
runMicrophoneCapture()
case .external:
runExternalCapture()
}
}
/**
* 开启音频写入线程
*/
public func startWriteThread() {
writeThread?.async { [weak self] in
guard let self = self else { return }
while self.isRunning {
// print("是否写入: \(self.isWriting)")
if !self.isWriting {
usleep(10_000)
continue
}
guard let dataToWrite = self.writeQueue.take() else { continue }
// print("写入数据长度: \(dataToWrite.count)")
do {
try self.pushAudioStream?.write(dataToWrite)
self.onAudioData?(dataToWrite)
} catch {
print("推送音频数据失败: \(error.localizedDescription)")
}
}
}
public func resumeRecord() {
os_log("继续录音", log: log, type: .info)
guard let audioStream = audioStream, audioStream.recordfile != nil else {
return
}
/**
* 向音频流写入音频数据
* 仅当音频源设置为external时有效
*
* @param data 音频数据字节数组
*/
public func saveAudioDataTo(data: Data) {
// 通过父类引用调用方法
if audioSourceType != .external || !(parentHelper?.isContinuousRecognitionActive() ?? false) {
return
}
// print("外部data=\(data)")
// 放入队列,由写线程写入
writeQueue.put(data)
}
// 私有方法:启动麦克风捕获
/**
* 启动麦克风捕获
* 优化:复用已初始化的音频引擎,减少启动延迟
*/
private func runMicrophoneCapture() {
do {
// 【优化】检查音频引擎是否已初始化,避免重复创建
if audioEngine == nil {
audioEngine = AVAudioEngine()
}
// 如果音频引擎正在运行,先停止
if audioEngine?.isRunning == true {
audioEngine?.stop()
}
// 获取音频输入节点(麦克风)
guard let inputNode = audioEngine?.inputNode else {
throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "无法获取音频输入节点"])
}
// 移除之前的音频处理块,避免重复添加
inputNode.removeTap(onBus: 0)
// 获取硬件支持的原始音频格式
let hardwareFormat = inputNode.inputFormat(forBus: 0)
// iOS 13+ 启用语音处理
if #available(iOS 13.0, *) {
try inputNode.setVoiceProcessingEnabled(true)
}
// 检查目标音频格式和转换器是否可用
guard let targetFormat = audioFormat, // 外部定义的期望音频格式
let converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) else {
throw NSError(domain: "AudioSetup", code: 2)
}
// 在输入节点上安装录音回调
inputNode.installTap(onBus: 0,
bufferSize: UInt32(bufferSize), // 每次回调的缓冲区大小
format: hardwareFormat) { // 使用原始硬件格式
[weak self] buffer, time in // 弱引用避免循环引用
// 确保实例存在且正在写入状态
guard let self = self, self.isWriting else { return }
// 创建目标格式的音频缓冲区
let convertedBuffer = AVAudioPCMBuffer(
pcmFormat: targetFormat,
// 计算转换后的帧容量(考虑采样率差异)
frameCapacity: AVAudioFrameCount(
targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate
)
)!
var error: NSError?
// 执行音频格式转换
let status = converter.convert(
to: convertedBuffer,
error: &error,
withInputFrom: { inNumPackets, outStatus in
outStatus.pointee = .haveData // 标记有数据可用
return buffer // 返回原始音频数据
}
)
// 转换成功且无错误
if status == .haveData, error == nil {
// 将音频缓冲区转换为二进制数据
let data = self.audioBufferToData(convertedBuffer)
// 将数据放入写入队列(后续处理)
self.writeQueue.put(data)
}
}
// 激活音频会话(允许录音)
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
// 启动音频引擎
try audioEngine?.start()
} catch {
print("麦克风启动失败: \(error)")
audioStream.resumeRecord()
}
}
private func audioBufferToData(_ buffer: AVAudioPCMBuffer) -> Data {
let frameLength = Int(buffer.frameLength)
let channelCount = 1
let dataLength = frameLength * channelCount * MemoryLayout<Int16>.size
// Handle 16-bit integer format
if let int16Data = buffer.int16ChannelData {
return Data(
bytes: int16Data.pointee,
count: dataLength
)
}
// Handle float format
else if let floatData = buffer.floatChannelData {
var int16Array = [Int16](repeating: 0, count: frameLength)
let floatBuffer = floatData.pointee
for i in 0..<frameLength {
let sample = floatBuffer[i]
let clamped = max(-1.0, min(sample, 1.0))
let scaled = clamped * Float(Int16.max)
int16Array[i] = Int16(scaled)
}
return Data(
bytes: int16Array,
count: dataLength
)
}
return Data() // Fallback for unsupported formats
}
private func runExternalCapture() {
if audioSourceType == .external {
//pushAudioData(data: Data())
audioEngine?.pause()
//audioEngine?.inputNode.removeTap(onBus: 0)
}
}
public func stopAudioCapture() {
guard isWriting==true else { return }
print("stopContinuousRecognition")
isWriting=false
audioEngine?.pause()
audioEngine?.inputNode.removeTap(onBus: 0)
writeThread?.async {
self.writeQueue.close()
}
}
public func releaseAudioResources() {
print("释放了")
stopAudioCapture()
audioEngine?.stop()
writeThread = nil
audioEngine?.inputNode.removeTap(onBus: 0)
isRunning=false
audioEngine = nil
}
// 音频路由管理
public enum AudioOutputRoute {
case speaker
case receiver
case bluetooth
}
public func setAudioOutputRoute(_ route: AudioOutputRoute) {
do {
//
if self.isWriting {
print("当前正在识别")
try audioEngine?.pause()
// 先停用以避免冲突
try audioSession.setActive(false)
}
print("setAudioOutputRoutecurrent,route=\(route)")
switch route {
case .speaker:
print("setAudioOutputRoutecurrent,speaker")
// 使用扬声器时必须用videoChat模式
try audioSession.setCategory(
.playAndRecord,
mode: .videoChat,
options: [.defaultToSpeaker]
)
try audioSession.overrideOutputAudioPort(.speaker)
case .receiver:
// 听筒模式使用voiceChat节省资源
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth]
)
try audioSession.overrideOutputAudioPort(.none)
case .bluetooth:
// 完整蓝牙设备支持
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.overrideOutputAudioPort(.none)
// 不需要override,系统自动路由
}
self.currentRoute = route
if self.isWriting {
// 重新激活
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
try audioEngine?.start()
writeThread?.async {
self.writeQueue.close()
}
}
} catch {
print("路由切换失败: \(error)")
}
}
}
// MARK: - iOS版LinkedBlockingQueue实现
/**
* iOS版LinkedBlockingQueue实现
* 与Android LinkedBlockingQueue保持一致的API和行为
* 关闭录音
*/
private class LinkedBlockingQueue<T> {
private var queue: [T] = []
private var isClosed = false
// 使用DispatchSemaphore实现阻塞行为
private let availableItems: DispatchSemaphore
private let queueLock = NSLock()
init() {
self.availableItems = DispatchSemaphore(value: 0)
}
/**
* 阻塞式获取元素(等价于Android的take())
* @return 队列中的元素,如果队列已关闭则返回nil
*/
func take() -> T? {
// 等待可用元素(阻塞直到有元素或队列关闭)
availableItems.wait()
queueLock.lock()
defer { queueLock.unlock() }
// 检查队列是否已关闭
if isClosed && queue.isEmpty {
return nil
}
// 获取第一个元素
guard !queue.isEmpty else {
return nil
}
return queue.removeFirst()
}
/**
* 添加元素(等价于Android的put())
* @param item 要添加的元素
*/
func put(_ item: T) {
queueLock.lock()
defer { queueLock.unlock() }
// 检查队列是否已关闭
if isClosed {
return
}
// 添加元素(无容量限制,与Android一致)
queue.append(item)
// 通知有新元素可用
availableItems.signal()
}
/**
* 关闭队列
* 关闭后不能再添加新元素,但可以继续取出已有元素
*/
func close() {
queueLock.lock()
defer { queueLock.unlock() }
isClosed = true
// 唤醒所有等待的take()操作
for _ in 0..<100 { // 假设最多100个等待者
availableItems.signal()
}
public func stopRecord(isSave: Bool) {
guard let audioStream = audioStream, audioStream.recordfile != nil else {
return
}
audioStream.stopMicrophoneCapture()
audioStream.recordfile?.isPause = false //
audioStream.recordfile?.closeFile(isSave: true) //
}
@ -1177,4 +753,4 @@ private func runMicrophoneCapture() {
// "languageName": languageName,
// "originalCode": topResult.language
// ]
// }
// }

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

@ -258,16 +258,37 @@ import os.log
case "enableRecord":
guard let args = call.arguments as? [String: Any],
let filePath = args["filePath"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "filePath 参数不能为空", details: nil))
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文件路径不能为空", details: nil))
return
}
// 检查是否需要接受音频数据
let acceptAudioData = args["acceptAudioData"] as? Bool ?? false
do {
print("音频文件名称为: \(filePath)")
try azureAsrHelper.enableRecord(filePath: filePath)
if acceptAudioData {
// 创建音频数据回调实现类
let audioCallback = AudioDataCallbackImpl { [weak self] audioData in
// 构建音频数据事件映射
let audioEvent: [String: Any] = [
"type": "audioData",
"data": audioData,
"timestamp": Date().timeIntervalSince1970 * 1000,
"size": audioData.count
]
// 发送音频数据事件到 Flutter 层
self?.sendAsrEvent(audioEvent)
}
try azureAsrHelper.enableRecord(filePath: filePath, audioDataCallback: audioCallback)
} else {
try azureAsrHelper.enableRecord(filePath: filePath)
}
result(true)
} catch {
result(FlutterError(code: "ENABLERECORD_ERROR", message: error.localizedDescription, details: nil))
}
case "pauseRecord":
azureAsrHelper.pauseRecord()
@ -574,3 +595,30 @@ extension AzureSpeechPlugin: AudioDataListener {
sendTtsEvent(eventMap)
}
}
// MARK: - 音频数据回调实现
/**
* 音频数据回调实现类
* 实现 SimpleAudioReceiver.AudioDataCallback 协议
*/
private class AudioDataCallbackImpl: SimpleAudioReceiver.AudioDataCallback {
private let callback: (Data) -> Void
/**
* 初始化音频数据回调实现
* @param callback 音频数据处理闭包
*/
init(callback: @escaping (Data) -> Void) {
self.callback = callback
}
/**
* 音频数据回调方法
* @param data 音频数据
*/
func onAudio(_ data: Data) {
callback(data)
}
}

0
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/RecordFile.swift → local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift

534
local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -0,0 +1,534 @@
import Foundation
import AVFoundation
import MicrosoftCognitiveServicesSpeech
import os.log
/**
* 简单音频接收器类,用于处理音频录制和流传输
* 对应Android的SimpleAudioReceiver功能
*/
public class SimpleAudioReceiver: NSObject {
private let tag = "SimpleAudioReceiver"
private let log = OSLog(subsystem: "com.azure.speech", category: "SimpleAudioReceiver")
/**
* 音频来源类型
*/
public enum AudioSourceType {
/** 使用设备麦克风 */
case microphone
/** 使用外部提供的音频数据 */
case external
}
// MARK: - 音频流类
public private(set) var pushAudioStream: SPXPushAudioInputStream?
private let writeQueue = LinkedBlockingQueue<Data>()
private var audioEngine: AVAudioEngine?
private var audioFormat: AVAudioFormat?
private let audioSession = AVAudioSession.sharedInstance()
private var audioSourceType = AudioSourceType.microphone
private var audioDataCallback: AudioDataCallback?
private var isRunning = false
public var _isWriting = false
private let bufferSize: Int = 4096
private var writeThread: DispatchQueue?
private var currentRoute: AudioOutputRoute?
//public var onAudioData: ((Data) -> Void)?
public var recordfile: RecordFile?
// 添加对外部类的弱引用
private weak var parentHelper: AzureAsrHelper?
// 添加初始化方法,接收外部类引用
init(parentHelper: AzureAsrHelper) {
self.parentHelper = parentHelper
super.init()
}
/**
* 初始化
*/
/**
* 初始化音频录制组件
* 包括音频格式、推流、音频引擎等核心组件的初始化
*/
public func initAudioRecord() {
print("初始化了")
audioFormat = getOptimalAudioFormat()
pushAudioStream = SPXPushAudioInputStream()
// 初始化音频引擎
audioEngine = AVAudioEngine()
isRunning = true
// 创建新的写线程
writeThread = DispatchQueue(label: "audio.stream.writer")
startWriteThread()
}
/// 获取最佳音频格式 (iOS 通常支持标准采样率)
public func getOptimalAudioFormat() -> AVAudioFormat? {
let sampleRate: Double = 16000 // iOS 通常支持 16kHz
return AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: sampleRate,
channels: 1,
interleaved: true
)
}
/**
* 设置音频配置
* @param sampleRate 采样率,默认16000
* @param channels 声道数,默认1
*/
/**
* 设置音频配置参数
* @param sampleRate 采样率
* @param channels 声道数
*/
public func setAudioConfig(sampleRate: Int, channels: Int) {
print(
"AudioStream设置音频配置: sampleRate=\(sampleRate), channels=\(channels)"
)
// 如果已经有推流,重新创建
if pushAudioStream != nil {
// 修复:安全解包 SPXAudioStreamFormat
guard let audioStreamFormat = SPXAudioStreamFormat.init(
usingPCMWithSampleRate: UInt(sampleRate),
bitsPerSample: 16,
channels: UInt(channels)
) else {
print("创建音频流格式失败")
return
}
pushAudioStream = SPXPushAudioInputStream(audioFormat: audioStreamFormat)
}
print("AudioStream音频配置设置完成")
}
/**
* 开始音频输入
*/
public func startAudioRecord(audioSourceType: AudioSourceType = .microphone, audioDataCallback: AudioDataCallback?) {
_isWriting=true
self.audioSourceType = audioSourceType;
self.audioDataCallback = audioDataCallback
print("startAudioRecord=audioSourceType\(audioSourceType)")
switch audioSourceType {
case .microphone:
runMicrophoneCapture()
case .external:
runExternalCapture()
}
}
/**
* 开启音频写入线程
* 修复:使用userInitiated QoS避免优先级反转
*/
public func startWriteThread() {
// 使用userInitiated QoS匹配音频录制线程的优先级
writeThread = DispatchQueue(label: "audio.stream.writer", qos: .userInitiated)
writeThread?.async { [weak self] in
guard let self = self else { return }
while self.isRunning {
if !self._isWriting {
print("startWriteThread 111")
usleep(10_000)
continue
}
print("startWriteThread 222")
guard let dataToWrite = self.writeQueue.take() else { continue }
do {
if let callback = self.audioDataCallback {
callback.onAudio(dataToWrite)
}
try self.pushAudioStream?.write(dataToWrite)
if self.recordfile != nil {
print("写入recordfile数据长度: \(dataToWrite.count)")
recordfile?.saveAudioDataToWav(dataToWrite)
}
print("写入数据长度: \(dataToWrite.count)")
} catch {
print("推送音频数据失败: \(error.localizedDescription)")
}
}
}
}
/**
* 向音频流写入音频数据
* 仅当音频源设置为external时有效
*
* @param data 音频数据字节数组
*/
public func saveAudioDataTo(data: Data) {
// 通过父类引用调用方法
if audioSourceType != .external || !(parentHelper?.isContinuousRecognitionActive() ?? false) {
return
}
// print("外部data=\(data)")
// 放入队列,由写线程写入
writeQueue.put(data)
}
// 私有方法:启动麦克风捕获
/**
* 启动麦克风捕获
* 优化:复用已初始化的音频引擎,减少启动延迟
*/
private func runMicrophoneCapture() {
do {
// 【新增】首先配置音频会话
try audioSession.setCategory(
.playAndRecord,
mode: .measurement, // 使用 measurement 模式获得最佳录音质量
options: [.allowBluetooth, .defaultToSpeaker]
)
// 【新增】请求麦克风权限(如果尚未授权)
if audioSession.recordPermission != .granted {
audioSession.requestRecordPermission { granted in
if !granted {
print("麦克风权限被拒绝")
}
}
}
// 【新增】激活音频会话(在配置音频引擎之前)
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
// 检查音频引擎是否已初始化,避免重复创建
if audioEngine == nil {
audioEngine = AVAudioEngine()
}
// 如果音频引擎正在运行,先停止
if audioEngine?.isRunning == true {
audioEngine?.stop()
}
// 获取音频输入节点(麦克风)
guard let inputNode = audioEngine?.inputNode else {
throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "无法获取音频输入节点"])
}
// 移除之前的音频处理块,避免重复添加
inputNode.removeTap(onBus: 0)
// 获取硬件支持的原始音频格式
let hardwareFormat = inputNode.inputFormat(forBus: 0)
// iOS 13+ 启用语音处理
if #available(iOS 13.0, *) {
try inputNode.setVoiceProcessingEnabled(true)
}
// 检查目标音频格式和转换器是否可用
guard let targetFormat = audioFormat, // 外部定义的期望音频格式
let converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) else {
throw NSError(domain: "AudioSetup", code: 2)
}
// 在输入节点上安装录音回调
inputNode.installTap(onBus: 0,
bufferSize: UInt32(bufferSize), // 每次回调的缓冲区大小
format: hardwareFormat) { // 使用原始硬件格式
[weak self] buffer, time in // 弱引用避免循环引用
// 确保实例存在且正在写入状态
guard let self = self, self._isWriting else { return }
// 创建目标格式的音频缓冲区
let convertedBuffer = AVAudioPCMBuffer(
pcmFormat: targetFormat,
// 计算转换后的帧容量(考虑采样率差异)
frameCapacity: AVAudioFrameCount(
targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate
)
)!
var error: NSError?
// 执行音频格式转换
let status = converter.convert(
to: convertedBuffer,
error: &error,
withInputFrom: { inNumPackets, outStatus in
outStatus.pointee = .haveData // 标记有数据可用
return buffer // 返回原始音频数据
}
)
// 转换成功且无错误
if status == .haveData, error == nil {
// 将音频缓冲区转换为二进制数据
let data = self.audioBufferToData(convertedBuffer)
// 将数据放入写入队列(后续处理)
self.writeQueue.put(data)
}
}
// 激活音频会话(允许录音)
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
try audioEngine?.start()
} catch {
print("麦克风启动失败: \(error)")
// 【新增】添加详细错误处理
if let nsError = error as NSError? {
print("错误域: \(nsError.domain), 错误代码: \(nsError.code)")
print("错误描述: \(nsError.localizedDescription)")
}
}
}
private func audioBufferToData(_ buffer: AVAudioPCMBuffer) -> Data {
let frameLength = Int(buffer.frameLength)
let channelCount = 1
let dataLength = frameLength * channelCount * MemoryLayout<Int16>.size
// Handle 16-bit integer format
if let int16Data = buffer.int16ChannelData {
return Data(
bytes: int16Data.pointee,
count: dataLength
)
}
// Handle float format
else if let floatData = buffer.floatChannelData {
var int16Array = [Int16](repeating: 0, count: frameLength)
let floatBuffer = floatData.pointee
for i in 0..<frameLength {
let sample = floatBuffer[i]
let clamped = max(-1.0, min(sample, 1.0))
let scaled = clamped * Float(Int16.max)
int16Array[i] = Int16(scaled)
}
return Data(
bytes: int16Array,
count: dataLength
)
}
return Data() // Fallback for unsupported formats
}
private func runExternalCapture() {
if audioSourceType == .external {
//pushAudioData(data: Data())
audioEngine?.pause()
//audioEngine?.inputNode.removeTap(onBus: 0)
}
}
/**
* 继续麦克风捕获
*/
/**
* 继续麦克风捕获
*/
public func resumeRecord() {
guard _isWriting==false else { return }
_isWriting = true
// 启动录音引擎
do {
print("resumeRecord")
try audioEngine?.start()
} catch {
os_log("Failed to start audio engine: %@", log: log, type: .error, error.localizedDescription)
}
}
public func stopMicrophoneCapture() {
guard _isWriting==true else { return }
_isWriting=false
audioEngine?.pause()
print("stopMicrophoneCapture")
}
public func releaseAudioResources() {
print("释放了")
stopMicrophoneCapture()
audioEngine?.stop()
writeThread?.async {
self.writeQueue.close()
}
writeThread = nil
audioEngine?.inputNode.removeTap(onBus: 0)
isRunning=false
audioEngine = nil
}
// 音频路由管理
public enum AudioOutputRoute {
case speaker
case receiver
case bluetooth
}
public func setAudioOutputRoute(_ route: AudioOutputRoute) {
do {
//
if self._isWriting {
print("当前正在识别")
try audioEngine?.pause()
// 先停用以避免冲突
try audioSession.setActive(false)
}
print("setAudioOutputRoutecurrent,route=\(route)")
switch route {
case .speaker:
print("setAudioOutputRoutecurrent,speaker")
// 使用扬声器时必须用videoChat模式
try audioSession.setCategory(
.playAndRecord,
mode: .videoChat,
options: [.defaultToSpeaker]
)
try audioSession.overrideOutputAudioPort(.speaker)
case .receiver:
// 听筒模式使用voiceChat节省资源
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth]
)
try audioSession.overrideOutputAudioPort(.none)
case .bluetooth:
// 完整蓝牙设备支持
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.overrideOutputAudioPort(.none)
// 不需要override,系统自动路由
}
self.currentRoute = route
if self._isWriting {
// 重新激活
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
try audioEngine?.start()
// writeThread?.async {
// self.writeQueue.close()
// }
}
} catch {
print("路由切换失败: \(error)")
}
}
/**
* 音频数据回调接口
*/
public protocol AudioDataCallback {
/**
* 音频数据回调
* @param data 音频数据
*/
func onAudio(_ data: Data)
}
/**
* 检查是否正在写入
* @return 是否正在写入音频数据
*/
public func isWriting() -> Bool {
return _isWriting
}
}
// MARK: - iOS版LinkedBlockingQueue实现
private class LinkedBlockingQueue<T> {
private var queue: [T] = []
private var isClosed = false
// 使用DispatchSemaphore实现阻塞行为
private let availableItems: DispatchSemaphore
private let queueLock = NSLock()
init() {
self.availableItems = DispatchSemaphore(value: 0)
}
/**
* 阻塞式获取元素(等价于Android的take())
* @return 队列中的元素,如果队列已关闭则返回nil
*/
func take() -> T? {
// 等待可用元素(阻塞直到有元素或队列关闭)
availableItems.wait()
queueLock.lock()
defer { queueLock.unlock() }
// 检查队列是否已关闭
if isClosed && queue.isEmpty {
return nil
}
// 获取第一个元素
guard !queue.isEmpty else {
return nil
}
return queue.removeFirst()
}
/**
* 添加元素(等价于Android的put())
* @param item 要添加的元素
*/
func put(_ item: T) {
queueLock.lock()
defer { queueLock.unlock() }
// 检查队列是否已关闭
if isClosed {
return
}
// 添加元素(无容量限制,与Android一致)
queue.append(item)
// 通知有新元素可用
availableItems.signal()
}
/**
* 关闭队列
* 关闭后不能再添加新元素,但可以继续取出已有元素
*/
func close() {
queueLock.lock()
defer { queueLock.unlock() }
isClosed = true
// 唤醒所有等待的take()操作
for _ in 0..<100 { // 假设最多100个等待者
availableItems.signal()
}
}
}
// MARK: - 协议定义
Loading…
Cancel
Save