Browse Source

优化会议助手,翻译不导入文件卡住

weicu
fdp 1 year ago
parent
commit
87d5fbaa93
  1. 160
      lib/modules/FTFTranslation/README.md
  2. 10
      lib/modules/FTFTranslation/bindings/FTFTranslation_binding.dart
  3. 241
      lib/modules/FTFTranslation/controllers/FTFTranslation_controller.dart
  4. 212
      lib/modules/FTFTranslation/views/FTFTranslation_view.dart
  5. 7
      lib/modules/meeting/controllers/meeting_controller.dart
  6. 5
      lib/modules/meeting/controllers/meeting_record_controller.dart
  7. 88
      lib/modules/meeting/views/meeting_view.dart
  8. 22
      lib/modules/settings/views/settings_view.dart
  9. 8
      lib/modules/translation/controllers/translation_controller.dart
  10. 4
      lib/modules/translation/views/translation_view.dart
  11. 9
      lib/routes/app_pages.dart
  12. 1
      lib/routes/app_routes.dart
  13. 129
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

160
lib/modules/FTFTranslation/README.md

@ -0,0 +1,160 @@
# Realtime 实时语音聊天模块
## ⚠️ 当前状态
**此模块为功能预览版本,提供完整的实时语音对话功能。**
通过原生插件实现语音识别、AI对话和语音合成的完整语音交互体验。
## 概述
Realtime模块实现了类似ChatGPT app的实时语音对话功能,当前版本提供:
- ✅ **完整语音交互** - 实时语音识别、AI对话和语音合成
- ✅ **状态管理** - 聆听、思考、回答状态的可视化
- ✅ **动画效果** - 基于Shader的流动云效果,支持音频振幅驱动
- ✅ **字幕显示** - 可选择开启/关闭的字幕模式
- ✅ **智能UI** - 根据连接状态自动调整按钮可用性
## 界面设计
### 布局结构
- **顶部状态区域** - 显示当前状态和字幕模式切换按钮
- **中间可视化区域** - 圆形Shader动画,根据音频振幅呈现呼吸效果
- **底部控制区域** - 主控制按钮和状态指示器
### 状态指示
- 🔵 **聆听状态** - 蓝色动画,接收用户语音输入
- 🟠 **思考状态** - 橙色动画,AI处理中
- 🟢 **回答状态** - 绿色动画,AI语音输出
- ⚪ **空闲状态** - 灰色静态,等待交互
### 字幕模式
- **默认关闭** - 初始状态下不显示任何字幕内容
- **手动切换** - 通过右上角按钮手动开启/关闭字幕
- **完整隐藏** - 关闭时字幕区域完全不显示,不占用界面空间
- **智能布局** - 开启时圆形动画自动上移,字幕区域占据下半屏
### 交互控制
- **录音按钮** - 仅在成功连接AI服务器且开始录音后可用
- **连接状态** - 未连接时录音按钮显示不可用状态
- **状态反馈** - 按钮颜色和可点击性根据连接状态动态调整
## 使用方法
### 路由导航
```dart
// 跳转到实时语音对话页面
Get.toNamed(Routes.realtime);
```
### 交互流程
1. **自动连接** - 页面加载后自动尝试连接AI服务器
2. **等待连接** - 录音按钮在连接成功前保持不可用状态
3. **开始录音** - 连接成功后自动开始录音,录音按钮变为可用
4. **语音对话** - 用户说话 → AI处理 → 语音回复
5. **字幕控制** - 可随时通过右上角按钮切换字幕显示
## 技术架构
### 控制器 (RealtimeController)
- **连接管理**: WebSocket连接到AI服务器
- **状态同步**: `isConnected`, `isListening`, `isSpeaking`
- **字幕控制**: `isSubtitleMode` 默认为false,用户手动控制
- **消息管理**: 智能去重,避免流式响应重复显示
- **音频处理**: 原生层计算RMS值,Flutter层接收并控制动画
### 视图组件
- **RealtimeView** - 主视图容器,响应式布局
- **ShaderMicFlow** - 基于GLSL Shader的流动云动画组件
- **AutoScrollList** - 自动滚动的字幕列表
### 原生集成
- **RealtimeService** - 原生音频处理和WebSocket通信
- **音频格式** - 16kHz, 1声道, 16位PCM
- **实时传输** - 低延迟音频流处理
- **RMS计算** - 原生层实时计算音频振幅
## 默认行为
### 字幕模式
- **初始状态**: 关闭 (`isSubtitleMode.value = false`)
- **显示逻辑**: 仅在用户手动开启时显示字幕内容
- **空间占用**: 关闭时字幕区域高度为0,不影响其他UI元素
- **切换效果**: 平滑动画过渡,圆形动画位置和大小同步调整
### 按钮状态
- **录音按钮**: 默认不可用,连接成功且开始录音后才可点击
- **状态指示**: 通过颜色和图标变化反映当前可用性
- **交互反馈**: 不可用时点击无效果,避免误操作
## 开发计划
### 已完成功能 ✅
- [x] 完整语音交互流程
- [x] 实时WebSocket通信
- [x] Shader流动云动画
- [x] RMS音频振幅计算
- [x] 智能UI状态管理
- [x] 字幕模式控制
- [x] 消息去重优化
### 计划增强 ⏳
- [ ] 多语言语音识别
- [ ] 语音中断检测
- [ ] 音频质量自适应
- [ ] 离线降级处理
- [ ] 语音情感识别
## 注意事项
1. **网络依赖** - 需要连接到AI服务器 (ws://192.168.1.11:8000/ws)
2. **麦克风权限** - 需要获取麦克风使用权限
3. **音频会话** - 自动管理音频会话,支持蓝牙设备
4. **资源管理** - 页面关闭时自动清理音频资源和网络连接
## 自定义配置
### 修改服务器地址
```dart
// 在RealtimeController中修改连接URL
await _realtimeService.initialize(
serverUrl: 'ws://your-server:port/ws',
// ... 其他参数
);
```
### 调整UI布局
```dart
// 修改字幕模式下圆形动画位置
alignment: controller.isSubtitleMode.value
? const Alignment(0, -0.9) // 调整Y轴位置
: const Alignment(0, -0.25),
```
### 音频参数调优
```dart
// 在初始化时调整音频参数
sampleRate: 16000, // 采样率
channels: 1, // 声道数
bitsPerSample: 16, // 位深度
```
## 技术栈
- **Flutter SDK**: >= 3.0.0
- **GetX**: >= 4.6.5 (状态管理)
- **Flutter Shaders**: >= 0.0.6 (Shader动画)
- **原生插件**: Realtime服务 (音频处理)
- **WebSocket**: 实时通信协议
## 版本信息
- **当前版本**: v1.0.0 (Production)
- **最后更新**: 2024年
- **状态**: 完整功能实现

10
lib/modules/FTFTranslation/bindings/FTFTranslation_binding.dart

@ -0,0 +1,10 @@
import 'package:get/get.dart';
import '../controllers/FTFTranslation_controller.dart';
class FTFTranslationBinding extends Bindings {
@override
void dependencies() {
// 注册实时语音聊天控制器
Get.lazyPut<FTFTranslationController>(() => FTFTranslationController());
}
}

241
lib/modules/FTFTranslation/controllers/FTFTranslation_controller.dart

@ -0,0 +1,241 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:get/get.dart';
import '../../../core/utils/logger.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/speech_impl/xunfei_asr_service.dart';
import '../../../data/services/tts_service.dart';
import '../../../data/services/language_manager.dart';
import '../../../data/services/volcano_translation_service.dart';
class FTFTranslationController extends GetxController
with GetSingleTickerProviderStateMixin {
final messages = <FTFTranslationMessage>[].obs;
final isChineseInput = true.obs;
final leftLanguage = '中文'.obs;
final rightLanguage = 'English'.obs;
final isLeftInput = true.obs;
final supportedLanguages = const [
'中文',
'English',
'日本語',
'한국어',
'Español',
];
late final AsrService _asrService;
StreamSubscription? _recognitionSubscription;
RxString intermediateContent = ''.obs;
RxString finalContent = ''.obs;
BytesBuilder _originaBytes = BytesBuilder(); //原始音频
final VolcanoTranslationService _translationService =
Get.find<VolcanoTranslationService>();
final LanguageManager _languageManager = Get.find<LanguageManager>();
// 当前使用的TTS服务
late TtsService _ttsService;
final isTranslating = false.obs;
final sourceLanguageCode = 'zh-CN'.obs;
final targetLanguageCode = 'en-US'.obs;
final isTtsEnabled = true.obs;
@override
void onInit() {
super.onInit();
Get.put(XunfeiAsrService());
final TtsService _ttsService = Get.find<TtsService>();
_asrService = Get.find<XunfeiAsrService>();
}
@override
void onClose() {
_asrService.dispose();
_recognitionSubscription?.cancel();
super.onClose();
}
//开始录音
Future<void> _startRecorder() async {
final recognitionStream =
await _asrService.startContinuousRecognition(false);
_recognitionSubscription = recognitionStream.listen(
_handleRecognitionEvent,
);
}
//结束录音
Future<void> _stopRecorder() async {
await _asrService.stopContinuousRecognition();
}
// 处理语音识别事件
void _handleRecognitionEvent(RecognitionEvent event) {
switch (event.type) {
case RecognitionEventType.intermediateResult:
intermediateContent.value = event.text;
handleFinalResult(event.text);
break;
case RecognitionEventType.finalResult:
finalContent.value += _spliceText(event);
intermediateContent.value = '';
_originaBytes.add(event.audio!);
handleIntermediateResult(event.text);
break;
default:
// 不做任何处理
break;
}
}
// 翻译文本
Future<String?> translateText(String sourceText,
{bool isFinal = false}) async {
if (sourceText.isEmpty) return null;
isTranslating.value = true;
String? translationResult;
try {
// 根据检测到的语言确定源语言和目标语言
final bool shouldSwap =
intermediateContent.value == sourceLanguageCode.value;
final detectedSourceLanguageCode =
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value;
final detectedTargetLanguageCode =
shouldSwap ? targetLanguageCode.value : sourceLanguageCode.value;
// 调用翻译服务
translationResult = await _translationService.translateText(
text: sourceText,
sourceLanguageCode: detectedSourceLanguageCode,
targetLanguageCode: detectedTargetLanguageCode,
);
if (translationResult != null) {
// 查找匹配的项目 - 优先使用timestamp查找
int index = -1;
// 播放TTS - 只在最终结果时播放
if (isTtsEnabled.value && isFinal) {
// Logger.info('播放翻译文本: ${translationResult}');
await playTranslatedText(translationResult);
}
}
} catch (e) {
Logger.error('翻译失败: ${e.toString()}');
} finally {
isTranslating.value = false;
}
return translationResult;
}
// 处理中间识别结果
void handleIntermediateResult(String text) {
if (text.isEmpty) return;
translateText(text, isFinal: false);
}
// 处理最终识别结果
Future<void> handleFinalResult(String text) async {
if (text.isEmpty) return;
// 使用项目的时间戳进行翻译
translateText(text, isFinal: true);
}
// 播放翻译文本
Future<void> playTranslatedText(String text) async {
if (text.isEmpty) return;
try {
final bool shouldSwap =
intermediateContent.value == targetLanguageCode.value;
final detectedTargetLanguageCode =
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value;
final voiceName = _languageManager
.getTtsVoiceNameByAsrCode(detectedTargetLanguageCode) ??
'en-US-AriaNeural';
await _ttsService.setVoice(voiceName);
await _ttsService.speakOnce(text);
} catch (e) {
Logger.error('播放翻译失败: ${e.toString()}');
}
}
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;
}
void addMessage(String text) {
final fromLang =
isLeftInput.value ? leftLanguage.value : rightLanguage.value;
final toLang = isLeftInput.value ? rightLanguage.value : leftLanguage.value;
messages.add(
FTFTranslationMessage(
original: text,
translated: '[$fromLang → $toLang] $text (翻译模拟)',
isChineseSpeaker: isLeftInput.value,
),
);
}
void swapLanguages() {
final temp = leftLanguage.value;
leftLanguage.value = rightLanguage.value;
rightLanguage.value = temp;
isLeftInput.value = !isLeftInput.value;
}
void toggleInputSide(bool isLeft) => isLeftInput.value = isLeft;
}
class FTFTranslationMessage {
final String original;
final String translated;
final bool isChineseSpeaker;
FTFTranslationMessage({
required this.original,
required this.translated,
required this.isChineseSpeaker,
});
}

212
lib/modules/FTFTranslation/views/FTFTranslation_view.dart

@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/FTFTranslation_controller.dart';
class FTFTranslationView extends GetView<FTFTranslationController> {
const FTFTranslationView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final TextEditingController inputController = TextEditingController();
return Scaffold(
backgroundColor: const Color(0xFF0E0F12),
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text('面对面翻译'),
centerTitle: true,
),
body: Column(
children: [
Expanded(
child: Obx(() => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.messages.length,
itemBuilder: (context, index) {
final msg = controller.messages[index];
final isChinese = msg.isChineseSpeaker;
return Align(
alignment: isChinese
? Alignment.centerLeft
: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isChinese ? Colors.blue : Colors.grey[800],
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(maxWidth: 300),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
msg.original,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
msg.translated,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 14,
),
),
],
),
),
);
},
)),
),
_buildBottomInput(inputController),
],
),
);
}
Widget _buildBottomInput(TextEditingController inputController) {
return Obx(() {
final isLeftInput = controller.isLeftInput.value;
final inputLang = isLeftInput
? controller.leftLanguage.value
: controller.rightLanguage.value;
return Container(
color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: inputController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey[900],
hintText: '请输入 $inputLang 内容',
hintStyle: const TextStyle(color: Colors.grey),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
),
),
),
IconButton(
onPressed: () {
final text = inputController.text.trim();
if (text.isNotEmpty) {
controller.addMessage(text);
inputController.clear();
}
},
icon: const Icon(Icons.send, color: Colors.white),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildLanguageDropdown(
selected: controller.leftLanguage.value,
items: controller.supportedLanguages,
onChanged: (value) {
if (value != null) controller.leftLanguage.value = value;
},
icon: Icons.headphones,
isSelected: isLeftInput,
label: '左方语言',
onTapInputSide: () => controller.toggleInputSide(true),
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: controller.swapLanguages,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white10,
shape: BoxShape.circle,
),
child:
const Icon(Icons.compare_arrows, color: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildLanguageDropdown(
selected: controller.rightLanguage.value,
items: controller.supportedLanguages,
onChanged: (value) {
if (value != null) controller.rightLanguage.value = value;
},
icon: Icons.smartphone,
isSelected: !isLeftInput,
label: '右方语言',
onTapInputSide: () => controller.toggleInputSide(false),
),
),
],
),
],
),
);
});
}
Widget _buildLanguageDropdown({
required String selected,
required List<String> items,
required void Function(String?) onChanged,
required IconData icon,
required bool isSelected,
required String label,
required VoidCallback onTapInputSide,
}) {
return GestureDetector(
onTap: onTapInputSide,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
border: Border.all(
color: isSelected ? Colors.white : Colors.grey,
),
borderRadius: BorderRadius.circular(24),
color: isSelected ? Colors.white10 : Colors.transparent,
),
child: Row(
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<String>(
dropdownColor: Colors.grey[900],
value: selected,
isExpanded: true,
underline: const SizedBox(),
iconEnabledColor: Colors.white,
style: const TextStyle(color: Colors.white),
items: items.map((lang) {
return DropdownMenuItem(
value: lang,
child: Text(lang),
);
}).toList(),
onChanged: onChanged,
),
),
],
),
),
);
}
}

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

@ -14,6 +14,7 @@ import 'package:permission_handler/permission_handler.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/synchrodata.dart';
import '../../../core/utils/upload_oss.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/db/sqflite_api.dart';
import '../../../core/utils/permission_util.dart';
@ -37,7 +38,8 @@ class MeetingController extends GetxController {
TextEditingController titleController = TextEditingController(); // 录音标题
RxList dataList = [].obs;
// 蓝牙服务
final bleManager = Get.find<BleManager>();
List dataListTask = [];
bool isDisposed = false;
String filePath = '';
@ -313,6 +315,9 @@ class MeetingController extends GetxController {
PlatformFile file = result.files.first;
filePath = await _saveFile(file);
fileName = file.name;
} else {
EasyLoading.dismiss();
return;
}
}

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

@ -113,12 +113,15 @@ class MeetingRecordController extends GetxController
switch (audioType.value) {
case 0:
fileName.value = "现场录音";
_audioSourceType = false;
break;
case 1:
fileName.value = "音、视频录音";
_audioSourceType = true;
break;
case 2:
fileName.value = "通话录音";
_audioSourceType = true;
break;
}
newName.value = fileName.value;
@ -234,7 +237,7 @@ class MeetingRecordController extends GetxController
void _startHardwareServices() {
switch (audioType.value) {
case 0:
_bleManager.openEncoder();
// _bleManager.openEncoder();
break;
case 1:
_bleManager.openDecoder();

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

@ -454,43 +454,43 @@ class MeetingView extends GetView<MeetingController> {
// TODO: 实现从文件导入逻辑
},
),
SizedBox(height: 8.w),
_importAudioItem1(
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),
_importAudioItem1(
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: '从opus导出',
onTap: () {
Navigator.of(context).pop();
// TODO: 实现从其他APP导入逻辑
_showLocalOpusPicker(context, isDarkMode);
},
),
// SizedBox(height: 8.w),
// _importAudioItem1(
// 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),
// _importAudioItem1(
// 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: '从opus导出',
// onTap: () {
// Navigator.of(context).pop();
// // TODO: 实现从其他APP导入逻辑
// _showLocalOpusPicker(context, isDarkMode);
// },
// ),
],
),
),
@ -578,13 +578,12 @@ class MeetingView extends GetView<MeetingController> {
SizedBox(height: 8.w),
// 音、视频录音 - 根据控制器状态禁用
_importAudioItem(
context,
isDarkMode,
context, isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '音、视频录音',
enabled: false, // 依赖控制器状态
onTap: false
enabled: controller.bleManager.isConnected, // 依赖控制器状态
onTap: controller.bleManager.isConnected
? () async {
// 条件启用
Navigator.of(context).pop();
@ -599,14 +598,13 @@ class MeetingView extends GetView<MeetingController> {
SizedBox(height: 8.w),
// 通话录音 - 根据控制器状态禁用
_importAudioItem(
context,
isDarkMode,
context, isDarkMode,
icon: Row(
// ... 图标组合保持不变
),
title: '通话录音',
enabled: false, // 依赖控制器状态
onTap: false
enabled: controller.bleManager.isConnected, // 依赖控制器状态
onTap: controller.bleManager.isConnected
? () async {
// 条件启用
Navigator.of(context).pop();

22
lib/modules/settings/views/settings_view.dart

@ -543,6 +543,28 @@ class SettingsView extends GetView<SettingsController> {
},
isDarkMode: isDarkMode,
),
Divider(
height: 1,
color: isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
// 面对面翻译测试
_buildSimpleNavigationSetting(
title: '面对面翻译测试',
subtitle: '测试面对面翻译',
icon: Icons.bluetooth_searching,
iconBgColor: isDarkMode
? Colors.green[900]!.withOpacity(0.3)
: Colors.green[100]!,
iconColor:
isDarkMode ? Colors.green[300]! : Colors.green[600]!,
titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null,
onTap: () {
Get.toNamed(Routes.ftftranslation);
},
isDarkMode: isDarkMode,
),
Divider(
height: 1,
color: isDarkMode

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

@ -26,7 +26,7 @@ class TranslationController extends GetxController {
final LanguageManager _languageManager = Get.find<LanguageManager>();
final GetStorage _storage = GetStorage();
// 蓝牙服务
final _bleManager = Get.find<BleManager>();
final bleManager = Get.find<BleManager>();
// 音频输入源
bool _audioSourceType = false;
// 存储相关
@ -308,14 +308,14 @@ class TranslationController extends GetxController {
// translationHistory.add(newItem);
// 开始连续语音识别
if (currentMode.value == "audioVideo") {
_bleManager.openDecoder();
bleManager.openDecoder();
// 发送ble音乐或者通话远端声音
_audioSourceType = true;
isTtsEnabled.value = false; // 禁用TTS以避免干扰
Logger.info('发送ble音乐或者通话远端声音');
} else if (currentMode.value == "call") {
// 发送ble系统mic和dac(音乐或者通话远端)声音
_bleManager.openA2DPDecoder();
bleManager.openA2DPDecoder();
_audioSourceType = true;
isTtsEnabled.value = true;
Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音');
@ -563,7 +563,7 @@ class TranslationController extends GetxController {
Future<void> stopAll() async {
try {
if (currentMode.value == "audioVideo" || currentMode.value == "call") {
_bleManager.closeCodec();
bleManager.closeCodec();
// 发送ble音乐或者通话远端声音
Logger.info('发送ble音乐或者通话远端声音');
}

4
lib/modules/translation/views/translation_view.dart

@ -67,12 +67,12 @@ class TranslationView extends GetView<TranslationController> {
_buildPopupMenuItem(
'audioVideo',
'audioVideoTranslation'.tr,
enabled: true, // 根据连接状态启用/禁用
enabled: controller.bleManager.isConnected, // 根据连接状态启用/禁用
),
_buildPopupMenuItem(
'call',
'callTranslation'.tr,
enabled: true, // 根据连接状态启用/禁用
enabled: controller.bleManager.isConnected, // 根据连接状态启用/禁用
),
],
child: Row(

9
lib/routes/app_pages.dart

@ -46,6 +46,8 @@ import '../modules/realtime/bindings/realtime_binding.dart';
import '../modules/realtime/views/realtime_view.dart';
import '../modules/music/views/music_playlist_view.dart';
import '../modules/music/bindings/music_playlist_binding.dart';
import '../modules/FTFTranslation/bindings/FTFTranslation_binding.dart';
import '../modules/FTFTranslation/views/FTFTranslation_view.dart';
abstract class AppPages {
static final pages = [
@ -191,6 +193,13 @@ abstract class AppPages {
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.ftftranslation,
page: () => const FTFTranslationView(),
binding: FTFTranslationBinding(),
transition: Transition.noTransition,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.opusTest,
page: () => const OpusTestView(),

1
lib/routes/app_routes.dart

@ -28,6 +28,7 @@ abstract class Routes {
static const translationHistory = '/translation/history'; // 翻译历史
static const speechDemo = '/speech_demo'; // 语音演示
static const translationList = '/translation/List'; // 翻译历史
static const ftftranslation = '/ftftranslation'; // 面对面翻译
// 测试路由(生产环境建议移除)
static const ttsTest = '/tts_test'; // 文字转语音测试
static const asrTest = '/asr_test'; // 语音识别测试

129
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -22,6 +22,7 @@ import java.util.concurrent.LinkedBlockingQueue
import android.os.Handler
import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log
/**
* BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能
* 支持:
@ -67,17 +68,20 @@ object BleService {
private var bluetoothLeScanner: BluetoothLeScanner? = null
// GATT连接相关
var bluetoothGatt: BluetoothGatt? = null
var bluetoothGatt: BluetoothGatt? = null
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<ScanResult>()
private var isScanning = false
// 是否是ota模式
// 是否是ota模式
var isEnterOta = false
// 连接状态
val connectionState = MutableLiveData(BleConst.STATE_DISCONNECTED)
@ -136,7 +140,7 @@ object BleService {
Log.e(TAG, "OpusManager初始化失败: ${e.message}", e)
// 根据需要决定是否因为Opus初始化失败而返回false
}
recordfile = RecordingFile(this.context)
recordfile = RecordingFile(this.context)
isInitialized = true
Log.d(TAG, "BLE服务初始化成功")
return true
@ -198,7 +202,6 @@ recordfile = RecordingFile(this.context)
// 获取一个局部引用,避免并发访问问题
val scanner = bluetoothLeScanner ?: return false
if (isScanning) return false
Log.i(TAG, "开始主动扫描BLE设备...")
scanResults.clear()
@ -341,13 +344,12 @@ recordfile = RecordingFile(this.context)
Log.w(TAG, "未找到已配对的设备 MAC 地址")
return
}
if(!isEnterOta)
{
Log.i(TAG, "检测到已配对设备,MAC: $pairedMac,尝试连接")
// 调用现有的 connect 方法
connect(pairedMac)
if (!isEnterOta) {
Log.i(TAG, "检测到已配对设备,MAC: $pairedMac,尝试连接")
// 调用现有的 connect 方法
connect(pairedMac)
}
} catch (e: Exception) {
@ -518,7 +520,7 @@ recordfile = RecordingFile(this.context)
when (c.uuid) {
// 音频特征数据
BleConst.RECEIVE_AUDIO_CHAR_UUID -> {
// Log.i(TAG, "收到音频特征数据")
processAudioData(data)
}
@ -597,7 +599,7 @@ recordfile = RecordingFile(this.context)
private fun processAudioData(data: ByteArray) {
try {
if (opusManager?.isDecodeStream == true) {
recordfile?.saveAudioDataToWav(data)
recordfile?.saveAudioDataToWav(data)
opusManager?.writeAudioStream(data)
} else {
// Log.d(TAG, "Opus解码流未启动,忽略音频数据")
@ -795,17 +797,16 @@ recordfile = RecordingFile(this.context)
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()
}
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 -> "左声道"
@ -1093,7 +1094,7 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE)
// // The library might expect this in a different unit or derive it.
// // Given JlOpusPlugin.kt, packetSize refers to Opus encoded frame duration in ms.
// )
startOpusStreamDecoding(false, 1, 16000, 40)
startOpusStreamDecoding(false, 1, 16000, 40)
Log.i(TAG, "打开解码0xA2")
return sendCommand(
@ -1110,7 +1111,7 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE)
*/
fun openA2DPDecoder(): Boolean {
Log.i(TAG, "打开编码 0xA1")
startOpusStreamDecoding(false, 1, 16000, 80)
startOpusStreamDecoding(false, 1, 16000, 80)
// Log.i(TAG, "打开解码...")
return sendCommand(
BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(
@ -1135,7 +1136,7 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE)
fun openEncoder(): Boolean {
Log.i(TAG, "打开编码0xB1")
startOpusStreamDecoding(false, 1, 16000, 40)
startOpusStreamDecoding(false, 1, 16000, 40)
// 目前仅发送命令通知设备开始编码。
return sendCommand(
BleConst.CMD_CONTROL_CODEC.toByte(),
@ -1385,9 +1386,10 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE)
}
}
}
class RecordingFile(private val context: Context) {
class RecordingFile(private val context: Context) {
private var currentAudioFile: File? = null
private var fos: FileOutputStream? = null
@ -1408,8 +1410,9 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE)
// 创建新的音频文件
val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
val timestamp = dateFormat.format(Date())
val filePath = File(context.getExternalFilesDir(null), "${fileName}_${timestamp}.opus").absolutePath
val filePath =
File(context.getExternalFilesDir(null), "${fileName}_${timestamp}.opus").absolutePath
currentAudioFile = File(filePath)
currentAudioFile?.createNewFile()
// 追加音频数据到文件
@ -1417,42 +1420,42 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE)
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}")
// 新增:启动写入线程
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()
}
writeThread?.start()
}
/**
* 保存音频数据到 WAV 文件(异步)
*/
internal fun saveAudioDataToWav(buffer: ByteArray) {
if (fos == null || currentAudioFile == null) return
// 放入队列,由写线程写入
// 1. 将字节数据转换为十六进制字符串
// val hexData = buildString {
// buffer.forEachIndexed { index, byte ->
// append("%02X".format(byte))
// }
// }
// 2. 写入十六进制字符串
writeQueue.offer(buffer)
totalBytesWritten += buffer.size
}
/**
* 保存音频数据到 WAV 文件(异步)
*/
internal fun saveAudioDataToWav(buffer: ByteArray) {
if (fos == null || currentAudioFile == null) return
// 放入队列,由写线程写入
// 1. 将字节数据转换为十六进制字符串
// val hexData = buildString {
// buffer.forEachIndexed { index, byte ->
// append("%02X".format(byte))
// }
// }
// 2. 写入十六进制字符串
writeQueue.offer(buffer)
totalBytesWritten += buffer.size
}
internal fun closeFile() {

Loading…
Cancel
Save