From 2bf8c767f9269177fcc320f6742d04f2fdec1c97 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Tue, 10 Jun 2025 11:07:34 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BF=BB=E8=AF=91=E5=BD=95=E9=9F=B3=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BF=9D=E5=AD=98=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/asr_service.dart | 6 + .../speech_impl/azure_asr_service.dart | 6 +- .../speech_impl/volcano_asr_api_service.dart | 14 +- .../speech_impl/volcano_asr_service.dart | 12 + .../speech_impl/xunfei_asr_service.dart | 16 +- lib/modules/meeting/views/meeting_view.dart | 436 +++++++++++++++++- .../controllers/translation_controller.dart | 15 +- .../azure_speech/AzureAsrHelper.kt | 71 +-- .../azure_speech/AzureSpeechPlugin.kt | 7 + 9 files changed, 541 insertions(+), 42 deletions(-) diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 75c2457c6..715a87a19 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -29,6 +29,12 @@ abstract class AsrService { /// 检查连续识别是否活跃 bool isContinuousRecognitionActive(); + /// 停止连续语音识别 + Future enableRecord(String fileName); + + /// 停止连续语音识别 + Future stopRecord(); + /// 释放资源 Future dispose(); } diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index b44d2aa2b..b7f12e55a 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -344,9 +344,11 @@ class AzureAsrService extends GetxService implements AsrService { } @override - Future enableRecord() async { + Future enableRecord(String fileName) async { try { - final bool result = await _channel.invokeMethod('enableRecord'); + final bool result = await _channel.invokeMethod('enableRecord', { + 'fileName': fileName, + }); return result; } catch (e) { 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 26f18e1c5..ba0629282 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -811,4 +811,16 @@ class VolcanoAsrApiService implements AsrService { _isContinuousRecognitionActive = false; _isRecognitionEndRequested = false; } -} \ No newline at end of file + + @override + Future enableRecord(String fileName) { + // TODO: implement enableRecord + throw UnimplementedError(); + } + + @override + Future stopRecord() { + // TODO: implement stopRecord + 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 ccb53f447..f32330875 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -346,4 +346,16 @@ class VolcanoAsrService extends GetxService implements AsrService { _isInitialized = false; } } + + @override + Future enableRecord(String fileName) { + // TODO: implement enableRecord + throw UnimplementedError(); + } + + @override + Future stopRecord() { + // TODO: implement stopRecord + 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 00b151019..81cdd9db3 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -185,7 +185,7 @@ class XunfeiAsrService extends GetxService implements AsrService { } } } - Logger.info( + Logger.info( "识别到的内容 type:${data['cn']['st']['type']} msg:$msg rl:$role"); if (msg.isNotEmpty) { if (data['cn']['st']['type'] == "0") { @@ -270,4 +270,16 @@ class XunfeiAsrService extends GetxService implements AsrService { _isInitialized = false; } } -} \ No newline at end of file + + @override + Future enableRecord(String fileName) { + // TODO: implement enableRecord + throw UnimplementedError(); + } + + @override + Future stopRecord() { + // TODO: implement stopRecord + throw UnimplementedError(); + } +} diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index fce0d4021..c3f0b43be 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/lib/modules/meeting/views/meeting_view.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; +import 'package:path_provider/path_provider.dart'; import '../../../core/theme/app_colors.dart'; import '../../../routes/app_routes.dart'; import '../controllers/meeting_controller.dart'; import '../model/meeting_model.dart'; +import 'dart:io'; class MeetingView extends GetView { const MeetingView({super.key}); @@ -21,12 +23,444 @@ class MeetingView extends GetView { appBar: _buildAppBar(isDarkMode), body: _buildBody(context, isDarkMode), floatingActionButton: FloatingActionButton( - onPressed: controller.addAudio, + //onPressed: controller.addAudio, + onPressed: () { + // 显示底部选择菜单 + _showAddAudioMenu(context, isDarkMode); + }, child: const Icon(Icons.add), ), ); } +// ...existing code... + void _showAddAudioMenu(BuildContext context, bool isDarkMode) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 30.w, + left: 16.w, + right: 16.w, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 录音按钮 + Container( + margin: EdgeInsets.only(bottom: 12.w), + child: GestureDetector( + onTap: () { + Navigator.of(context).pop(); + // TODO: 实现开始录音逻辑 + // controller.startRecording(); + }, + child: Container( + height: 56.w, + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(0.05) + : Colors.grey[100], + borderRadius: BorderRadius.circular(28.w), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 36.w, + height: 36.w, + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.mic, color: Colors.red, size: 24.w), + ), + SizedBox(width: 12.w), + Text( + '开始录音', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 16.sp, + fontWeight: FontWeight.w500, + ), + ), + Spacer(), + Icon(Icons.arrow_forward_ios, + color: Colors.grey, size: 18.w), + SizedBox(width: 16.w), + ], + ), + ), + ), + ), + // 导入音频按钮 + Container( + margin: EdgeInsets.only(bottom: 24.w), + child: GestureDetector( + onTap: () { + Navigator.of(context).pop(); + _showImportAudioSheet(context, isDarkMode); + }, + child: Container( + height: 56.w, + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(0.10) + : Colors.white, + borderRadius: BorderRadius.circular(28.w), + border: Border.all( + color: Colors.blue.withOpacity(0.15), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.08), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 36.w, + height: 36.w, + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.file_upload_outlined, + color: Colors.blue, size: 24.w), + ), + SizedBox(width: 12.w), + Text( + '导入音频', + style: TextStyle( + color: Colors.blue, + fontSize: 16.sp, + fontWeight: FontWeight.w500, + ), + ), + Spacer(), + Icon(Icons.arrow_forward_ios, + color: Colors.grey, size: 18.w), + SizedBox(width: 16.w), + ], + ), + ), + ), + ), + // 关闭按钮 + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 56.w, + height: 56.w, + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(0.08) + : Colors.black.withOpacity(0.08), + shape: BoxShape.circle, + ), + child: Icon(Icons.close, + color: isDarkMode ? Colors.white : Colors.black54, + size: 32.w), + ), + ), + SizedBox(height: 10.w), + ], + ), + ); + }, + ); + } +// ...existing code... + +// 新增:本地音视频文件选择弹窗 + void _showLocalAudioVideoPicker(BuildContext context, bool isDarkMode) async { + // 这里以用户目录下的 Downloads 为例,可根据实际需求修改路径 + + final directory = Directory( + '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); + final List files = directory + .listSync() + .where((f) => + f is File && + (f.path.endsWith('.mp3') || + f.path.endsWith('.wav') || + f.path.endsWith('.aac') || + f.path.endsWith('.m4a') || + f.path.endsWith('.mp4') || + f.path.endsWith('.mov'))) + .toList(); + + List selected = []; + + 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: files.length, + itemBuilder: (context, idx) { + final file = files[idx]; + final fileName = file.path.split('/').last; + final isChecked = selected.contains(file.path); + return CheckboxListTile( + value: isChecked, + onChanged: (checked) { + setState(() { + if (checked == true) { + selected.add(file.path); + } else { + 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: selected.isEmpty + ? null + : () { + Navigator.of(context).pop(); + // TODO: 调用导入逻辑 + // controller.importFiles(selected); + }, + child: + Text('导入选中', style: TextStyle(color: Colors.white)), + ), + ], + ), + SizedBox(height: 8.w), + ], + ), + ); + }, + ); + }, + ); + } + +// ...existing code... + void _showImportAudioSheet(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(); + controller.addAudio(); + // 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(); + _showLocalAudioVideoPicker(context, isDarkMode); + // 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(); + // TODO: 实现从其他APP导入逻辑 + }, + ), + ], + ), + ), + 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), + ], + ), + ); + }, + ); + } + + Widget _importAudioItem( + BuildContext context, + bool isDarkMode, { + required Widget icon, + required String title, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12.w), + onTap: onTap, + child: Container( + height: 52.w, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: + isDarkMode ? Colors.white.withOpacity(0.03) : Colors.grey[50], + borderRadius: BorderRadius.circular(12.w), + ), + child: Row( + children: [ + icon, + SizedBox(width: 14.w), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 16.sp, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + ), + Icon(Icons.arrow_forward_ios, size: 18.w, color: Colors.grey), + ], + ), + ), + ), + ); + } +// ...existing code... + // 构建应用栏 PreferredSizeWidget _buildAppBar(bool isDarkMode) { return AppBar( diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 47bfbd1b6..5be5a8990 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -47,10 +47,13 @@ class TranslationController extends GetxController { final translationHistory = [].obs; String? currentSessionId; - void changeTranslationMode(String mode) { + Future changeTranslationMode(String mode) async { currentMode.value = mode; currentModeTitle.value = _getModeTitle(mode); // 这里添加具体模式切换逻辑 + if (isRecording.value) { + await _asrService.enableRecord(currentModeTitle.value.tr); + } stopAll(); } @@ -196,11 +199,11 @@ class TranslationController extends GetxController { // 切换录音功能 Future toggleisRecord() async { isRecording.toggle(); - // if (isRecording.value) { - // await _asrService.enableRecord(); - // } else { - // await _asrService.stopRecord(); - // } + if (isRecording.value) { + await _asrService.enableRecord(currentModeTitle.value.tr); + } else { + await _asrService.stopRecord(); + } } // 开始语音识别 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 48c10d5bc..3fed166b0 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 @@ -28,7 +28,8 @@ import java.util.concurrent.TimeUnit import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile - +import java.text.SimpleDateFormat +import java.util.Date /** * Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 * @@ -61,7 +62,7 @@ class AzureAsrHelper(private val context: Context) { private var externalAudioStream: ExternalAudioPullStream? = null // 录音文件处理 - private var recordfile: RecordingFile? = null + var recordfile: RecordingFile? = null private var isRecording = false /** @@ -188,10 +189,13 @@ class AzureAsrHelper(private val context: Context) { val needEchoCancellation = !isHeadsetConnected if (needEchoCancellation) { + Log.i(tag, "使用拉流方式进行回音消除") // 使用拉流方式进行回音消除 setupMicrophoneStream() } else { + Log.i(tag, "使用拉流方式进行回音消除2") audioConfig = AudioConfig.fromDefaultMicrophoneInput() + setupMicrophoneStream() } } @@ -601,12 +605,11 @@ class AzureAsrHelper(private val context: Context) { Log.i(tag, "开启录音:") isRecording = true recordfile!!.closeFile() - - - if (isContinuousRecognitionActive) { + if (isContinuousRecognitionActive) { recordfile!!.creatingFiles() } + } /** @@ -723,6 +726,7 @@ class AzureAsrHelper(private val context: Context) { Log.e(tag, "读取音频数据失败: $result") return 0 } + recordfile?.saveAudioDataToWav(buffer) return result } catch (e: Exception) { Log.e(tag, "读取音频异常: ${e.message}") @@ -882,7 +886,7 @@ class AzureAsrHelper(private val context: Context) { } - private inner class RecordingFile { + inner class RecordingFile { private var currentAudioFile: File? = null private var fos: FileOutputStream? = null @@ -893,15 +897,16 @@ class AzureAsrHelper(private val context: Context) { // 音频配置 private val sampleRate = 16000 - + var fileName = "" internal fun creatingFiles() { if (fos != null || currentAudioFile != null) { return } // 创建新的音频文件 - val timestamp = System.currentTimeMillis() + val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss") + val timestamp = dateFormat.format(Date()) val filePath = - context.getExternalFilesDir(null)?.absolutePath + "/recorded_audio_$timestamp.wav" + context.getExternalFilesDir(null)?.absolutePath + "/${fileName}_${timestamp}.wav" currentAudioFile = File(filePath) currentAudioFile?.createNewFile() @@ -962,29 +967,35 @@ class AzureAsrHelper(private val context: Context) { ) } - internal fun closeFile() { - try { - if (fos == null || currentAudioFile == null) { - return - } - // 关闭文件流 - fos?.close() - - // 更新WAV文件头 - currentAudioFile?.let { file -> - RandomAccessFile(file, "rw").use { raf -> - raf.seek(0) - raf.write(generateWavHeader(totalBytesWritten)) - } - Log.d(tag, "音频文件保存完成: ${file.absolutePath}") - } + internal fun closeFile() { + try { + if (fos == null || currentAudioFile == null) { + return + } + // 关闭文件流 + fos?.close() - } catch (e: Exception) { - Log.e(tag, "更新WAV文件头失败: ${e.message}") - } finally { - fos = null - currentAudioFile = null + // 更新WAV文件头 + currentAudioFile?.let { file -> + // 如果文件小于44字节,直接删除 + if (file.length() <= 44) { + file.delete() + Log.d(tag, "音频文件过小已删除: ${file.absolutePath}") + } else { + RandomAccessFile(file, "rw").use { raf -> + raf.seek(0) + raf.write(generateWavHeader(totalBytesWritten)) + } + Log.d(tag, "音频文件保存完成: ${file.absolutePath}") } } + + } catch (e: Exception) { + Log.e(tag, "更新WAV文件头失败: ${e.message}") + } finally { + fos = null + currentAudioFile = null + } +} } } \ No newline at end of file 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 830a92b0b..af89d17a6 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 @@ -355,8 +355,15 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } "enableRecord" -> { + val fileName = call.argument("fileName")?: "" + try { + FileLogger.d(tag, "音频文件名称为: ${fileName}") // + azureAsrHelper.recordfile?.fileName= fileName!! azureAsrHelper.enableRecord() result.success(true) + } catch (e: Exception) { + result.error("ENABLERECORD_ERROR", e.message, null) + } } "stopRecord" -> {