diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 715a87a19..6d09e8744 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -29,10 +29,13 @@ abstract class AsrService { /// 检查连续识别是否活跃 bool isContinuousRecognitionActive(); - /// 停止连续语音识别 + /// 开始录音 Future enableRecord(String fileName); - /// 停止连续语音识别 + /// 暂停录音 + Future pauseRecord(); + + /// 结束录音 Future stopRecord(); /// 释放资源 diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 4fd99d91e..316ecf7e0 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -290,9 +290,17 @@ class BleManager extends GetxService { } // 提取电池信息 - final int leftBattery = data['leftEarBattery'] as int? ?? 0; - final int rightBattery = data['rightEarBattery'] as int? ?? 0; - final int caseBattery = data['caseBattery'] as int? ?? 0; + final int leftBattery = (data['leftEarBattery'] as int? ?? 0) > 100 + ? 0 + : (data['leftEarBattery'] as int? ?? 0); + + final int rightBattery = (data['rightEarBattery'] as int? ?? 0) > 100 + ? 0 + : (data['rightEarBattery'] as int? ?? 0); + + final int caseBattery = (data['caseBattery'] as int? ?? 0) > 100 + ? 0 + : (data['caseBattery'] as int? ?? 0); final bool leftCharging = data['leftEarCharging'] as bool? ?? false; final bool rightCharging = data['rightEarCharging'] as bool? ?? false; final bool caseCharging = data['caseCharging'] as bool? ?? false; diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index b7f12e55a..317836638 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -368,4 +368,16 @@ class AzureAsrService extends GetxService implements AsrService { rethrow; } } + + @override + Future pauseRecord() async { + try { + final bool result = await _channel.invokeMethod('pauseRecord'); + + return result; + } catch (e) { + Logger.error('停止录音: ${e.toString()}'); + rethrow; + } + } } diff --git a/lib/data/services/speech_impl/volcano_asr_api_service.dart b/lib/data/services/speech_impl/volcano_asr_api_service.dart index ba0629282..50cb85f12 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -823,4 +823,10 @@ class VolcanoAsrApiService implements AsrService { // TODO: implement stopRecord throw UnimplementedError(); } + + @override + Future pauseRecord() { + // TODO: implement pauseRecord + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart index f32330875..923ede8f0 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -358,4 +358,10 @@ class VolcanoAsrService extends GetxService implements AsrService { // TODO: implement stopRecord throw UnimplementedError(); } + + @override + Future pauseRecord() { + // TODO: implement pauseRecord + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index 81cdd9db3..4cd8ac171 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -282,4 +282,10 @@ class XunfeiAsrService extends GetxService implements AsrService { // TODO: implement stopRecord throw UnimplementedError(); } + + @override + Future pauseRecord() { + // TODO: implement pauseRecord + throw UnimplementedError(); + } } diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index f946e1e04..4616d5c49 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -275,7 +275,8 @@ class HomeController extends GetxController { String getEarbudsText() { if (deviceInfo == null || (deviceInfo.value!.leftEarBattery <= 0 && - deviceInfo.value!.rightEarBattery <= 0)) { + deviceInfo.value!.rightEarBattery <= 0 || + deviceInfo.value!.caseBattery > 100)) { return 'unknown'.tr; } @@ -296,7 +297,9 @@ class HomeController extends GetxController { /// 获取充电仓电量文本 String getCaseText() { - if (deviceInfo.value == null || deviceInfo.value!.caseBattery <= 0) { + if (deviceInfo.value == null || + deviceInfo.value!.caseBattery <= 0 || + deviceInfo.value!.caseBattery > 100) { return 'unknown'.tr; } diff --git a/lib/modules/meeting/bindings/meeting_record_binding.dart b/lib/modules/meeting/bindings/meeting_record_binding.dart new file mode 100644 index 000000000..f4880d510 --- /dev/null +++ b/lib/modules/meeting/bindings/meeting_record_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../controllers/meeting_record_controller.dart'; + +/// 录音模块依赖绑定类 +class MeetingRecordBinding extends Bindings { + @override + void dependencies() { + // 延迟注册 MeetingRecordController 控制器 + Get.lazyPut(() => MeetingRecordController()); + } +} diff --git a/lib/modules/meeting/controllers/meeting_controller.dart b/lib/modules/meeting/controllers/meeting_controller.dart index ccb746c78..f6d1d7ee9 100644 --- a/lib/modules/meeting/controllers/meeting_controller.dart +++ b/lib/modules/meeting/controllers/meeting_controller.dart @@ -15,6 +15,8 @@ import '../../../core/utils/upload_oss.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../../../core/utils/permission_util.dart'; +import 'package:share_plus/share_plus.dart'; + class MeetingController extends GetxController { final ScrollController listController = ScrollController(); final AudioPlayer _audioPlayer = AudioPlayer(); @@ -112,10 +114,24 @@ class MeetingController extends GetxController { }); } + void loadTranslatopus() async { + final appDir = await appDocDir; // 先 await 获取目录 + final dir = Directory("${appDir.path}/OpusAudio"); + + print("appDocDir=${dir?.path}"); + + files = dir + .listSync() + .where((f) => f is File && (f.path.endsWith('.opus'))) + .toList(); + } + void loadTranslatAudio() async { final appDir = await appDocDir; // 先 await 获取目录 final dir = Directory("${appDir.path}/TranslatAudio"); + print("appDocDir=${dir?.path}"); + files = dir .listSync() .where((f) => @@ -128,6 +144,27 @@ class MeetingController extends GetxController { f.path.endsWith('.mov'))) .toList(); } +// 导入音频文件 + + Future shareAllTranslatAudio() async { + final filePath = selected[0]; + // for (final filePath in selected) { + // if (filePath.isEmpty) { + // EasyLoading.showToast('没有可分享的音频文件'); + // return; + // } + // } + try { + await Share.shareXFiles( + [XFile(filePath)], + text: '分享会议音频', + ); + + EasyLoading.showToast('分享完成!'); + } catch (e) { + EasyLoading.showToast('分享失败: ${e.toString()}'); + } + } void addTranslatAudio() async { EasyLoading.show(status: '正在批量导入...'); diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index ea15ec38e..c54ded46c 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -17,6 +17,7 @@ import '../../../data/services/deapsound_ai_service.dart'; import '../model/meeting_details_model.dart'; import '../model/meeting_model.dart'; import 'meeting_controller.dart'; +import 'package:share_plus/share_plus.dart'; class MeetingDetailsController extends GetxController with GetTickerProviderStateMixin { @@ -422,4 +423,26 @@ class MeetingDetailsController extends GetxController } allText = text; } + +// 导入音频文件 + + Future shareAllTranslatAudio() async { + // 确保 filepath 是字符串列表类型 + if (meetingData.value.filepath.isEmpty) { + EasyLoading.showToast('没有可分享的音频文件'); + return; + } + + try { + // 直接使用文件路径列表创建 XFile 列表 + await Share.shareXFiles( + [XFile(meetingData.value.filepath)], // 注意:这里用方括号创建包含单个文件的列表 + text: '分享会议音频', + ); + + EasyLoading.showToast('分享完成!'); + } catch (e) { + EasyLoading.showToast('分享失败: ${e.toString()}'); + } + } } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart new file mode 100644 index 000000000..1ad18656f --- /dev/null +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -0,0 +1,254 @@ +// meeting_record_controller.dart +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../data/services/asr_service.dart'; +import '../../../data/services/ble_manager.dart'; + +// 新增计时器状态枚举 +enum TimerStatus { + stopped, // 已停止/未开始 + running, // 运行中 + paused, // 已暂停 +} + +/// 控制器:处理录音状态、波形、文本转录等逻辑 +class MeetingRecordController extends GetxController + with SingleGetTickerProviderMixin { + //语言服务 + final AsrService _asrService = Get.find(); + //语言服务监听 + StreamSubscription? _recognitionSubscription; + //是否开启识别 + final RxBool isVoiceRecognitionActive = false.obs; +// 音频输入源 + bool _audioSourceType = false; + + //识别文本 + RxString intermediateContent = ''.obs; + RxString finalContent = ''.obs; + // final RxBool isRecording = false.obs; // 是否正在录音 + // 蓝牙服务 + final _bleManager = Get.find(); + final RxInt seconds = 0.obs; // 录音秒数 + final RxInt audioTypes = 0.obs; // 音频类型 + final RxBool isMarked = false.obs; // 是否标记重点 + final RxList waveData = [].obs; // 波形图数据 + RxBool showTextContent = false.obs; // 控制文本显示 + late TabController tabController; + Timer? _timer; + int _currentPosition = 0; // 当前播放位置(用于波形图动画) + + // 新增计时器状态变量 + final Rx timerStatus = TimerStatus.stopped.obs; + int _elapsedMilliseconds = 0; // 累计毫秒数 + int? _lastStartTime; // 上次开始计时的时间戳 + + @override + void onInit() { + super.onInit(); + _asrService.initialize(); + tabController = TabController(length: 2, vsync: this); + generateWaveData(); + // 移除初始化的 startTimer() 调用 + } + + @override + void onClose() { + _timer?.cancel(); + _asrService.dispose(); + + tabController.dispose(); + super.onClose(); + } + + /// 启动/继续计时器 + void startTimer() { + if (timerStatus.value != TimerStatus.running) { + timerStatus.value = TimerStatus.running; + _lastStartTime = DateTime.now().millisecondsSinceEpoch; + + _timer = Timer.periodic(const Duration(milliseconds: 200), (timer) { + if (timerStatus.value == TimerStatus.running) { + // 计算真实经过的时间(毫秒) + final currentTime = DateTime.now().millisecondsSinceEpoch; + _elapsedMilliseconds += currentTime - _lastStartTime!; + _lastStartTime = currentTime; + + // 转换为秒(四舍五入) + seconds.value = (_elapsedMilliseconds / 1000).round(); + update(); + } + }); + } + } + + /// 暂停计时器 + void pauseTimer() { + if (timerStatus.value == TimerStatus.running) { + timerStatus.value = TimerStatus.paused; + _timer?.cancel(); + _timer = null; + + // 更新累计时间 + final currentTime = DateTime.now().millisecondsSinceEpoch; + _elapsedMilliseconds += currentTime - _lastStartTime!; + _lastStartTime = null; + } + } + + /// 重置计时器 + void resetTimer() { + _timer?.cancel(); + _timer = null; + _elapsedMilliseconds = 0; + _lastStartTime = null; + seconds.value = 0; + timerStatus.value = TimerStatus.stopped; + } + + void toggleTextDisplay() { + showTextContent.toggle(); // 点击按钮切换显示状态 + } + + /// 切换录音状态 + Future toggleRecording() async { + if (timerStatus.value == TimerStatus.stopped) { + await _startRecording(); + } else if (timerStatus.value == TimerStatus.running) { + await _pauseRecording(); + } else if (timerStatus.value == TimerStatus.paused) { + await _resumeRecording(); + } + } + + /// 生成模拟波形图数据 + void generateWaveData() { + waveData.value = List.generate(120, (i) { + final rand = (i % 20) / 20; + final baseHeight = (sin(i / 5) * 0.5 + 0.5) * 25; + return baseHeight + rand * 10; + }); + } + +// 处理语音识别事件 + void _handleRecognitionEvent(RecognitionEvent event) { + switch (event.type) { + case RecognitionEventType.intermediateResult: + print( + "_handleRecognitionEvent,intermediateResult,event.text=${event.text}"); + intermediateContent.value = event.text; + break; + case RecognitionEventType.finalResult: + print("_handleRecognitionEvent,finalResult,event.text=${event.text}"); + finalContent.value += _spliceText(event); + intermediateContent.value = ''; + //_originaBytes.add(event.audio!); + break; + default: + // 不做任何处理 + break; + } + } + + String _spliceText(RecognitionEvent event) { + String eventText = event.text; + String text = ''; + if (finalContent.value.isNotEmpty) { + if (event.role != '') { + final punctuations = { + ',', + '。', + '!', + '?', + ';', + ':', + ',', + '.', + ':', + ';', + '!', + '?' + }; + final firstChar = eventText[0]; + if (punctuations.contains(firstChar)) { + text += '$firstChar\n'; + eventText = eventText.substring(1); + } else { + text += '\n'; + } + text += '说话人${event.role}:'; + } + } else { + text += '说话人${event.role}:'; + } + text += eventText; + return text; + } + + Future _startRecording() async { + // 开始录音 + final recognitionStream = + await _asrService.startContinuousRecognition(_audioSourceType); + _recognitionSubscription?.cancel(); + _recognitionSubscription = + recognitionStream.listen(_handleRecognitionEvent); + await _asrService.enableRecord("录音"); + + startTimer(); + + // 蓝牙处理 + if (audioTypes == 0) { + _bleManager.openEncoder(); + } else if (audioTypes == 1) { + _bleManager.openDecoder(); + } else if (audioTypes == 2) { + _bleManager.openA2DPDecoder(); + } + } + + Future _pauseRecording() async { + // 暂停录音 + await _asrService.stopContinuousRecognition(); + await _asrService.pauseRecord(); + pauseTimer(); + //await _asrService.pauseRecognition(); // 假设ASR服务有暂停方法 + } + + Future _resumeRecording() async { + // 开始录音 + final recognitionStream = + await _asrService.startContinuousRecognition(_audioSourceType); + _recognitionSubscription?.cancel(); + _recognitionSubscription = + recognitionStream.listen(_handleRecognitionEvent); + startTimer(); + + //await _asrService.resumeRecognition(); // 假设ASR服务有继续方法 + } + + /// 切换是否标记为重点 + void toggleMarked() { + isMarked.toggle(); + } + + /// 格式化时间为 mm:ss + String formatTime(int seconds) { + // 实现你的时间格式化逻辑 + return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; + } + + /// 游标在波形图中的位置(0~1) + double get cursorPosition => _currentPosition / waveData.length; + + /// 保存录音(目前模拟为返回上一页) + Future saveRecording() async { + resetTimer(); // 重置计时器 + _bleManager.closeCodec(); + await _asrService.stopRecord(); + await _asrService.stopContinuousRecognition(); + Get.back(); + } +} diff --git a/lib/modules/meeting/views/meeting_details_view.dart b/lib/modules/meeting/views/meeting_details_view.dart index 5ce0b6cbc..6295bdf4a 100644 --- a/lib/modules/meeting/views/meeting_details_view.dart +++ b/lib/modules/meeting/views/meeting_details_view.dart @@ -24,7 +24,7 @@ class MeetingDetailsView extends GetView { child: Obx( () => Scaffold( backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: _buildAppBar(isDarkMode), + appBar: _buildAppBar(context, isDarkMode), body: _buildBody(isDarkMode), ), ), @@ -32,7 +32,7 @@ class MeetingDetailsView extends GetView { } // 构建应用栏 - PreferredSizeWidget _buildAppBar(bool isDarkMode) { + PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) { return AppBar( backgroundColor: controller.isTop.value ? Colors.grey[50] : Colors.grey[200], @@ -142,7 +142,9 @@ class MeetingDetailsView extends GetView { color: isDarkMode ? Colors.white : Colors.black, size: 22.sp, ), - onPressed: () {}, + onPressed: () { + _showShareSheet(context, isDarkMode); + }, ), if (!controller.isSpeakerText.value) IconButton( @@ -274,6 +276,102 @@ class MeetingDetailsView extends GetView { ); } +// 2. 新增底部分享弹窗方法 + void _showShareSheet(BuildContext context, bool isDarkMode) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + ), + padding: EdgeInsets.only( + left: 0, + right: 0, + top: 12.w, + bottom: MediaQuery.of(context).viewInsets.bottom + 12.w, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 文件名和关闭按钮 + Row( + children: [ + SizedBox(width: 16.w), + Icon(Icons.insert_drive_file, color: Colors.grey, size: 22.w), + SizedBox(width: 8.w), + Expanded( + child: Text( + controller.meetingData.value.title ?? '会议文件', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.sp, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + ), + IconButton( + icon: Icon(Icons.close, + color: isDarkMode ? Colors.white : Colors.black54, + size: 24.w), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + SizedBox(height: 8.w), + Divider( + height: 1, + color: isDarkMode ? Colors.white12 : Colors.grey[200]), + // 分享选项 + _shareItem(Icons.link, '分享链接', onTap: () {/* TODO */}), + _shareItem(Icons.copy, '复制转写', onTap: () {/* TODO */}), + _shareItem(Icons.copy, '复制总结', onTap: () {/* TODO */}), + // ...existing code... + _shareItem(Icons.multitrack_audio, '导出音频', onTap: () { + controller.shareAllTranslatAudio(); + }), +// ...existing code... + _shareItem(Icons.description, '导出转写', onTap: () {/* TODO */}), + _shareItem(Icons.description, '导出总结', onTap: () {/* TODO */}), + _shareItem(Icons.account_tree, '导出思维导图', onTap: () {/* TODO */}), + SizedBox(height: 8.w), + ], + ), + ); + }, + ); + } + +// 3. 分享选项组件 + Widget _shareItem(IconData icon, String title, {VoidCallback? onTap}) { + return InkWell( + onTap: () { + Navigator.of(Get.context!).pop(); + if (onTap != null) onTap(); + }, + child: Container( + height: 52.w, + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Row( + children: [ + Icon(icon, color: Colors.grey[700], size: 22.w), + SizedBox(width: 16.w), + Text( + title, + style: TextStyle( + fontSize: 16.sp, + color: Colors.black87, + ), + ), + ], + ), + ), + ); + } + Widget _meetingWait(String title) { return Stack( alignment: Alignment.topCenter, diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart new file mode 100644 index 000000000..f29de80c6 --- /dev/null +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:lottie/lottie.dart'; +import '../controllers/meeting_record_controller.dart'; + +class MeetingRecordView extends GetView { + const MeetingRecordView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('新录音 29', style: TextStyle(fontSize: 18)), // 更新为29 + elevation: 0, + backgroundColor: Colors.white, + foregroundColor: Colors.black, + ), + body: Column( + children: [ + TabBar( + controller: controller.tabController, + indicatorColor: Colors.red, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + tabs: const [ + Tab(text: '音频'), + Tab(text: '文本'), + ], + ), + Expanded( + child: TabBarView( + controller: controller.tabController, + children: [ + _buildAudioTab(context), + _buildTextTab(context), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAudioTab(BuildContext context) { + return Obx(() => Column( + children: [ + const SizedBox(height: 24), + SizedBox( + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + painter: WaveformPainter( + controller.waveData, + cursorPosition: controller.cursorPosition, + ), + size: Size.infinite, + ), + Positioned( + left: MediaQuery.of(context).size.width * + controller.cursorPosition, + child: Container( + width: 2, + height: 120, + color: Colors.red, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + controller.formatTime(controller.seconds.value), + style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold), + ), + const Spacer(), + _buildBottomBar(context), + const SizedBox(height: 32), + ], + )); + } + + Widget _buildTextTab(BuildContext context) { + return Obx(() => Column( + children: [ + const SizedBox(height: 48), + Center( + child: Column( + children: [ + const Icon(Icons.graphic_eq, color: Colors.red, size: 32), + const SizedBox(height: 8), + Text( + controller.formatTime(controller.seconds.value), + style: const TextStyle( + fontSize: 36, fontWeight: FontWeight.bold), + ), + ], + ), + ), + const SizedBox(height: 24), + // 新增:转文本提示区域 + if (!controller.showTextContent.value) ...[ + const Text('无文本', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + )), + const SizedBox(height: 16), + ElevatedButton( + onPressed: controller.toggleTextDisplay, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + ), + child: const Text('转文本'), + ), + ], + // 文本内容区域(转文本后显示) + if (controller.showTextContent.value) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SingleChildScrollView( + child: Text( + '${controller.finalContent.value}${controller.intermediateContent.value}', + style: const TextStyle(fontSize: 16), + ), + ), + ), + ), + const Spacer(), + _buildBottomBar(context), + const SizedBox(height: 32), + ], + )); + } + + Widget _buildBottomBar(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: controller.toggleMarked, + child: Obx(() => CircleAvatar( + radius: 32, + backgroundColor: Colors.white, + child: Icon( + controller.isMarked.value ? Icons.star : Icons.star_border, + size: 36, + color: + controller.isMarked.value ? Colors.orange : Colors.grey, + ), + )), + ), + GestureDetector( + onTap: controller.toggleRecording, + child: Obx(() => CircleAvatar( + radius: 40, + backgroundColor: Colors.white, + child: Icon( + controller.timerStatus.value == TimerStatus.running + ? Icons.pause + : Icons.play_arrow, + size: 44, + color: Colors.red, + ), + )), + ), + GestureDetector( + onTap: controller.saveRecording, + child: const CircleAvatar( + radius: 32, + backgroundColor: Colors.white, + child: Icon( + Icons.check, + size: 36, + color: Colors.green, + ), + ), + ), + ], + ); + } +} + +class WaveformPainter extends CustomPainter { + final List data; + final double cursorPosition; + + WaveformPainter(this.data, {required this.cursorPosition}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.blue[300]! + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + + final cursorPaint = Paint() + ..color = Colors.red + ..strokeWidth = 2; + + final double barWidth = size.width / data.length; + final centerY = size.height / 2; + + for (int i = 0; i < data.length; i++) { + final x = i * barWidth + barWidth / 2; + final height = data[i]; + canvas.drawLine( + Offset(x, centerY - height), + Offset(x, centerY + height), + paint, + ); + } + + final cursorX = size.width * cursorPosition; + canvas.drawLine( + Offset(cursorX, 0), + Offset(cursorX, size.height), + cursorPaint, + ); + } + + @override + bool shouldRepaint(covariant WaveformPainter oldDelegate) { + return oldDelegate.data != data || + oldDelegate.cursorPosition != cursorPosition; + } +} diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index 67e4297b6..b377232f7 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/lib/modules/meeting/views/meeting_view.dart @@ -9,31 +9,36 @@ import '../../../core/theme/app_colors.dart'; import '../../../routes/app_routes.dart'; import '../controllers/meeting_controller.dart'; import '../model/meeting_model.dart'; -import 'dart:io'; +import 'meeting_record_view.dart'; class MeetingView extends GetView { const MeetingView({super.key}); - + static OverlayEntry? _recordingOverlayEntry; @override Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: _buildAppBar(isDarkMode), + appBar: _buildAppBar(context, isDarkMode), body: _buildBody(context, isDarkMode), - floatingActionButton: FloatingActionButton( - //onPressed: controller.addAudio, - onPressed: () { - // 显示底部选择菜单 - _showAddAudioMenu(context, isDarkMode); - }, - child: const Icon(Icons.add), + floatingActionButton: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: "addAudio", + onPressed: () { + _showAddAudioMenu(context, isDarkMode); + }, + child: const Icon(Icons.add), + ), + ], ), ); } -// ...existing code... +// 底部+号菜单 void _showAddAudioMenu(BuildContext context, bool isDarkMode) { showModalBottomSheet( context: context, @@ -56,7 +61,7 @@ class MeetingView extends GetView { onTap: () { Navigator.of(context).pop(); // TODO: 实现开始录音逻辑 - // controller.startRecording(); + _showStartsRecordSheet(context, isDarkMode); }, child: Container( height: 56.w, @@ -178,7 +183,114 @@ class MeetingView extends GetView { }, ); } -// ...existing code... + + // 新增:从opus导出选择弹窗 + void _showLocalOpusPicker(BuildContext context, bool isDarkMode) async { + // 这里以用户目录下的 Downloads 为例,可根据实际需求修改路径 + controller.loadTranslatopus(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + ), + padding: EdgeInsets.only( + left: 0, + right: 0, + top: 12.w, + bottom: MediaQuery.of(context).viewInsets.bottom + 12.w, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '选择音视频文件', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + Divider( + height: 1, + color: isDarkMode ? Colors.white12 : Colors.grey[200]), + SizedBox(height: 8.w), + SizedBox( + height: 300.w, + child: ListView.builder( + itemCount: controller.files.length, + itemBuilder: (context, idx) { + final file = controller.files[idx]; + final fileName = file.path.split('/').last; + final isChecked = + controller.selected.contains(file.path); + return CheckboxListTile( + value: isChecked, + onChanged: (checked) { + setState(() { + if (checked == true) { + controller.selected.add(file.path); + } else { + controller.selected.remove(file.path); + } + }); + }, + title: Text( + fileName, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 15.sp, + ), + ), + controlAffinity: ListTileControlAffinity.leading, + activeColor: Colors.blue, + ); + }, + ), + ), + SizedBox(height: 12.w), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + ), + onPressed: () => Navigator.of(context).pop(), + child: + Text('取消', style: TextStyle(color: Colors.white)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + ), + onPressed: controller.selected.isEmpty + ? null + : () { + Navigator.of(context).pop(); + // TODO: 调用导入逻辑 + controller.shareAllTranslatAudio(); + }, + child: + Text('导入选中', style: TextStyle(color: Colors.white)), + ), + ], + ), + SizedBox(height: 8.w), + ], + ), + ); + }, + ); + }, + ); + } // 新增:本地音视频文件选择弹窗 void _showLocalAudioVideoPicker(BuildContext context, bool isDarkMode) async { @@ -288,7 +400,7 @@ class MeetingView extends GetView { ); } -// ...existing code... +// 导入音频弹窗 void _showImportAudioSheet(BuildContext context, bool isDarkMode) { showModalBottomSheet( context: context, @@ -371,9 +483,128 @@ class MeetingView extends GetView { width: 20.w, height: 20.w), ], ), - title: '从录音里导入', + title: '从opus导出', + onTap: () { + Navigator.of(context).pop(); + // TODO: 实现从其他APP导入逻辑 + _showLocalOpusPicker(context, isDarkMode); + }, + ), + ], + ), + ), + SizedBox(height: 8.w), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Text( + '支持导入单文件时长不超过5小时的音频', + style: TextStyle( + fontSize: 13.sp, + color: isDarkMode ? Colors.white54 : Colors.grey[600], + ), + ), + ), + SizedBox(height: 16.w), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Icon(Icons.close, size: 32.w, color: Colors.grey), + ), + SizedBox(height: 8.w), + ], + ), + ); + }, + ); + } + +// 开始录音弹窗 + void _showStartsRecordSheet(BuildContext context, bool isDarkMode) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[900] : Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(18.w)), + ), + padding: EdgeInsets.only( + left: 0, + right: 0, + top: 12.w, + bottom: MediaQuery.of(context).viewInsets.bottom + 12.w, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.w), + child: Text( + '开始录音', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + ), + Divider( + height: 1, + color: isDarkMode ? Colors.white12 : Colors.grey[200]), + // 选项列表 + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), + child: Column( + children: [ + _importAudioItem( + context, + isDarkMode, + icon: Image.asset('assets/images/explore.png', + width: 32.w, height: 32.w), + title: '音/视频录音', + onTap: () { + Navigator.of(context).pop(); + Get.toNamed(Routes.meetingRecord); + // TODO: 实现从文件导入逻辑 + }, + ), + SizedBox(height: 8.w), + _importAudioItem( + context, + isDarkMode, + icon: Image.asset('assets/images/explore.png', + width: 32.w, height: 32.w), + title: '现场录音', + onTap: () { + Navigator.of(context).pop(); + Get.toNamed(Routes.meetingRecord); + + // TODO: 实现从相册导入逻辑 + }, + ), + SizedBox(height: 8.w), + _importAudioItem( + context, + isDarkMode, + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/explore.png', + width: 20.w, height: 20.w), + SizedBox(width: 4.w), + Image.asset('assets/images/explore.png', + width: 20.w, height: 20.w), + SizedBox(width: 4.w), + Image.asset('assets/images/explore.png', + width: 20.w, height: 20.w), + ], + ), + title: '通话录音', onTap: () { Navigator.of(context).pop(); + Get.toNamed(Routes.meetingRecord); // TODO: 实现从其他APP导入逻辑 }, ), @@ -404,6 +635,7 @@ class MeetingView extends GetView { ); } +// 选项框 Widget _importAudioItem( BuildContext context, bool isDarkMode, { @@ -444,10 +676,120 @@ class MeetingView extends GetView { ), ); } -// ...existing code... + +// 新增右侧弹窗 + + void _showRightDrawerDialog(BuildContext context, bool isDarkMode) { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: "rightDrawer", + transitionDuration: const Duration(milliseconds: 300), + pageBuilder: (context, anim1, anim2) { + return Align( + alignment: Alignment.centerRight, + child: FractionallySizedBox( + widthFactor: 0.7, + child: Material( + color: Colors.transparent, + child: Container( + height: MediaQuery.of(context).size.height, + decoration: BoxDecoration( + color: isDarkMode ? Colors.white : Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(18.w), + bottomLeft: Radius.circular(18.w), + ), + ), + child: _buildDrawerContent(context, isDarkMode), + ), + ), + ), + ); + }, + transitionBuilder: (context, anim1, anim2, child) { + final offsetAnimation = Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: anim1, curve: Curves.easeOutCubic)); + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + ); + } + +// 侧边栏内容 + Widget _buildDrawerContent(BuildContext context, bool isDarkMode) { + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.all(20.w), + child: TextField( + decoration: InputDecoration( + hintText: '在所有文件中搜索', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.w), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.grey[100], + contentPadding: EdgeInsets.zero, + ), + ), + ), + ListTile( + leading: Icon(Icons.folder, color: Colors.red), + title: + Text('全部文件(4)', style: TextStyle(fontWeight: FontWeight.bold)), + onTap: () {}, + ), + ListTile( + leading: Icon(Icons.category, color: Colors.blue), + title: Text('未分类(4)'), + onTap: () {}, + ), + ListTile( + leading: Icon(Icons.delete, color: Colors.teal), + title: Text('回收站'), + onTap: () {}, + ), + Divider(), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), + child: Text('文件夹', style: TextStyle(color: Colors.grey)), + ), + ...['NOTE', 'CALL', 'NotePin', 'Import', 'Desktop'] + .map((name) => ListTile( + title: Text(name), + onTap: () {}, + )), + Divider(), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), + child: Text('排序方式', style: TextStyle(color: Colors.grey)), + ), + ListTile( + title: Text('创建时间'), + trailing: Icon(Icons.arrow_upward), + onTap: () {}, + ), + ListTile( + title: Text('修改时间'), + trailing: Icon(Icons.arrow_upward), + onTap: () {}, + ), + ], + ), + ); + } // 构建应用栏 - PreferredSizeWidget _buildAppBar(bool isDarkMode) { + PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) { return AppBar( backgroundColor: isDarkMode ? Colors.black : Colors.white, elevation: 0.5, @@ -468,6 +810,15 @@ class MeetingView extends GetView { ), ), centerTitle: true, + actions: [ + IconButton( + icon: Icon(Icons.folder_open, + color: isDarkMode ? Colors.white : Colors.black), + onPressed: () { + _showRightDrawerDialog(context, isDarkMode); + }, + ), + ], ); } @@ -809,4 +1160,19 @@ class MeetingView extends GetView { clipBehavior: Clip.hardEdge, ); } +// ...existing code... + + // void _showRecordingSheet(BuildContext context, bool isDarkMode) { + // if (_recordingOverlayEntry != null) return; // 防止重复添加 + // _recordingOverlayEntry = OverlayEntry( + // builder: (ctx) => RecordingFloatingPanel( + // onStop: () { + // _recordingOverlayEntry?.remove(); + // _recordingOverlayEntry = null; + // // 录音完成后的逻辑 + // }, + // ), + // ); + // Overlay.of(context).insert(_recordingOverlayEntry!); + // } } diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 849c81551..8f5eaa502 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -9,6 +9,8 @@ import '../modules/explore/bindings/explore_binding.dart'; import '../modules/meeting/bindings/meeting_binding.dart'; import '../modules/meeting/bindings/meeting_details_binding.dart'; import '../modules/meeting/views/meeting_details_view.dart'; +import '../modules/meeting/bindings/meeting_record_binding.dart'; +import '../modules/meeting/views/meeting_record_view.dart'; import '../modules/meeting/views/meeting_view.dart'; import '../modules/profile/views/profile_view.dart'; import '../modules/profile/bindings/profile_binding.dart'; @@ -141,6 +143,13 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), + GetPage( + name: Routes.meetingRecord, + page: () => const MeetingRecordView(), + binding: MeetingRecordBinding(), + transition: Transition.rightToLeft, + transitionDuration: Duration(milliseconds: 250), + ), GetPage( name: Routes.timbre, page: () => const TimbreView(), diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index e054da0f3..8ee5515dd 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -22,7 +22,7 @@ abstract class Routes { // 会议相关 static const meeting = '/meeting'; static const meetingDetails = '/meeting/details'; - + static const meetingRecord = '/meeting/record'; // 语言功能 static const translation = '/translation'; // 实时翻译 static const translationHistory = '/translation/history'; // 翻译历史 diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index da6d58be0..d493330ec 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -486,7 +486,7 @@ class AzureAsrHelper(private val context: Context) { // 停止音频处理 stopAudioProcessing() // 停止录音 - recordfile!!.closeFile() + // 会话结束事件会设置isContinuousRecognitionActive = false return true } catch (e: Exception) { @@ -496,7 +496,7 @@ class AzureAsrHelper(private val context: Context) { // 停止音频处理 stopAudioProcessing() - recordfile!!.closeFile() + // 尝试强制关闭识别器 try { recognizer?.close() @@ -609,16 +609,30 @@ class AzureAsrHelper(private val context: Context) { */ fun enableRecord() { Log.i(tag, "开启录音:") - +if( recordfile!!.isPause) +{ + recordfile!!.isPause=false +} +else{ recordfile!!.closeFile() isRecording = true if (isContinuousRecognitionActive) { recordfile!!.creatingFiles() } +} } + /** + * 停止录音 + */ + fun pauseRecord() { + Log.i(tag, "停止连续录音:") + isRecording = false + recordfile!!.isPause=true + } + /** * 停止录音 @@ -626,6 +640,7 @@ class AzureAsrHelper(private val context: Context) { fun stopRecord() { Log.i(tag, "停止连续录音:") isRecording = false + recordfile!!.isPause=false recordfile!!.closeFile() } @@ -912,6 +927,7 @@ class AzureAsrHelper(private val context: Context) { // 音频配置 private val sampleRate = 16000 +var isPause=false var fileName = "" internal fun creatingFiles() { if (fos != null || currentAudioFile != null) { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index af89d17a6..855d0b843 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -365,7 +365,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { result.error("ENABLERECORD_ERROR", e.message, null) } } - + "pauseRecord" -> { + azureAsrHelper.pauseRecord() + result.success(true) + } "stopRecord" -> { azureAsrHelper.stopRecord() result.success(true) diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 84cfc4e05..d81842a90 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -5,7 +5,6 @@ import android.bluetooth.le.* import android.content.Context import android.os.* import androidx.lifecycle.MutableLiveData -import android.util.Log import java.util.* import java.util.concurrent.CopyOnWriteArrayList // Import Jieli Opus SDK classes @@ -14,6 +13,19 @@ import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback import com.jieli.jl_audio_decode.opus.OpusManager import com.jieli.jl_audio_decode.exceptions.OpusException + +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile +import java.text.SimpleDateFormat +import java.util.Date +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import android.os.Handler +import android.os.HandlerThread +import java.util.concurrent.atomic.AtomicBoolean +import android.util.Log /** * BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能 * 支持: @@ -63,7 +75,7 @@ object BleService { private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null - + var recordfile: RecordingFile? = null // 扫描相关 private lateinit var scanHandler: Handler private val scanResults = ArrayList() @@ -128,7 +140,7 @@ object BleService { Log.e(TAG, "OpusManager初始化失败: ${e.message}", e) // 根据需要决定是否因为Opus初始化失败而返回false } - +recordfile = RecordingFile(this.context) isInitialized = true Log.d(TAG, "BLE服务初始化成功") return true @@ -510,6 +522,7 @@ object BleService { when (c.uuid) { // 音频特征数据 BleConst.RECEIVE_AUDIO_CHAR_UUID -> { + // Log.i(TAG, "收到音频特征数据") processAudioData(data) } @@ -588,6 +601,7 @@ object BleService { private fun processAudioData(data: ByteArray) { try { if (opusManager?.isDecodeStream == true) { + recordfile?.saveAudioDataToWav(data) opusManager?.writeAudioStream(data) } else { // Log.d(TAG, "Opus解码流未启动,忽略音频数据") @@ -785,7 +799,17 @@ object BleService { BleConst.CODEC_CONTROL_ENCODE_ON -> "已打开编码" else -> "未知状态($codecStatus)" } - +if (codecStatus == BleConst.CODEC_CONTROL_DECODE_ON || + codecStatus == BleConst.CODEC_CONTROL_A2DP_PLAY || + codecStatus == BleConst.CODEC_CONTROL_ENCODE_ON) { + + recordfile!!.closeFile() + recordfile!!.creatingFiles() +} +else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) +{ + recordfile!!.closeFile() +} // 声道模式描述 val channelDesc = when (channelMode) { BleConst.AUDIO_CHANNEL_LEFT -> "左声道" @@ -1366,4 +1390,96 @@ object BleService { } } } -} \ No newline at end of file + + +} + class RecordingFile(private val context: Context) { + private var currentAudioFile: File? = null + private var fos: FileOutputStream? = null + + // 新增:用于异步写入的队列和线程 + private val writeQueue = LinkedBlockingQueue() + private val isWriting = AtomicBoolean(false) + private var writeThread: Thread? = null + + // 用于存储音频数据的缓冲区 + private val dataBuffer = mutableListOf() + private var totalBytesWritten = 0 + + var fileName = "" + internal fun creatingFiles() { + if (fos != null || currentAudioFile != null) { + return + } + // 创建新的音频文件 + val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) + val timestamp = dateFormat.format(Date()) + val parentDir = File(context.filesDir, "OpusAudio") + if (!parentDir.exists()) { + parentDir.mkdirs() + } + val filePath = File(parentDir, "${fileName}_${timestamp}.opus").absolutePath + currentAudioFile = File(filePath) + currentAudioFile?.createNewFile() + // 追加音频数据到文件 + fos = FileOutputStream(currentAudioFile, true) + startWriteThread() // 新增:启动写入线程 + } + + // 新增:启动写入线程 + private fun startWriteThread() { + if (isWriting.get()) return + isWriting.set(true) + writeThread = Thread { + try { + while (isWriting.get() || writeQueue.isNotEmpty()) { + val data = writeQueue.poll() ?: continue + fos?.write(data) + } + } catch (e: Exception) { + Log.e("", "异步写入音频数据失败: ${e.message}") + } + } + writeThread?.start() + } + + /** + * 保存音频数据到 WAV 文件(异步) + */ + internal fun saveAudioDataToWav(buffer: ByteArray) { + if (fos == null || currentAudioFile == null) return + // 放入队列,由写线程写入 + writeQueue.offer(buffer.copyOf()) + totalBytesWritten += buffer.size + } + + + internal fun closeFile() { + try { + if (fos == null || currentAudioFile == null) { + return + } + isWriting.set(false) + writeThread?.join(500) // 等待写线程结束 + fos?.close() + + // 更新WAV文件头 + currentAudioFile?.let { file -> + // 如果文件小于44字节,直接删除 + if (file.length() <= 0) { + file.delete() + Log.d("TAG", "音频文件过小已删除: ${file.absolutePath}") + } else { + Log.d("TAG", "音频文件保存完成: ${file.absolutePath}") + } + } + + } catch (e: Exception) { + Log.e("TAG", "更新opus失败: ${e.message}") + } finally { + fos = null + currentAudioFile = null + } + } +} + \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 6844d9e38..ccd4d7c49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,7 @@ dependencies: device_info_plus: ^10.1.2 uuid: ^4.3.3 record: ^5.2.1 - share_plus: ^10.0.0 + share_plus: ^11.0.0 image_picker: ^1.1.2 audio_waveforms: ^1.3.0 font_awesome_flutter: ^10.8.0