Browse Source

修改录音功能

newdev_shunjiawei
fdp 1 year ago
parent
commit
a369c647bd
  1. 10
      lib/data/services/asr_service.dart
  2. 42
      lib/data/services/speech_impl/azure_asr_service.dart
  3. 16
      lib/data/services/speech_impl/volcano_asr_api_service.dart
  4. 16
      lib/data/services/speech_impl/volcano_asr_service.dart
  5. 16
      lib/data/services/speech_impl/xunfei_asr_service.dart
  6. 131
      lib/modules/meeting/controllers/meeting_controller.dart
  7. 92
      lib/modules/meeting/controllers/meeting_record_controller.dart
  8. 199
      lib/modules/meeting/views/meeting_record_view.dart
  9. 18
      lib/modules/meeting/views/meeting_view.dart
  10. 42
      lib/modules/translation/controllers/translation_controller.dart
  11. 205
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  12. 41
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  13. 251
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt

10
lib/data/services/asr_service.dart

@ -30,13 +30,19 @@ abstract class AsrService {
bool isContinuousRecognitionActive();
/// 开始录音
Future<bool> enableRecord(String fileName);
Future<bool> enableRecord(String filePath);
/// 暂停录音
Future<bool> pauseRecord();
// /// 移动文件到新路径
Future<bool> moveFile(String sourcePath, String destPath);
// /// 重命名指定路径的音频文件
Future<bool> renameFile(String filePath, String newName);
/// 结束录音
Future<bool> stopRecord();
Future<bool> stopRecord(bool isSave);
/// 释放资源
Future<void> dispose();

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

@ -344,10 +344,10 @@ class AzureAsrService extends GetxService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) async {
Future<bool> enableRecord(String filePath) async {
try {
final bool result = await _channel.invokeMethod('enableRecord', {
'fileName': fileName,
'filePath': filePath,
});
return result;
@ -358,9 +358,11 @@ class AzureAsrService extends GetxService implements AsrService {
}
@override
Future<bool> stopRecord() async {
Future<bool> stopRecord(bool isSave) async {
try {
final bool result = await _channel.invokeMethod('stopRecord');
final bool result = await _channel.invokeMethod('stopRecord', {
'isSave': isSave,
});
return result;
} catch (e) {
@ -380,4 +382,36 @@ class AzureAsrService extends GetxService implements AsrService {
rethrow;
}
}
@override
Future<bool> moveFile(String sourcePath, String destPath) async {
// TODO: implement moveFile
try {
final bool result = await _channel.invokeMethod('moveFile', {
'sourcePath': sourcePath,
'destPath': destPath,
});
return result;
} catch (e) {
Logger.error('开始录音: ${e.toString()}');
rethrow;
}
}
@override
Future<bool> renameFile(String filePath, String newName) async {
// TODO: implement renameFile
try {
final bool result = await _channel.invokeMethod('renameFile', {
'filePath': filePath,
'newName': newName,
});
return result;
} catch (e) {
Logger.error('开始录音: ${e.toString()}');
rethrow;
}
}
}

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

@ -813,13 +813,13 @@ class VolcanoAsrApiService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) {
Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord
throw UnimplementedError();
}
@ -829,4 +829,16 @@ class VolcanoAsrApiService implements AsrService {
// TODO: implement pauseRecord
throw UnimplementedError();
}
@override
Future<bool> moveFile(String sourcePath, String destPath) {
// TODO: implement moveFile
throw UnimplementedError();
}
@override
Future<bool> renameFile(String filePath, String newName) {
// TODO: implement renameFile
throw UnimplementedError();
}
}

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

@ -348,13 +348,13 @@ class VolcanoAsrService extends GetxService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) {
Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord
throw UnimplementedError();
}
@ -364,4 +364,16 @@ class VolcanoAsrService extends GetxService implements AsrService {
// TODO: implement pauseRecord
throw UnimplementedError();
}
@override
Future<bool> moveFile(String sourcePath, String destPath) {
// TODO: implement moveFile
throw UnimplementedError();
}
@override
Future<bool> renameFile(String filePath, String newName) {
// TODO: implement renameFile
throw UnimplementedError();
}
}

16
lib/data/services/speech_impl/xunfei_asr_service.dart

@ -272,13 +272,13 @@ class XunfeiAsrService extends GetxService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) {
Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord
throw UnimplementedError();
}
@ -288,4 +288,16 @@ class XunfeiAsrService extends GetxService implements AsrService {
// TODO: implement pauseRecord
throw UnimplementedError();
}
@override
Future<bool> moveFile(String sourcePath, String destPath) {
// TODO: implement moveFile
throw UnimplementedError();
}
@override
Future<bool> renameFile(String filePath, String newName) {
// TODO: implement renameFile
throw UnimplementedError();
}
}

131
lib/modules/meeting/controllers/meeting_controller.dart

@ -28,7 +28,7 @@ class MeetingController extends GetxController {
int addQuantity = 0;
List<String> selected = [];
final appDocDir = getApplicationSupportDirectory();
final appDocDir = getApplicationDocumentsDirectory();
// final directory = Directory(
// '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files');
List<FileSystemEntity> files = [];
@ -56,6 +56,7 @@ class MeetingController extends GetxController {
'Content-Type': 'application/json',
};
_getDataList();
addMeetingAudio();
}
@override
@ -127,22 +128,34 @@ class MeetingController extends GetxController {
}
void loadTranslatAudio() async {
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/TranslatAudio");
print("appDocDir=${dir?.path}");
try {
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/TranslatAudio");
print("appDocDir=${dir?.path}");
// Create directory if it doesn't exist
if (!await dir.exists()) {
await dir.create(recursive: true); // Create directory tree if needed
files = []; // No files exist in new directory
return;
}
files = dir
.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();
// Directory exis
files = dir
.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();
} catch (e) {
print("Error loading files: $e");
files = []; // Return empty list on error
}
}
// 导入音频文件
@ -166,6 +179,92 @@ class MeetingController extends GetxController {
}
}
void addMeetingAudio() async {
EasyLoading.show(status: '正在导入本地录音...');
try {
final appDir = await appDocDir;
final sourceDir = Directory("${appDir.path}/MeetingAudio/");
// 检查源目录是否存在
if (!await sourceDir.exists()) {
EasyLoading.dismiss();
return;
}
// 创建目标目录(如果不存在)
final destDir = Directory("${appDir.path}/LocalAudio/");
if (!await destDir.exists()) {
await destDir.create(recursive: true);
}
// 获取目录下所有文件
final files =
await sourceDir.list().where((entity) => entity is File).toList();
int addQuantity = 0;
for (final fileEntity in files) {
try {
final file = File(fileEntity.path);
final filePath = file.path;
// 设置音频源获取时长
final _audioPlayer = AudioPlayer();
await _audioPlayer.setAudioSource(AudioSource.file(filePath));
int seconds = _audioPlayer.duration?.inSeconds ?? 0;
// 获取文件修改时间
final fileStat = await file.stat();
final creationTime = fileStat.changed.millisecondsSinceEpoch;
// 上传OSS
final audiourl =
await UploadOss.upload(filepath: filePath, rootDir: 'LocalAudio');
// 准备新路径
final fileName = filePath.split('/').last;
final newPath = "${destDir.path}/$fileName";
// 构建数据
Map<String, Object?> data = {
'title': fileName,
'type': 'LOCAL',
'seconds': seconds,
'filepath': newPath, // 存储新路径
'audiourl': audiourl,
'tasktype': 0,
'creationtime': creationTime,
};
print("导入数据: $data");
// 插入数据库
final meetingId = await SqfliteApi.insertMeeting(data);
if (meetingId != null) {
// 更新UI数据
dataList.insert(0, {
'id': meetingId,
'taskid': '',
...data,
});
// 移动文件
await file.copy(newPath);
await file.delete();
addQuantity++;
}
} catch (e) {
print("文件处理失败: ${fileEntity.path}, 错误: $e");
} finally {
await _audioPlayer.dispose(); // 释放播放器资源
}
}
if (addQuantity > 0) EasyLoading.showSuccess('成功导入本地 $addQuantity 条录音');
} catch (e) {
EasyLoading.showError('导入失败: $e');
} finally {
EasyLoading.dismiss();
}
}
void addTranslatAudio() async {
EasyLoading.show(status: '正在批量导入...');
for (final filePath in selected) {

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

@ -1,9 +1,12 @@
// meeting_record_controller.dart
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/ble_manager.dart';
@ -46,24 +49,66 @@ class MeetingRecordController extends GetxController
int _elapsedMilliseconds = 0; // 累计毫秒数
int? _lastStartTime; // 上次开始计时的时间戳
final newName = "".obs;
final fileName = "初始文件名".obs;
final isEditingTitle = false.obs;
late TextEditingController titleEditingController;
late FocusNode titleFocusNode;
final appDocDir = getApplicationDocumentsDirectory();
@override
void onInit() {
super.onInit();
_asrService.initialize();
tabController = TabController(length: 2, vsync: this);
tabController = TabController(length: 2, vsync: this); // Add this line
titleEditingController = TextEditingController(text: fileName.value);
titleFocusNode = FocusNode();
// 监听焦点变化
titleFocusNode.addListener(() {
if (!titleFocusNode.hasFocus && isEditingTitle.value) {
saveTitleChange();
}
});
final Map<String, dynamic> args = Get.arguments ?? {};
audioTypes.value = args['audioTypes'];
// 蓝牙处理
if (audioTypes.value == 0) {
fileName.value = "现场录音";
} else if (audioTypes.value == 1) {
fileName.value = "音、视频录音";
} else if (audioTypes.value == 2) {
fileName.value = "通话录音";
}
newName.value = fileName.value;
generateWaveData();
// 移除初始化的 startTimer() 调用
}
@override
void onClose() {
_timer?.cancel();
_asrService.dispose();
tabController.dispose();
titleEditingController.dispose();
titleFocusNode.dispose();
super.onClose();
}
void startTitleEditing() {
isEditingTitle.value = true;
titleEditingController.text = fileName.value;
newName.value = fileName.value;
// 延迟请求焦点确保TextField已渲染
Future.delayed(Duration(milliseconds: 100), () {
titleFocusNode.requestFocus();
});
}
void saveTitleChange() {
if (isEditingTitle.value) {
newName.value = titleEditingController.text;
isEditingTitle.value = false;
// 这里添加实际的文件重命名逻辑
// 例如: renameFile(fileName.value);
}
}
/// 启动/继续计时器
void startTimer() {
if (timerStatus.value != TimerStatus.running) {
@ -195,16 +240,29 @@ class MeetingRecordController extends GetxController
_recognitionSubscription?.cancel();
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
await _asrService.enableRecord("录音");
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) {
await dir.create(recursive: true); // Add recursive creation
}
print("appDocDir=${dir?.path}");
// 使用可读性更好的时间格式
final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
await _asrService
.enableRecord("${dir?.path}/${fileName.value}_$formattedTime.wav");
fileName.value = "${fileName.value}_$formattedTime";
newName.value = fileName.value;
titleEditingController.text = fileName.value;
startTimer();
// 蓝牙处理
if (audioTypes == 0) {
if (audioTypes.value == 0) {
_bleManager.openEncoder();
} else if (audioTypes == 1) {
} else if (audioTypes.value == 1) {
_bleManager.openDecoder();
} else if (audioTypes == 2) {
} else if (audioTypes.value == 2) {
_bleManager.openA2DPDecoder();
}
}
@ -225,8 +283,6 @@ class MeetingRecordController extends GetxController
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
startTimer();
//await _asrService.resumeRecognition(); // 假设ASR服务有继续方法
}
/// 切换是否标记为重点
@ -247,8 +303,14 @@ class MeetingRecordController extends GetxController
Future<void> saveRecording() async {
resetTimer(); // 重置计时器
_bleManager.closeCodec();
await _asrService.stopRecord();
await _asrService.stopRecord(true);
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) {
await dir.create(recursive: true); // Add recursive creation
}
await _asrService.renameFile(
"${dir.path}/${fileName.value}.wav", newName.value);
await _asrService.stopContinuousRecognition();
Get.back();
}
}

199
lib/modules/meeting/views/meeting_record_view.dart

@ -3,26 +3,79 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import '../controllers/meeting_record_controller.dart';
import '../../../core/theme/app_colors.dart';
class MeetingRecordView extends GetView<MeetingRecordController> {
const MeetingRecordView({super.key});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
// 在控制器中添加编辑状态管理
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text('新录音 29', style: TextStyle(fontSize: 18)), // 更新为29
title: Obx(() {
if (controller.isEditingTitle.value) {
return SizedBox(
height: 40,
child: TextField(
controller: controller.titleEditingController,
focusNode: controller.titleFocusNode,
autofocus: true,
style: TextStyle(
fontSize: 18,
color: isDarkMode ? Colors.white : Colors.black,
),
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
hintText: '输入文件名',
hintStyle: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (value) {
controller.saveTitleChange();
},
),
);
} else {
return GestureDetector(
onTap: () {
controller.startTitleEditing();
},
child: Text(
controller.newName.value,
style: const TextStyle(fontSize: 18),
),
);
}
}),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
foregroundColor: isDarkMode ? Colors.white : Colors.black,
actions: [
Obx(() {
if (controller.isEditingTitle.value) {
return IconButton(
icon: const Icon(Icons.check),
onPressed: controller.saveTitleChange,
);
}
return const SizedBox.shrink();
}),
],
),
body: Column(
children: [
TabBar(
controller: controller.tabController,
indicatorColor: Colors.red,
labelColor: Colors.black,
labelColor: isDarkMode ? Colors.white : Colors.black,
unselectedLabelColor: Colors.grey,
tabs: const [
Tab(text: '音频'),
@ -33,8 +86,8 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
child: TabBarView(
controller: controller.tabController,
children: [
_buildAudioTab(context),
_buildTextTab(context),
_buildAudioTab(context, isDarkMode),
_buildTextTab(context, isDarkMode),
],
),
),
@ -43,7 +96,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
);
}
Widget _buildAudioTab(BuildContext context) {
Widget _buildAudioTab(BuildContext context, bool isDarkMode) {
return Obx(() => Column(
children: [
const SizedBox(height: 24),
@ -77,13 +130,13 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
),
const Spacer(),
_buildBottomBar(context),
_buildBottomBar(context, isDarkMode),
const SizedBox(height: 32),
],
));
}
Widget _buildTextTab(BuildContext context) {
Widget _buildTextTab(BuildContext context, bool isDarkMode) {
return Obx(() => Column(
children: [
const SizedBox(height: 48),
@ -139,16 +192,18 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
),
const Spacer(),
_buildBottomBar(context),
_buildBottomBar(context, isDarkMode),
const SizedBox(height: 32),
],
));
}
Widget _buildBottomBar(BuildContext context) {
// 底部操作栏
Widget _buildBottomBar(BuildContext context, bool isDarkMode) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 收藏按钮
GestureDetector(
onTap: controller.toggleMarked,
child: Obx(() => CircleAvatar(
@ -162,6 +217,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
)),
),
// 录音按钮
GestureDetector(
onTap: controller.toggleRecording,
child: Obx(() => CircleAvatar(
@ -176,8 +232,9 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
)),
),
// 完成按钮
GestureDetector(
onTap: controller.saveRecording,
onTap: () => _bottomSheet(context, isDarkMode), // 改为匿名函数
child: const CircleAvatar(
radius: 32,
backgroundColor: Colors.white,
@ -191,6 +248,124 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
],
);
}
// 底部编辑弹窗
void _bottomSheet(BuildContext context, bool isDarkMode) {
Get.bottomSheet(
Padding(
padding: MediaQuery.of(context).viewInsets,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.darkBackground : Colors.grey[50],
borderRadius: BorderRadius.vertical(top: Radius.circular(20.w)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题
Padding(
padding: EdgeInsets.only(bottom: 20.w),
child: Text(
'editMeetingTitle'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
),
// 输入框
Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.white10 : Colors.white,
borderRadius: BorderRadius.circular(12.w),
),
child: TextField(
controller: controller.titleEditingController,
autofocus: true, // 自动弹出键盘
maxLength: 50, // 添加字符限制
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 16.sp,
),
decoration: InputDecoration(
hintText: 'maxCharacters'.tr,
hintStyle: TextStyle(
color: isDarkMode ? Colors.white60 : Colors.black45,
),
border: InputBorder.none,
contentPadding: EdgeInsets.all(16.w),
counterText: '', // 隐藏默认计数器
),
),
),
// 操作按钮
Padding(
padding: EdgeInsets.only(top: 30.w),
child: Row(
children: [
// 取消按钮
Expanded(
child: TextButton(
onPressed: () {
Get.back;
},
style: TextButton.styleFrom(
foregroundColor:
isDarkMode ? AppColors.primary : Colors.blue,
padding: EdgeInsets.symmetric(vertical: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
side: BorderSide(
color:
isDarkMode ? AppColors.primary : Colors.blue,
width: 2.w,
),
),
),
child: Text('cancel'.tr),
),
),
SizedBox(width: 20.w),
// 确认按钮
Expanded(
child: TextButton(
onPressed: () async {
// 先执行保存标题修改
controller.saveTitleChange();
// 然后执行保存录音
await controller.saveRecording();
// 最后关闭弹窗
Get.back();
},
style: TextButton.styleFrom(
backgroundColor:
isDarkMode ? AppColors.primary : Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
),
),
child: Text('confirm'.tr),
),
),
],
),
),
],
),
),
),
isScrollControlled: true, // 防止键盘遮挡
backgroundColor: Colors.transparent, // 移除默认背景
);
}
}
class WaveformPainter extends CustomPainter {

18
lib/modules/meeting/views/meeting_view.dart

@ -3,13 +3,11 @@ 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 'meeting_record_view.dart';
class MeetingView extends GetView<MeetingController> {
const MeetingView({super.key});
@ -563,10 +561,12 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '音/视频录音',
title: '现场录音',
onTap: () {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 0,
});
// TODO: 实现从文件导入逻辑
},
),
@ -576,10 +576,12 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '现场录音',
title: '音、视频录音',
onTap: () {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 1,
});
// TODO: 实现从相册导入逻辑
},
@ -604,7 +606,9 @@ class MeetingView extends GetView<MeetingController> {
title: '通话录音',
onTap: () {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 2,
});
// TODO: 实现从其他APP导入逻辑
},
),

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

@ -1,8 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'dart:async';
import 'dart:convert';
import 'package:get_storage/get_storage.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../../data/services/volcano_translation_service.dart';
import '../../../data/services/tts_service.dart';
import '../../../data/services/language_manager.dart';
@ -46,14 +50,13 @@ class TranslationController extends GetxController {
// 翻译历史和当前项
final translationHistory = <TranslationItem>[].obs;
String? currentSessionId;
final appDocDir = getApplicationDocumentsDirectory();
var appDir;
var dir;
Future<void> changeTranslationMode(String mode) async {
currentMode.value = mode;
currentModeTitle.value = _getModeTitle(mode);
// 这里添加具体模式切换逻辑
if (isRecording.value) {
await _asrService.enableRecord(currentModeTitle.value.tr);
}
stopAll();
}
@ -176,7 +179,11 @@ class TranslationController extends GetxController {
await _asrService.initialize(
supportedLanguages: asrSupportedLanguages,
);
appDir = await appDocDir; // 先 await 获取目录
dir = Directory("${appDir.path}/TranslatAudio");
if (!await dir.exists()) {
await dir.create(recursive: true); // Add recursive creation
}
Logger.info('所有服务初始化完成');
} catch (e) {
Logger.error('初始化服务失败: ${e.toString()}');
@ -199,11 +206,15 @@ class TranslationController extends GetxController {
// 切换录音功能
Future<void> toggleisRecord() async {
isRecording.toggle();
if (isRecording.value) {
await _asrService.enableRecord(currentModeTitle.value.tr);
} else {
await _asrService.stopRecord();
}
if (isRecording.value && isRecognizing.value) {
// 使用可读性更好的时间格式
final formattedTime =
DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
await _asrService.enableRecord(
"${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav");
} else if (!isRecording.value) {
await _asrService.stopRecord(true);
} else {}
}
// 开始语音识别
@ -262,7 +273,12 @@ class TranslationController extends GetxController {
final recognitionStream =
await _asrService.startContinuousRecognition(_audioSourceType);
isRecognizing.value = true;
if (isRecording.value && isRecognizing.value) {
final formattedTime =
DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
await _asrService.enableRecord(
"${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav");
}
// 监听识别结果
_recognitionSubscription?.cancel();
_recognitionSubscription =
@ -500,7 +516,7 @@ class TranslationController extends GetxController {
if (isRecognizing.value) {
await stopRecognition();
}
await _asrService.stopRecord(true);
_translationDebounceTimer?.cancel();
await _ttsService.stop();
} catch (e) {

205
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt

@ -24,15 +24,8 @@ import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import com.yunqiinnovation.azure_speech.tools.RecordFile
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import android.os.Handler
import android.os.HandlerThread
/**
* Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别
@ -70,8 +63,8 @@ class AzureAsrHelper(private val context: Context) {
private var originalAudioMode = AudioManager.MODE_NORMAL
// 录音文件处理
var recordfile: RecordingFile? = null
private var isRecording = false
var recordfile: RecordFile? = null
/**
* 音频来源类型
@ -153,7 +146,7 @@ class AzureAsrHelper(private val context: Context) {
setProperty("Speech_SegmentationStrategy", "Time")
}
// 录音文件类
recordfile = RecordingFile()
recordfile = RecordFile;
// 创建识别器
val setupSuccess = setupRecognizer()
if (setupSuccess) {
@ -360,10 +353,7 @@ class AzureAsrHelper(private val context: Context) {
// 开始连续识别
recognizer?.startContinuousRecognitionAsync()
isContinuousRecognitionActive = true
Log.e(tag, "开始连续语音识别 是否录音${isRecording}")
if (isRecording) {
enableRecord()
}
return true
} catch (e: Exception) {
stopAudioProcessing()
@ -510,7 +500,7 @@ class AzureAsrHelper(private val context: Context) {
// 停止音频处理
stopAudioProcessing()
// 停止录音
recordfile!!.closeFile()
recordfile!!.closeFile(true)
// 释放recognizer
recognizer?.close()
recognizer = null
@ -522,7 +512,7 @@ class AzureAsrHelper(private val context: Context) {
// 释放音频配置
audioConfig?.close()
audioConfig = null
isRecording = false
// 确保状态被重置
isContinuousRecognitionActive = false
microphoneStream = null
@ -532,7 +522,7 @@ class AzureAsrHelper(private val context: Context) {
speechConfig = null
} catch (e: Exception) {
// 确保状态被重置
isRecording = false
isContinuousRecognitionActive = false
microphoneStream = null
externalAudioStream = null
@ -588,41 +578,55 @@ class AzureAsrHelper(private val context: Context) {
/**
* 开启录音
*/
fun enableRecord() {
fun enableRecord(filePath: String) {
Log.i(tag, "开启录音:")
if( recordfile!!.isPause)
{
recordfile!!.isPause=false
}
else{
recordfile!!.closeFile()
isRecording = true
if (isContinuousRecognitionActive) {
recordfile!!.creatingFiles()
}
}
recordfile!!.closeFile(true)
recordfile!!.creatingFiles(filePath)
}
/**
* 移动文件到新路径
*/
fun moveFile(sourcePath: String, destPath: String): Boolean {
Log.i(tag, "移动文件到新路径:")
recordfile!!.moveFile(sourcePath,destPath)
return true
}
/**
* 重命名指定路径的音频文件
*/
fun renameFile(filePath: String, newName: String): Boolean {
Log.i(tag, "重命名指定路径的音频文件:")
recordfile!!.renameFile(filePath,newName)
return true
}
/**
* 停止录音
*/
fun pauseRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=true
}
/**
* 停止录音
* 关闭录音
*/
fun stopRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
fun stopRecord(isSave:Boolean) {
Log.i(tag, "关闭录音:")
recordfile!!.isPause=false
recordfile!!.closeFile()
recordfile!!.closeFile(isSave)
}
/**
@ -941,133 +945,4 @@ else{
}
inner class RecordingFile {
private var currentAudioFile: File? = null
private var fos: FileOutputStream? = null
// 新增:用于异步写入的队列和线程
private val writeQueue = LinkedBlockingQueue<ByteArray>()
private val isWriting = AtomicBoolean(false)
private var writeThread: Thread? = null
// 用于存储音频数据的缓冲区
private val dataBuffer = mutableListOf<ByteArray>()
private var totalBytesWritten = 0
// 音频配置
private val sampleRate = 16000
var isPause=false
var fileName = ""
internal fun creatingFiles() {
if (fos != null || currentAudioFile != null) {
return
}
// 创建新的音频文件
val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss")
val timestamp = dateFormat.format(Date())
val parentDir = File(context.filesDir, "TranslatAudio")
if (!parentDir.exists()) {
parentDir.mkdirs()
}
val filePath = File(parentDir, "${fileName}_${timestamp}.wav").absolutePath
currentAudioFile = File(filePath)
currentAudioFile?.createNewFile()
// 仅写入初始文件头
fos = FileOutputStream(currentAudioFile).apply {
write(generateWavHeader(0)) // 初始0长度
close()
}
// 追加音频数据到文件
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(tag, "异步写入音频数据失败: ${e.message}")
}
}
writeThread?.start()
}
/**
* 保存音频数据到 WAV 文件(异步)
*/
internal fun saveAudioDataToWav(buffer: ByteArray) {
if (fos == null || currentAudioFile == null) return
// 放入队列,由写线程写入
writeQueue.offer(buffer.copyOf())
totalBytesWritten += buffer.size
}
// 更新文件头生成(修正RIFF长度计算)
private fun generateWavHeader(dataLength: Int): ByteArray {
val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据
val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数
return byteArrayOf(
'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(),
(totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(),
((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(),
'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(),
'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(),
16, 0, 0, 0, // PCM头长度
1, 0, // PCM格式
1, 0, // 单声道
(sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率
(byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率
2, 0, // 块对齐 (通道数 * 样本位数/8)
16, 0, // 样本位数
'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(),
(dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(),
((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte()
)
}
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() <= 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
}
}
}
}

41
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -354,12 +354,36 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
result.success(true)
}
"enableRecord" -> {
val fileName = call.argument<String>("fileName")?: ""
"renameFile" -> {
val filePath = call.argument<String>("filePath")?: ""
val newName = call.argument<String>("newName")?: ""
try {
FileLogger.d(tag, "音频文件名称为: ${fileName}") //
azureAsrHelper.recordfile?.fileName= fileName!!
azureAsrHelper.enableRecord()
FileLogger.d(tag, "音频文件名称为: ${filePath}") //
azureAsrHelper.renameFile(filePath,newName)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
"moveFile" -> {
val sourcePath = call.argument<String>("sourcePath")?: ""
val destPath = call.argument<String>("destPath")?: ""
try {
FileLogger.d(tag, "音频文件名称为: ${sourcePath}") //
azureAsrHelper.moveFile(sourcePath,destPath)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
"enableRecord" -> {
val filePath = call.argument<String>("filePath")?: ""
try {
FileLogger.d(tag, "音频文件名称为: ${filePath}") //
azureAsrHelper.enableRecord(filePath)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
@ -370,8 +394,13 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
result.success(true)
}
"stopRecord" -> {
azureAsrHelper.stopRecord()
val isSave = call.argument<Boolean>("isSave")?:false
try {
azureAsrHelper.stopRecord(isSave)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
else -> {

251
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt

@ -0,0 +1,251 @@
package com.yunqiinnovation.azure_speech.tools
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import android.util.Log
import java.io.FileInputStream
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
object RecordFile {
private var currentAudioFile: File? = null
private var fos: FileOutputStream? = null
// 新增:用于异步写入的队列和线程
private val writeQueue = LinkedBlockingQueue<ByteArray>()
private val isWriting = AtomicBoolean(false)
private var writeThread: Thread? = null
// 用于存储音频数据的缓冲区
private val dataBuffer = mutableListOf<ByteArray>()
private var totalBytesWritten = 0
// 音频配置
private val sampleRate = 16000
// 新增:用于存储最后成功保存的文件
private var lastSavedFile: File? = null
private var lastFileTimestamp: String = ""
var isPause = false
var fileName = ""
// 修改:添加 filePath 参数
internal fun creatingFiles(filePath: String) {
if (fos != null || currentAudioFile != null) {
return
}
val file = File(filePath)
val parentDir = file.parentFile
if (!parentDir.exists()) {
parentDir.mkdirs()
}
currentAudioFile = file
currentAudioFile?.createNewFile()
fos = FileOutputStream(currentAudioFile).apply {
write(generateWavHeader(0))
close()
}
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("tag", "异步写入音频数据失败: ${e.message}")
}
}
writeThread?.start()
}
/**
* 保存音频数据到 WAV 文件(异步)
*/
internal fun saveAudioDataToWav(buffer: ByteArray) {
if (fos == null || currentAudioFile == null) return
// 放入队列,由写线程写入
writeQueue.offer(buffer.copyOf())
totalBytesWritten += buffer.size
}
// 更新文件头生成(修正RIFF长度计算)
private fun generateWavHeader(dataLength: Int): ByteArray {
val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据
val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数
return byteArrayOf(
'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(),
(totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(),
((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(),
'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(),
'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(),
16, 0, 0, 0, // PCM头长度
1, 0, // PCM格式
1, 0, // 单声道
(sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率
(byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率
2, 0, // 块对齐 (通道数 * 样本位数/8)
16, 0, // 样本位数
'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(),
(dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(),
((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte()
)
}
/**
* 重命名指定路径的音频文件
* @param filePath 要重命名的文件完整路径
* @param newName 新的文件名(不含扩展名)
* @return true 重命名成功,false 失败
*/
internal fun renameFile(filePath: String, newName: String): Boolean {
val sourceFile = File(filePath)
// 验证源文件存在
if (!sourceFile.exists()) {
Log.w("tag", "重命名失败: 文件不存在 $filePath")
return false
}
// 防止重命名正在录制的文件
if (sourceFile == currentAudioFile) {
Log.w("tag", "重命名失败: 不能重命名正在录制的文件")
return false
}
val parentDir = sourceFile.parentFile
val newFile = File(parentDir, "$newName.wav") // 保持.wav扩展名
// 处理目标文件已存在的情况
if (newFile.exists()) {
Log.w("tag", "重命名失败: 目标文件已存在 ${newFile.absolutePath}")
return false
}
// 执行重命名
return try {
val success = sourceFile.renameTo(newFile)
if (success) {
Log.d("tag", "文件重命名成功: ${sourceFile.name} -> ${newFile.name}")
// 更新最后保存的文件引用(如果是同一个文件)
if (lastSavedFile == sourceFile) {
lastSavedFile = newFile
}
} else {
Log.w("tag", "文件重命名操作失败")
}
success
} catch (e: Exception) {
Log.e("tag", "重命名异常: ${e.message}")
false
}
}
/**
* 移动文件到新路径
* @param sourcePath 原始文件完整路径
* @param destPath 目标文件完整路径
* @return true 移动成功,false 移动失败
*/
internal fun moveFile(sourcePath: String, destPath: String): Boolean {
val sourceFile = File(sourcePath)
val destFile = File(destPath)
if (!sourceFile.exists()) {
Log.w("tag", "移动失败: 源文件不存在 ${sourceFile.absolutePath}")
return false
}
// 确保目标目录存在
val destDir = destFile.parentFile
if (!destDir.exists() && !destDir.mkdirs()) {
Log.w("tag", "移动失败: 无法创建目标目录 ${destDir.absolutePath}")
return false
}
// 删除已存在的目标文件
if (destFile.exists() && !destFile.delete()) {
Log.w("tag", "移动失败: 无法覆盖已存在文件 ${destFile.absolutePath}")
return false
}
return try {
// 使用renameTo尝试移动(同分区最快)
if (sourceFile.renameTo(destFile)) {
true
} else {
// 跨分区移动:复制+删除
FileInputStream(sourceFile).use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
if (!sourceFile.delete()) {
Log.w("tag", "移动警告: 源文件删除失败 ${sourceFile.absolutePath}")
}
true
}.also {
if (it) {
Log.d("tag", "文件移动成功: ${sourcePath} -> ${destPath}")
// 更新最后保存的文件引用
if (lastSavedFile?.absolutePath == sourcePath) {
lastSavedFile = destFile
}
}
}
} catch (e: Exception) {
Log.e("tag", "文件移动异常: ${e.message}")
false
}
}
internal fun closeFile( isSave:Boolean): Boolean {
try {
if (fos == null || currentAudioFile == null) {
return false
}
isWriting.set(false)
writeThread?.join(500)
fos?.close()
currentAudioFile?.let { file ->
if (file.length() <= 44||isSave==false) {
file.delete()
Log.d("tag", "音频文件过小已删除: ${file.absolutePath}")
return false
} else {
RandomAccessFile(file, "rw").use { raf ->
raf.seek(0)
raf.write(generateWavHeader(totalBytesWritten))
}
Log.d("tag", "音频文件保存完成: ${file.absolutePath}")
// 保存成功文件引用
lastSavedFile = file
return true
}
}
} catch (e: Exception) {
Log.e("tag", "更新WAV文件头失败: ${e.message}")
return false
} finally {
fos = null
currentAudioFile = null
}
return false
}
}
Loading…
Cancel
Save