Browse Source

翻译录音文件保存优化

newdev_shunjiawei
fdp 1 year ago
parent
commit
cde6626957
  1. 6
      lib/data/services/asr_service.dart
  2. 6
      lib/data/services/speech_impl/azure_asr_service.dart
  3. 14
      lib/data/services/speech_impl/volcano_asr_api_service.dart
  4. 12
      lib/data/services/speech_impl/volcano_asr_service.dart
  5. 16
      lib/data/services/speech_impl/xunfei_asr_service.dart
  6. 436
      lib/modules/meeting/views/meeting_view.dart
  7. 15
      lib/modules/translation/controllers/translation_controller.dart
  8. 71
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  9. 7
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

6
lib/data/services/asr_service.dart

@ -29,6 +29,12 @@ abstract class AsrService {
/// 检查连续识别是否活跃
bool isContinuousRecognitionActive();
/// 停止连续语音识别
Future<bool> enableRecord(String fileName);
/// 停止连续语音识别
Future<bool> stopRecord();
/// 释放资源
Future<void> dispose();
}

6
lib/data/services/speech_impl/azure_asr_service.dart

@ -344,9 +344,11 @@ class AzureAsrService extends GetxService implements AsrService {
}
@override
Future<bool> enableRecord() async {
Future<bool> 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) {

14
lib/data/services/speech_impl/volcano_asr_api_service.dart

@ -811,4 +811,16 @@ class VolcanoAsrApiService implements AsrService {
_isContinuousRecognitionActive = false;
_isRecognitionEndRequested = false;
}
}
@override
Future<bool> enableRecord(String fileName) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
// TODO: implement stopRecord
throw UnimplementedError();
}
}

12
lib/data/services/speech_impl/volcano_asr_service.dart

@ -346,4 +346,16 @@ class VolcanoAsrService extends GetxService implements AsrService {
_isInitialized = false;
}
}
@override
Future<bool> enableRecord(String fileName) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
// TODO: implement stopRecord
throw UnimplementedError();
}
}

16
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;
}
}
}
@override
Future<bool> enableRecord(String fileName) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
// TODO: implement stopRecord
throw UnimplementedError();
}
}

436
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<MeetingController> {
const MeetingView({super.key});
@ -21,12 +23,444 @@ class MeetingView extends GetView<MeetingController> {
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<FileSystemEntity> 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<String> 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(

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

@ -47,10 +47,13 @@ class TranslationController extends GetxController {
final translationHistory = <TranslationItem>[].obs;
String? currentSessionId;
void changeTranslationMode(String mode) {
Future<void> 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<void> 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();
}
}
// 开始语音识别

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

7
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<String>("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" -> {

Loading…
Cancel
Save