Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into liwei

newdev_shunjiawei
liwei1dao 1 year ago
parent
commit
3decdc73b3
  1. 128
      lib/core/translations/app_translations.dart
  2. 5
      lib/core/translations/language/bg_bg.dart
  3. 5
      lib/core/translations/language/cs_cz.dart
  4. 4
      lib/core/translations/language/da_dk.dart
  5. 5
      lib/core/translations/language/el_gr.dart
  6. 5
      lib/core/translations/language/et_ee.dart
  7. 5
      lib/core/translations/language/fi_fi.dart
  8. 5
      lib/core/translations/language/hr_hr.dart
  9. 5
      lib/core/translations/language/hu_hu.dart
  10. 12
      lib/core/translations/language/is_is.dart
  11. 5
      lib/core/translations/language/lt_lt.dart
  12. 5
      lib/core/translations/language/lv_lv.dart
  13. 5
      lib/core/translations/language/nb_no.dart
  14. 4
      lib/core/translations/language/nl_nl.dart
  15. 5
      lib/core/translations/language/ro_ro.dart
  16. 5
      lib/core/translations/language/sk_sk.dart
  17. 5
      lib/core/translations/language/sl_si.dart
  18. 12
      lib/core/translations/language/sr_rs.dart
  19. 4
      lib/core/translations/language/sv_se.dart
  20. 5
      lib/core/translations/language/tl_ph.dart
  21. 5
      lib/core/translations/language/uk_ua.dart
  22. 10
      lib/data/services/asr_service.dart
  23. 42
      lib/data/services/speech_impl/azure_asr_service.dart
  24. 16
      lib/data/services/speech_impl/volcano_asr_api_service.dart
  25. 16
      lib/data/services/speech_impl/volcano_asr_service.dart
  26. 16
      lib/data/services/speech_impl/xunfei_asr_service.dart
  27. 4
      lib/main.dart
  28. 8
      lib/modules/login/controllers/login_controller.dart
  29. 9
      lib/modules/login/views/login_view.dart
  30. 131
      lib/modules/meeting/controllers/meeting_controller.dart
  31. 92
      lib/modules/meeting/controllers/meeting_record_controller.dart
  32. 199
      lib/modules/meeting/views/meeting_record_view.dart
  33. 30
      lib/modules/meeting/views/meeting_view.dart
  34. 6
      lib/modules/profile/views/profile_view.dart
  35. 172
      lib/modules/realtime/README.md
  36. 342
      lib/modules/realtime/controllers/realtime_controller.dart
  37. 452
      lib/modules/realtime/views/realtime_view.dart
  38. 104
      lib/modules/realtime/views/widgets/shader_mic_flow.dart
  39. 89
      lib/modules/realtime/views/widgets/voice_mic_flow.dart
  40. 26
      lib/modules/settings/controllers/settings_controller.dart
  41. 9
      lib/modules/settings/views/settings_view.dart
  42. 42
      lib/modules/translation/controllers/translation_controller.dart
  43. 324
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  44. 52
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  45. 44
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt
  46. 254
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt
  47. 4
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  48. 2
      local_plugins/location_service/pubspec.yaml
  49. 55
      local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt
  50. 270
      local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt
  51. 266
      local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt
  52. 109
      local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt
  53. 30
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift
  54. 45
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift
  55. 71
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift

128
lib/core/translations/app_translations.dart

@ -3,38 +3,58 @@ import 'dart:ui';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'language/en_us.dart'; import 'language/en_us.dart'; // 英语
import 'language/zh_cn.dart'; import 'language/zh_cn.dart'; // 简体中文
import 'language/es_es.dart'; import 'language/es_es.dart'; // 西班牙语
import 'language/zh_hk.dart'; import 'language/zh_hk.dart'; // 繁体中文
import 'language/pt_pt.dart'; // 添加葡萄牙语导入 import 'language/pt_pt.dart'; // 添加葡萄牙语导入
import 'language/ja_jp.dart'; // 添加日语导入 import 'language/ja_jp.dart'; // 添加日语导入
import 'language/ko_kr.dart'; // 添加韩语导入 import 'language/ko_kr.dart'; // 添加韩语导入
import 'language/fr_fr.dart'; // 添加法语导入 import 'language/fr_fr.dart'; // 添加法语导入
import 'language/de_de.dart'; // 添加德语导入 import 'language/de_de.dart'; // 添加德语导入
import 'language/ru_ru.dart'; // 添加俄语导入 import 'language/ru_ru.dart'; // 添加俄语导入
import 'language/vi_vn.dart'; // 添加越南语导入 import 'language/vi_vn.dart'; // 添加越南语导入
import 'language/it_it.dart'; // 添加意大利语导入 import 'language/it_it.dart'; // 添加意大利语导入
import 'language/hi_in.dart'; // 添加印度语导入 import 'language/hi_in.dart'; // 添加印度语导入
import 'language/id_id.dart'; // 添加印尼语导入 import 'language/id_id.dart'; // 添加印尼语导入
import 'language/th_Th.dart'; // 添加泰语导入 import 'language/th_Th.dart'; // 添加泰语导入
import 'language/ms_MY.dart'; // 添加马来语导入 import 'language/ms_MY.dart'; // 添加马来语导入
import 'language/tr_TR.dart'; // 添加土耳其语导入 import 'language/tr_TR.dart'; // 添加土耳其语导入
import 'language/pl_PL.dart'; // 添加波兰语导入 import 'language/pl_PL.dart'; // 添加波兰语导入
import 'language/tl_ph.dart'; // 新增菲律宾语导入
import 'language/da_dk.dart'; // 新增丹麦语导入
import 'language/nl_nl.dart'; // 新增荷兰语导入
import 'language/sv_se.dart'; // 新增瑞典语导入
import 'language/fi_fi.dart'; // 新增芬兰语导入
import 'language/cs_cz.dart'; // 新增捷克语导入
import 'language/hu_hu.dart'; // 新增匈牙利语导入
import 'language/nb_no.dart'; // 新增挪威语导入
import 'language/ro_ro.dart'; // 新增罗马尼亚语导入
import 'language/uk_ua.dart'; // 新增乌克兰语导入
import 'language/el_gr.dart'; // 新增希腊语导入
import 'language/bg_bg.dart'; // 新增保加利亚语导入
import 'language/hr_hr.dart'; // 新增克罗地亚语导入
import 'language/sk_sk.dart'; // 新增斯洛伐克语导入
import 'language/sl_si.dart'; // 新增斯洛文尼亚语导入
import 'language/lt_lt.dart'; // 新增立陶宛语导入
import 'language/lv_lv.dart'; // 新增拉脱维亚语导入
import 'language/et_ee.dart'; // 新增爱沙尼亚语导入
import 'language/is_is.dart'; // 新增塞尔维亚语(拉丁)导入
import 'language/sr_rs.dart'; // 新增冰岛语导入
class AppTranslations extends Translations { class AppTranslations extends Translations {
static const fallbackLocale = Locale('zh', 'CN'); static const fallbackLocale = Locale('zh', 'CN');
static final supportedLocales = [ static final supportedLocales = [
const Locale('zh', 'CN'), const Locale('zh', 'CN'), // 简体中文
const Locale('zh', 'HK'), // 添加繁体中文支持 const Locale('zh', 'HK'), // 添加繁体中文支持
const Locale('en', 'US'), const Locale('en', 'US'), // 英语
const Locale('es', 'ES'), // 添加西班牙语 const Locale('es', 'ES'), // 添加西班牙语
const Locale('pt', 'PT'), // 添加葡萄牙语支持 const Locale('pt', 'PT'), // 添加葡萄牙语支持
const Locale('ja', 'JP'), // 添加日语支持 const Locale('ja', 'JP'), // 添加日语支持
const Locale('ko', 'KR'), // 添加韩语支持 const Locale('ko', 'KR'), // 添加韩语支持
const Locale('fr', 'FR'), // 添加法语支持 const Locale('fr', 'FR'), // 添加法语支持
const Locale('de', 'DE'), // 添加德语支持 const Locale('de', 'DE'), // 添加德语支持
const Locale('ru', 'RU'), // 添加俄语支持 const Locale('ru', 'RU'), // 添加俄语支持
const Locale('vi', 'VN'), // 添加越南语支持 const Locale('vi', 'VN'), // 添加越南语支持
const Locale('it', 'IT'), // 添加意大利语支持 const Locale('it', 'IT'), // 添加意大利语支持
const Locale('hi', 'IN'), // 添加印度语支持 const Locale('hi', 'IN'), // 添加印度语支持
@ -43,6 +63,26 @@ class AppTranslations extends Translations {
const Locale('ms', 'MY'), // 添加马来语支持 const Locale('ms', 'MY'), // 添加马来语支持
const Locale('tr', 'TR'), // 添加土耳其语支持 const Locale('tr', 'TR'), // 添加土耳其语支持
const Locale('pl', 'PL'), // 添加波兰语支持 const Locale('pl', 'PL'), // 添加波兰语支持
const Locale('tl', 'PH'), // 添加菲律宾语支持
const Locale('nl', 'NL'), // 荷兰语
const Locale('sv', 'SE'), // 瑞典语
const Locale('da', 'DK'), // 丹麦语
const Locale('fi', 'FI'), // 芬兰语
const Locale('cs', 'CZ'), // 捷克语
const Locale('hu', 'HU'), // 匈牙利语
const Locale('nb', 'NO'), // 挪威语
const Locale('ro', 'RO'), // 罗马尼亚语
const Locale('uk', 'UA'), // 乌克兰语
const Locale('el', 'GR'), // 希腊语
const Locale('bg', 'BG'), // 保加利亚语
const Locale('hr', 'HR'), // 克罗地亚语
const Locale('sk', 'SK'), // 斯洛伐克语
const Locale('sl', 'SI'), // 斯洛文尼亚语
const Locale('lt', 'LT'), // 立陶宛语
const Locale('lv', 'LV'), // 拉脱维亚语
const Locale('et', 'EE'), // 爱沙尼亚语
const Locale('sr', 'RS'), // 塞尔维亚语
const Locale('is', 'IS'), // 新增冰岛语支持
]; ];
static Locale? deviceLocale() { static Locale? deviceLocale() {
@ -58,23 +98,43 @@ class AppTranslations extends Translations {
@override @override
Map<String, Map<String, String>> get keys => { Map<String, Map<String, String>> get keys => {
'en_US': enUS, 'en_US': enUS,
'zh_CN': zhCN, 'zh_CN': zhCN,
'zh_HK': zhHK, // 添加繁体中文映射 'zh_HK': zhHK, // 添加繁体中文映射
'es_ES': esES, // 添加西班牙语翻译映射 'es_ES': esES, // 添加西班牙语翻译映射
'pt_PT': ptPT, // 添加葡萄牙语映射 'pt_PT': ptPT, // 添加葡萄牙语映射
'ja_JP': jaJP, // 添加日语映射 'ja_JP': jaJP, // 添加日语映射
'ko_KR': koKR, // 添加韩语映射 'ko_KR': koKR, // 添加韩语映射
'fr_FR': frFR, // 添加法语映射 'fr_FR': frFR, // 添加法语映射
'de_DE': deDE, // 添加德语映射 'de_DE': deDE, // 添加德语映射
'ru_RU': ruRU, // 添加俄语映射 'ru_RU': ruRU, // 添加俄语映射
'vi_VN': viVN, // 添加越南语映射 'vi_VN': viVN, // 添加越南语映射
'it_IT': itIT, // 添加意大利语映射 'it_IT': itIT, // 添加意大利语映射
'hi_IN': hiIN, // 添加印度语映射 'hi_IN': hiIN, // 添加印度语映射
'id_ID': idID, // 添加印尼语映射 'id_ID': idID, // 添加印尼语映射
'th_TH': thTH, // 添加泰语映射 'th_TH': thTH, // 添加泰语映射
'ms_MY': msMY, // 添加马来语映射 'ms_MY': msMY, // 添加马来语映射
'tr_TR': trTR, // 添加土耳其语映射 'tr_TR': trTR, // 添加土耳其语映射
'pl_PL': plPL, // 添加波兰语映射 'pl_PL': plPL, // 添加波兰语映射
}; 'tl_PH': tlPH, // 添加菲律宾语映射
'nl_NL': nlNL, // 荷兰语
'sv_SE': svSE, // 瑞典语
'da_DK': daDK, // 丹麦语
'fi_FI': fiFI, // 芬兰语
'cs_CZ': csCZ, // 捷克语
'hu_HU': huHU, // 匈牙利语
'nb_NO': nbNO, // 挪威语
'ro_RO': roRO, // 罗马尼亚语
'uk_UA': ukUA, // 乌克兰语
'el_GR': elGR, // 希腊语
'bg_BG': bgBG, // 保加利亚语
'hr_HR': hrHR, // 克罗地亚语
'sk_SK': skSK, // 斯洛伐克语
'sl_SI': slSI, // 斯洛文尼亚语
'lt_LT': ltLT, // 立陶宛语
'lv_LV': lvLV, // 拉脱维亚语
'et_EE': etEE, // 爱沙尼亚语
'sr_RS': srRS, // 新增塞尔维亚语(拉丁)映射
'is_IS': isIS, // 新增冰岛语映射
};
} }

5
lib/core/translations/language/bg_bg.dart

@ -0,0 +1,5 @@
const Map<String, String> bgBG = {
'app_title': 'Deep Voice',
'welcome': 'Добре дошли',
// 补充保加利亚语翻译...
};

5
lib/core/translations/language/cs_cz.dart

@ -0,0 +1,5 @@
const Map<String, String> csCZ = {
'app_title': 'Deep Voice',
'welcome': 'Vítejte',
// 补充捷克语翻译...
};

4
lib/core/translations/language/da_dk.dart

@ -0,0 +1,4 @@
const Map<String, String> daDK = {
'app_title': 'Deep Voice',
// 添加丹麦语翻译内容
};

5
lib/core/translations/language/el_gr.dart

@ -0,0 +1,5 @@
const Map<String, String> elGR = {
'app_title': 'Deep Voice',
'welcome': 'Καλώς ορίσατε',
// 补充希腊语翻译...
};

5
lib/core/translations/language/et_ee.dart

@ -0,0 +1,5 @@
const Map<String, String> etEE = {
'app_title': 'Deep Voice',
'welcome': 'Tere tulemast',
// 补充爱沙尼亚语翻译...
};

5
lib/core/translations/language/fi_fi.dart

@ -0,0 +1,5 @@
const Map<String, String> fiFI = {
'app_title': 'Deep Voice',
'welcome': 'Tervetuloa',
// 补充芬兰语翻译...
};

5
lib/core/translations/language/hr_hr.dart

@ -0,0 +1,5 @@
const Map<String, String> hrHR = {
'app_title': 'Deep Voice',
'welcome': 'Dobrodošli',
// 补充克罗地亚语翻译...
};

5
lib/core/translations/language/hu_hu.dart

@ -0,0 +1,5 @@
const Map<String, String> huHU = {
'app_title': 'Deep Voice',
'welcome': 'Üdvözlet',
// 补充匈牙利语翻译...
};

12
lib/core/translations/language/is_is.dart

@ -0,0 +1,12 @@
Map<String, String> isIS = {
'appTitle': 'Deep Voice',
'settings': 'Stillingar',
'language': 'Tungumál',
'theme': 'Þema',
'darkMode': 'Dökkur hamur',
'lightMode': 'Ljós hamur',
'systemMode': 'Kerfishamur',
'about': 'Um appið',
'version': 'Útgáfa',
// 添加更多翻译键值对
};

5
lib/core/translations/language/lt_lt.dart

@ -0,0 +1,5 @@
const Map<String, String> ltLT = {
'app_title': 'Deep Voice',
'welcome': 'Sveikas',
// 补充立陶宛语翻译...
};

5
lib/core/translations/language/lv_lv.dart

@ -0,0 +1,5 @@
const Map<String, String> lvLV = {
'app_title': 'Deep Voice',
'welcome': 'Sveiki',
// 补充拉脱维亚语翻译...
};

5
lib/core/translations/language/nb_no.dart

@ -0,0 +1,5 @@
const Map<String, String> nbNO = {
'app_title': 'Deep Voice',
'welcome': 'Velkommen',
// 补充挪威语翻译...
};

4
lib/core/translations/language/nl_nl.dart

@ -0,0 +1,4 @@
const Map<String, String> nlNL = {
'app_title': 'Deep Voice',
// 添加荷兰语翻译内容
};

5
lib/core/translations/language/ro_ro.dart

@ -0,0 +1,5 @@
const Map<String, String> roRO = {
'app_title': 'Deep Voice',
'welcome': 'Bine ați venit',
// 补充罗马尼亚语翻译...
};

5
lib/core/translations/language/sk_sk.dart

@ -0,0 +1,5 @@
const Map<String, String> skSK = {
'app_title': 'Deep Voice',
'welcome': 'Vitajte',
// 补充斯洛伐克语翻译...
};

5
lib/core/translations/language/sl_si.dart

@ -0,0 +1,5 @@
const Map<String, String> slSI = {
'app_title': 'Deep Voice',
'welcome': 'Dobrodošli',
//补充斯洛文尼亚语翻译
};

12
lib/core/translations/language/sr_rs.dart

@ -0,0 +1,12 @@
Map<String, String> srRS = {
'appTitle': 'Deep Voice',
'settings': 'Podešavanja',
'language': 'Jezik',
'theme': 'Tema',
'darkMode': 'Tamni režim',
'lightMode': 'Svetli režim',
'systemMode': 'Sistemski režim',
'about': 'O aplikaciji',
'version': 'Verzija',
// 添加更多翻译键值对
};

4
lib/core/translations/language/sv_se.dart

@ -0,0 +1,4 @@
const Map<String, String> svSE = {
'app_title': 'Deep Voice',
// 添加瑞典语翻译内容
};

5
lib/core/translations/language/tl_ph.dart

@ -0,0 +1,5 @@
const Map<String, String> tlPH = {
'welcome': 'Maligayang pagdating',
'loginTips': 'Mangyaring mag-log in o gumawa ng account...',
// 其他翻译条目...
};

5
lib/core/translations/language/uk_ua.dart

@ -0,0 +1,5 @@
const Map<String, String> ukUA = {
'app_title': 'Deep Voice',
'welcome': 'Ласкаво просимо',
// 补充乌克兰语翻译...
};

10
lib/data/services/asr_service.dart

@ -30,13 +30,19 @@ abstract class AsrService {
bool isContinuousRecognitionActive(); bool isContinuousRecognitionActive();
/// 开始录音 /// 开始录音
Future<bool> enableRecord(String fileName); Future<bool> enableRecord(String filePath);
/// 暂停录音 /// 暂停录音
Future<bool> pauseRecord(); 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(); Future<void> dispose();

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

@ -344,10 +344,10 @@ class AzureAsrService extends GetxService implements AsrService {
} }
@override @override
Future<bool> enableRecord(String fileName) async { Future<bool> enableRecord(String filePath) async {
try { try {
final bool result = await _channel.invokeMethod('enableRecord', { final bool result = await _channel.invokeMethod('enableRecord', {
'fileName': fileName, 'filePath': filePath,
}); });
return result; return result;
@ -358,9 +358,11 @@ class AzureAsrService extends GetxService implements AsrService {
} }
@override @override
Future<bool> stopRecord() async { Future<bool> stopRecord(bool isSave) async {
try { try {
final bool result = await _channel.invokeMethod('stopRecord'); final bool result = await _channel.invokeMethod('stopRecord', {
'isSave': isSave,
});
return result; return result;
} catch (e) { } catch (e) {
@ -380,4 +382,36 @@ class AzureAsrService extends GetxService implements AsrService {
rethrow; 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 @override
Future<bool> enableRecord(String fileName) { Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord // TODO: implement enableRecord
throw UnimplementedError(); throw UnimplementedError();
} }
@override @override
Future<bool> stopRecord() { Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord // TODO: implement stopRecord
throw UnimplementedError(); throw UnimplementedError();
} }
@ -829,4 +829,16 @@ class VolcanoAsrApiService implements AsrService {
// TODO: implement pauseRecord // TODO: implement pauseRecord
throw UnimplementedError(); 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 @override
Future<bool> enableRecord(String fileName) { Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord // TODO: implement enableRecord
throw UnimplementedError(); throw UnimplementedError();
} }
@override @override
Future<bool> stopRecord() { Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord // TODO: implement stopRecord
throw UnimplementedError(); throw UnimplementedError();
} }
@ -364,4 +364,16 @@ class VolcanoAsrService extends GetxService implements AsrService {
// TODO: implement pauseRecord // TODO: implement pauseRecord
throw UnimplementedError(); 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 @override
Future<bool> enableRecord(String fileName) { Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord // TODO: implement enableRecord
throw UnimplementedError(); throw UnimplementedError();
} }
@override @override
Future<bool> stopRecord() { Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord // TODO: implement stopRecord
throw UnimplementedError(); throw UnimplementedError();
} }
@ -288,4 +288,16 @@ class XunfeiAsrService extends GetxService implements AsrService {
// TODO: implement pauseRecord // TODO: implement pauseRecord
throw UnimplementedError(); 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();
}
} }

4
lib/main.dart

@ -24,10 +24,10 @@ void main() async {
// 初始化环境变量 // 初始化环境变量
await dotenv.load(); await dotenv.load();
// 初始化登录服务(包含Firebase初始化) // 初始化登录服务(包含Firebase初始化)
LoginService(); LoginService();
// 设置窗口大小(仅在 macOS 下生效) // 设置窗口大小(仅在 macOS 下生效)
if (Platform.isMacOS) { if (Platform.isMacOS) {
const double width = 375.0; const double width = 375.0;

8
lib/modules/login/controllers/login_controller.dart

@ -231,10 +231,12 @@ class LoginController extends GetxController {
style: TextStyle(fontSize: 14), style: TextStyle(fontSize: 14),
), ),
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
String url = 'https://www.deepsoud.com/ysxy.html'; String url = 'https://www.deepsoud.com/ysxy.html';
if (Get.locale?.toString() != 'zh_CN') { if (Get.locale?.languageCode != 'zh' ||
Get.locale?.countryCode != 'CN') {
url = 'https://www.deepsoud.com/ysxy_en.html'; url = 'https://www.deepsoud.com/ysxy_en.html';
} }
Get.to(() => WebViewPage( Get.to(() => WebViewPage(
@ -256,10 +258,12 @@ class LoginController extends GetxController {
style: TextStyle(fontSize: 14), style: TextStyle(fontSize: 14),
), ),
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
String url = 'https://www.deepsoud.com/yhxy.html'; String url = 'https://www.deepsoud.com/yhxy.html';
if (Get.locale?.toString() != 'zh_CN') { if (Get.locale?.languageCode != 'zh' ||
Get.locale?.countryCode != 'CN') {
url = 'https://www.deepsoud.com/yhxy_en.html'; url = 'https://www.deepsoud.com/yhxy_en.html';
} }
Get.to(() => WebViewPage( Get.to(() => WebViewPage(

9
lib/modules/login/views/login_view.dart

@ -4,6 +4,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../controllers/login_controller.dart'; import '../controllers/login_controller.dart';
import '../../common/webview_page.dart'; import '../../common/webview_page.dart';
import 'package:get_storage/get_storage.dart';
class LoginView extends GetView<LoginController> { class LoginView extends GetView<LoginController> {
const LoginView({Key? key}) : super(key: key); const LoginView({Key? key}) : super(key: key);
@ -575,7 +576,8 @@ class LoginView extends GetView<LoginController> {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
String url = 'https://www.deepsoud.com/yhxy.html'; String url = 'https://www.deepsoud.com/yhxy.html';
if (Get.locale?.toString() != 'zh_CN') { if (Get.locale?.languageCode != 'zh' ||
Get.locale?.countryCode != 'CN') {
url = 'https://www.deepsoud.com/yhxy_en.html'; url = 'https://www.deepsoud.com/yhxy_en.html';
} }
Get.to(() => WebViewPage( Get.to(() => WebViewPage(
@ -601,9 +603,12 @@ class LoginView extends GetView<LoginController> {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
String url = 'https://www.deepsoud.com/ysxy.html'; String url = 'https://www.deepsoud.com/ysxy.html';
if (Get.locale?.toString() != 'zh_CN') {
if (Get.locale?.languageCode != 'zh' ||
Get.locale?.countryCode != 'CN') {
url = 'https://www.deepsoud.com/ysxy_en.html'; url = 'https://www.deepsoud.com/ysxy_en.html';
} }
Get.to(() => WebViewPage( Get.to(() => WebViewPage(
url: url, url: url,
title: 'privacyPolicy'.tr, title: 'privacyPolicy'.tr,

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

@ -28,7 +28,7 @@ class MeetingController extends GetxController {
int addQuantity = 0; int addQuantity = 0;
List<String> selected = []; List<String> selected = [];
final appDocDir = getApplicationSupportDirectory(); final appDocDir = getApplicationDocumentsDirectory();
// final directory = Directory( // final directory = Directory(
// '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); // '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files');
List<FileSystemEntity> files = []; List<FileSystemEntity> files = [];
@ -56,6 +56,7 @@ class MeetingController extends GetxController {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
_getDataList(); _getDataList();
addMeetingAudio();
} }
@override @override
@ -127,22 +128,34 @@ class MeetingController extends GetxController {
} }
void loadTranslatAudio() async { void loadTranslatAudio() async {
final appDir = await appDocDir; // 先 await 获取目录 try {
final dir = Directory("${appDir.path}/TranslatAudio"); final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/TranslatAudio");
print("appDocDir=${dir?.path}");
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 // Directory exis
.listSync() files = dir
.where((f) => .listSync()
f is File && .where((f) =>
(f.path.endsWith('.mp3') || f is File &&
f.path.endsWith('.wav') || (f.path.endsWith('.mp3') ||
f.path.endsWith('.aac') || f.path.endsWith('.wav') ||
f.path.endsWith('.m4a') || f.path.endsWith('.aac') ||
f.path.endsWith('.mp4') || f.path.endsWith('.m4a') ||
f.path.endsWith('.mov'))) f.path.endsWith('.mp4') ||
.toList(); 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 { void addTranslatAudio() async {
EasyLoading.show(status: '正在批量导入...'); EasyLoading.show(status: '正在批量导入...');
for (final filePath in selected) { for (final filePath in selected) {

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

@ -1,9 +1,12 @@
// meeting_record_controller.dart // meeting_record_controller.dart
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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/asr_service.dart';
import '../../../data/services/ble_manager.dart'; import '../../../data/services/ble_manager.dart';
@ -46,24 +49,66 @@ class MeetingRecordController extends GetxController
int _elapsedMilliseconds = 0; // 累计毫秒数 int _elapsedMilliseconds = 0; // 累计毫秒数
int? _lastStartTime; // 上次开始计时的时间戳 int? _lastStartTime; // 上次开始计时的时间戳
final newName = "".obs;
final fileName = "初始文件名".obs;
final isEditingTitle = false.obs;
late TextEditingController titleEditingController;
late FocusNode titleFocusNode;
final appDocDir = getApplicationDocumentsDirectory();
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_asrService.initialize(); tabController = TabController(length: 2, vsync: this); // Add this line
tabController = TabController(length: 2, vsync: this); 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(); generateWaveData();
// 移除初始化的 startTimer() 调用
} }
@override @override
void onClose() { void onClose() {
_timer?.cancel(); titleEditingController.dispose();
_asrService.dispose(); titleFocusNode.dispose();
tabController.dispose();
super.onClose(); 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() { void startTimer() {
if (timerStatus.value != TimerStatus.running) { if (timerStatus.value != TimerStatus.running) {
@ -195,16 +240,29 @@ class MeetingRecordController extends GetxController
_recognitionSubscription?.cancel(); _recognitionSubscription?.cancel();
_recognitionSubscription = _recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent); 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(); startTimer();
// 蓝牙处理 // 蓝牙处理
if (audioTypes == 0) { if (audioTypes.value == 0) {
_bleManager.openEncoder(); _bleManager.openEncoder();
} else if (audioTypes == 1) { } else if (audioTypes.value == 1) {
_bleManager.openDecoder(); _bleManager.openDecoder();
} else if (audioTypes == 2) { } else if (audioTypes.value == 2) {
_bleManager.openA2DPDecoder(); _bleManager.openA2DPDecoder();
} }
} }
@ -225,8 +283,6 @@ class MeetingRecordController extends GetxController
_recognitionSubscription = _recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent); recognitionStream.listen(_handleRecognitionEvent);
startTimer(); startTimer();
//await _asrService.resumeRecognition(); // 假设ASR服务有继续方法
} }
/// 切换是否标记为重点 /// 切换是否标记为重点
@ -247,8 +303,14 @@ class MeetingRecordController extends GetxController
Future<void> saveRecording() async { Future<void> saveRecording() async {
resetTimer(); // 重置计时器 resetTimer(); // 重置计时器
_bleManager.closeCodec(); _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(); 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:get/get.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import '../controllers/meeting_record_controller.dart'; import '../controllers/meeting_record_controller.dart';
import '../../../core/theme/app_colors.dart';
class MeetingRecordView extends GetView<MeetingRecordController> { class MeetingRecordView extends GetView<MeetingRecordController> {
const MeetingRecordView({super.key}); const MeetingRecordView({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
// 在控制器中添加编辑状态管理
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), 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, elevation: 0,
backgroundColor: Colors.white, backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
foregroundColor: Colors.black, 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( body: Column(
children: [ children: [
TabBar( TabBar(
controller: controller.tabController, controller: controller.tabController,
indicatorColor: Colors.red, indicatorColor: Colors.red,
labelColor: Colors.black, labelColor: isDarkMode ? Colors.white : Colors.black,
unselectedLabelColor: Colors.grey, unselectedLabelColor: Colors.grey,
tabs: const [ tabs: const [
Tab(text: '音频'), Tab(text: '音频'),
@ -33,8 +86,8 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
child: TabBarView( child: TabBarView(
controller: controller.tabController, controller: controller.tabController,
children: [ children: [
_buildAudioTab(context), _buildAudioTab(context, isDarkMode),
_buildTextTab(context), _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( return Obx(() => Column(
children: [ children: [
const SizedBox(height: 24), const SizedBox(height: 24),
@ -77,13 +130,13 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
_buildBottomBar(context), _buildBottomBar(context, isDarkMode),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
)); ));
} }
Widget _buildTextTab(BuildContext context) { Widget _buildTextTab(BuildContext context, bool isDarkMode) {
return Obx(() => Column( return Obx(() => Column(
children: [ children: [
const SizedBox(height: 48), const SizedBox(height: 48),
@ -139,16 +192,18 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
), ),
const Spacer(), const Spacer(),
_buildBottomBar(context), _buildBottomBar(context, isDarkMode),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
)); ));
} }
Widget _buildBottomBar(BuildContext context) { // 底部操作栏
Widget _buildBottomBar(BuildContext context, bool isDarkMode) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
// 收藏按钮
GestureDetector( GestureDetector(
onTap: controller.toggleMarked, onTap: controller.toggleMarked,
child: Obx(() => CircleAvatar( child: Obx(() => CircleAvatar(
@ -162,6 +217,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
)), )),
), ),
// 录音按钮
GestureDetector( GestureDetector(
onTap: controller.toggleRecording, onTap: controller.toggleRecording,
child: Obx(() => CircleAvatar( child: Obx(() => CircleAvatar(
@ -176,8 +232,9 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
)), )),
), ),
// 完成按钮
GestureDetector( GestureDetector(
onTap: controller.saveRecording, onTap: () => _bottomSheet(context, isDarkMode), // 改为匿名函数
child: const CircleAvatar( child: const CircleAvatar(
radius: 32, radius: 32,
backgroundColor: Colors.white, 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 { class WaveformPainter extends CustomPainter {

30
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:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:haptic_feedback/haptic_feedback.dart'; import 'package:haptic_feedback/haptic_feedback.dart';
import 'package:path_provider/path_provider.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../controllers/meeting_controller.dart'; import '../controllers/meeting_controller.dart';
import '../model/meeting_model.dart'; import '../model/meeting_model.dart';
import 'meeting_record_view.dart';
class MeetingView extends GetView<MeetingController> { class MeetingView extends GetView<MeetingController> {
const MeetingView({super.key}); const MeetingView({super.key});
@ -563,10 +561,13 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode, isDarkMode,
icon: Image.asset('assets/images/explore.png', icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w), width: 32.w, height: 32.w),
title: '音/视频录音', title: '现场录音',
onTap: () { onTap: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord); await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 0,
});
controller.addMeetingAudio();
// TODO: 实现从文件导入逻辑 // TODO: 实现从文件导入逻辑
}, },
), ),
@ -576,11 +577,13 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode, isDarkMode,
icon: Image.asset('assets/images/explore.png', icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w), width: 32.w, height: 32.w),
title: '现场录音', title: '音、视频录音',
onTap: () { onTap: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord); await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 1,
});
controller.addMeetingAudio();
// TODO: 实现从相册导入逻辑 // TODO: 实现从相册导入逻辑
}, },
), ),
@ -602,9 +605,14 @@ class MeetingView extends GetView<MeetingController> {
], ],
), ),
title: '通话录音', title: '通话录音',
onTap: () { onTap: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 2,
});
controller.addMeetingAudio();
// TODO: 实现从其他APP导入逻辑 // TODO: 实现从其他APP导入逻辑
}, },
), ),

6
lib/modules/profile/views/profile_view.dart

@ -629,7 +629,8 @@ class EditProfileView extends GetView<ProfileController> {
titleColor: isDarkMode ? Colors.white : Colors.black87, titleColor: isDarkMode ? Colors.white : Colors.black87,
onTap: () { onTap: () {
String url = 'https://www.deepsoud.com/yhxy.html'; String url = 'https://www.deepsoud.com/yhxy.html';
if (Get.locale?.toString() != 'zh_CN') { if (Get.locale?.languageCode != 'zh' ||
Get.locale?.countryCode != 'CN') {
url = 'https://www.deepsoud.com/yhxy_en.html'; url = 'https://www.deepsoud.com/yhxy_en.html';
} }
Get.to(() => WebViewPage( Get.to(() => WebViewPage(
@ -657,7 +658,8 @@ class EditProfileView extends GetView<ProfileController> {
titleColor: isDarkMode ? Colors.white : Colors.black87, titleColor: isDarkMode ? Colors.white : Colors.black87,
onTap: () { onTap: () {
String url = 'https://www.deepsoud.com/ysxy.html'; String url = 'https://www.deepsoud.com/ysxy.html';
if (Get.locale?.toString() != 'zh_CN') { if (Get.locale?.languageCode != 'zh' ||
Get.locale?.countryCode != 'CN') {
url = 'https://www.deepsoud.com/ysxy_en.html'; url = 'https://www.deepsoud.com/ysxy_en.html';
} }
Get.to(() => WebViewPage( Get.to(() => WebViewPage(

172
lib/modules/realtime/README.md

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

342
lib/modules/realtime/controllers/realtime_controller.dart

@ -1,15 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:realtime/realtime.dart'; import 'package:realtime/realtime.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
class RealtimeController extends GetxController with GetTickerProviderStateMixin { class RealtimeController extends GetxController with GetTickerProviderStateMixin {
static const int _sampleRate = 16000;
// Realtime服务实例 // Realtime服务实例
final RealtimeService _realtimeService = RealtimeService(); final RealtimeService _realtimeService = RealtimeService();
// 音频振幅值(RMS)
final RxDouble audioAmplitude = 0.0.obs;
// 连接状态 // 连接状态
final RxBool isConnected = false.obs; final RxBool isConnected = false.obs;
final RxBool isConnecting = false.obs; final RxBool isConnecting = false.obs;
@ -18,31 +25,29 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
final RxBool isListening = false.obs; final RxBool isListening = false.obs;
final RxBool isSpeaking = false.obs; final RxBool isSpeaking = false.obs;
// 状态和字幕显示 // UI状态
final RxString currentStatus = '正在初始化...'.obs; final RxString currentStatus = ''.obs; // 移除默认文字
final RxString userSpeechText = ''.obs; final RxString userSpeechText = ''.obs;
final RxString aiResponseText = ''.obs; final RxBool isUserSpeechFinal = true.obs;
final RxList<String> aiResponseMessages = <String>[].obs;
final RxBool isSubtitleMode = true.obs;
// 音频可视化数据 // 字幕显示用的最新消息
final RxList<double> audioLevels = <double>[].obs; final RxString latestUserMessage = ''.obs;
final RxDouble userVoiceLevel = 0.0.obs; final RxString latestAiMessage = ''.obs;
final RxDouble aiVoiceLevel = 0.0.obs;
// 动画控制器 // 实时字幕更新用的临时消息
late AnimationController pulseAnimationController; final RxString partialUserMessage = ''.obs;
late Animation<double> pulseAnimation; final RxString streamingAiMessage = ''.obs;
// 状态监听 // 状态监听
StreamSubscription<RealtimeEvent>? _eventSubscription; StreamSubscription<RealtimeEvent>? _eventSubscription;
Timer? _audioUpdateTimer;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_initializeAnimations();
_initializeAudioLevels();
_setupEventListener(); _setupEventListener();
Logger.info('Realtime模块初始化完成');
// 自动开始连接 // 自动开始连接
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -56,27 +61,6 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
super.onClose(); super.onClose();
} }
/// 初始化动画
void _initializeAnimations() {
pulseAnimationController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
pulseAnimation = Tween<double>(
begin: 0.8,
end: 1.2,
).animate(CurvedAnimation(
parent: pulseAnimationController,
curve: Curves.easeInOut,
));
}
/// 初始化音频级别数据
void _initializeAudioLevels() {
audioLevels.value = List.generate(20, (index) => 0.0);
}
/// 设置事件监听器 /// 设置事件监听器
void _setupEventListener() { void _setupEventListener() {
_eventSubscription = _realtimeService.eventStream.listen( _eventSubscription = _realtimeService.eventStream.listen(
@ -101,6 +85,9 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
case 'textReceived': case 'textReceived':
_handleTextReceived(event.data); _handleTextReceived(event.data);
break; break;
case 'audioRms':
_handleAudioRms(event.data);
break;
case 'error': case 'error':
_handleError(event.data); _handleError(event.data);
break; break;
@ -109,7 +96,7 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
/// 自动连接 /// 自动连接
Future<void> _autoConnect() async { Future<void> _autoConnect() async {
currentStatus.value = '正在连接AI服务器...'; currentStatus.value = ''; // 不显示任何文字
await connect(); await connect();
} }
@ -119,9 +106,7 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
case 'connected': case 'connected':
isConnected.value = true; isConnected.value = true;
isConnecting.value = false; isConnecting.value = false;
currentStatus.value = '已连接,正在开始录音...'; currentStatus.value = ''; // 不显示任何文字
_startAudioUpdates();
Logger.info('已连接到Vocode服务器');
// 连接成功后自动开始录音 // 连接成功后自动开始录音
Timer(const Duration(milliseconds: 500), () { Timer(const Duration(milliseconds: 500), () {
startListening(); startListening();
@ -129,20 +114,18 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
break; break;
case 'connecting': case 'connecting':
isConnecting.value = true; isConnecting.value = true;
currentStatus.value = '正在连接AI服务器...'; currentStatus.value = ''; // 不显示任何文字
Logger.info('正在连接Vocode服务器...');
break; break;
case 'disconnected': case 'disconnected':
isConnected.value = false; isConnected.value = false;
isConnecting.value = false; isConnecting.value = false;
currentStatus.value = '已断开连接'; currentStatus.value = ''; // 不显示任何文字
_resetState(); _resetState();
Logger.info('已断开Vocode服务器连接');
break; break;
case 'error': case 'error':
isConnected.value = false; isConnected.value = false;
isConnecting.value = false; isConnecting.value = false;
currentStatus.value = '连接失败,请检查网络'; currentStatus.value = '连接失败,请检查网络'; // 保留错误提示
_resetState(); _resetState();
Logger.error('Vocode服务器连接错误'); Logger.error('Vocode服务器连接错误');
break; break;
@ -155,60 +138,139 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
case 'recording': case 'recording':
isListening.value = true; isListening.value = true;
isSpeaking.value = false; isSpeaking.value = false;
currentStatus.value = '正在聆听,请说话...'; currentStatus.value = ''; // 不显示任何文字
pulseAnimationController.repeat(reverse: true);
Logger.info('开始录音');
break; break;
case 'processing': case 'processing':
// 用户说话结束,等待AI回应
isListening.value = false; isListening.value = false;
currentStatus.value = 'AI正在思考中...'; currentStatus.value = ''; // 不显示任何文字
break; break;
case 'playing': case 'playing':
isListening.value = false; isListening.value = false;
isSpeaking.value = true; isSpeaking.value = true;
currentStatus.value = 'AI正在回复...'; currentStatus.value = ''; // 不显示任何文字
pulseAnimationController.repeat(reverse: true);
Logger.info('AI开始说话');
break; break;
case 'idle': case 'idle':
isListening.value = false; isListening.value = false;
isSpeaking.value = false; isSpeaking.value = false;
currentStatus.value = isConnected.value ? '等待用户说话...' : '已断开连接'; currentStatus.value = ''; // 不显示任何文字
pulseAnimationController.stop(); // 空闲时音频振幅归零
Logger.info('语音状态空闲'); audioAmplitude.value = 0.0;
break; break;
} }
} }
/// 处理收到的文本消息 /// 处理收到的文本消息 - 标准Vocode WebSocket协议
void _handleTextReceived(String text) { void _handleTextReceived(dynamic payload) {
Logger.info('收到文本消息: $text'); if (payload is String) {
try {
// 根据消息来源更新相应的文本 // 解析JSON消息
if (text.startsWith('[USER]')) { final Map<String, dynamic> json = jsonDecode(payload);
userSpeechText.value = text.replaceFirst('[USER]', '').trim(); final String messageType = json['type'] ?? '';
} else if (text.startsWith('[AI]')) {
aiResponseText.value = text.replaceFirst('[AI]', '').trim(); switch (messageType) {
case 'websocket_transcript':
// 处理统一的文本消息格式
final String text = json['text'] ?? '';
final String sender = json['sender'] ?? '';
final bool isFinal = json['is_final'] ?? false;
// final dynamic timestamp = json['timestamp']; // 保留供未来使用
if (sender.toLowerCase() == 'human') {
// 用户语音识别消息
if (isFinal) {
// ASR最终结果
if (text.isNotEmpty) {
latestUserMessage.value = text;
userSpeechText.value = text;
isUserSpeechFinal.value = true;
}
// 清空部分消息
partialUserMessage.value = '';
} else {
// ASR实时识别
if (text.isNotEmpty) {
partialUserMessage.value = text;
isUserSpeechFinal.value = false;
}
}
} else if (sender.toLowerCase() == 'bot') {
// AI回复消息
if (isFinal) {
// LLM回复完成标识 (text为空)
if (streamingAiMessage.value.isNotEmpty) {
// 将流式消息确认为最终消息
latestAiMessage.value = streamingAiMessage.value;
if (aiResponseMessages.isNotEmpty) {
aiResponseMessages[aiResponseMessages.length - 1] = streamingAiMessage.value;
} else {
aiResponseMessages.add(streamingAiMessage.value);
}
streamingAiMessage.value = '';
}
} else {
// LLM流式回复
if (text.isNotEmpty) {
streamingAiMessage.value += text;
}
}
}
break;
case 'websocket_ready':
// 连接就绪
break;
case 'websocket_stop':
// 会话结束
break;
case 'websocket_start':
// 会话开始
break;
default:
Logger.error('收到未知消息类型: "$messageType"');
}
} catch (e) {
Logger.error('解析WebSocket消息失败: $e');
}
} else { } else {
// 默认认为是AI回复 Logger.error('收到非字符串类型的payload: ${payload.runtimeType}');
aiResponseText.value = text; }
}
/// 处理音频RMS值
void _handleAudioRms(dynamic rms) {
if (rms is double || rms is num) {
// 平滑处理RMS值,避免抖动太大,增大幅度
final double rawValue = rms.toDouble().clamp(0.0, 1.0);
// 增强幅度:平方根函数减少小值,对数函数增强大值
final double enhancedValue = (rawValue * 3.0).clamp(0.0, 1.0);
// 更强的平滑处理,减少抖动
audioAmplitude.value = audioAmplitude.value * 0.85 + enhancedValue * 0.15;
} }
} }
/// 处理错误 /// 处理错误
void _handleError(String error) { void _handleError(String error) {
Logger.error('Realtime错误: $error'); Logger.error('Realtime错误: $error');
// 可以显示错误提示
} }
/// 切换连接状态 /// 切换字幕模式
Future<void> toggleConnection() async { void toggleSubtitleMode() {
if (isConnected.value) { isSubtitleMode.value = !isSubtitleMode.value;
await disconnect(); }
} else {
await connect(); /// 清空聊天记录
} void clearChat() {
userSpeechText.value = '';
isUserSpeechFinal.value = true;
aiResponseMessages.clear();
latestUserMessage.value = '';
latestAiMessage.value = '';
partialUserMessage.value = '';
streamingAiMessage.value = '';
HapticFeedback.lightImpact();
} }
/// 连接到AI服务 /// 连接到AI服务
@ -219,13 +281,14 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
// 初始化Realtime服务 // 初始化Realtime服务
final initialized = await _realtimeService.initialize( bool initialized = await _realtimeService.initialize(
serverUrl: 'ws://192.168.1.11:8000/ws', // 本地开发服务器使用ws协议 // serverUrl: 'ws://mcp.ideapsound.com:8000/ws',
sampleRate: 16000, serverUrl: 'ws://192.168.1.11:8000/ws',
sampleRate: _sampleRate,
channels: 1, channels: 1,
bitsPerSample: 16, bitsPerSample: 16,
); );
if (!initialized) { if (!initialized) {
throw Exception('初始化Realtime服务失败'); throw Exception('初始化Realtime服务失败');
} }
@ -248,36 +311,18 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
try { try {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
// 停止录音和播放
if (isListening.value) { if (isListening.value) {
await _realtimeService.stopRecording(); await _realtimeService.stopRecording();
} }
await _realtimeService.stopPlaying(); await _realtimeService.stopPlaying();
// 断开连接
await _realtimeService.disconnect(); await _realtimeService.disconnect();
// 停止本地更新
_stopAudioUpdates();
pulseAnimationController.stop();
} catch (e) { } catch (e) {
Logger.error('断开连接失败: $e'); Logger.error('断开连接失败: $e');
} }
} }
/// 开始/停止聆听
void toggleListening() {
if (!isConnected.value) return;
if (isListening.value) {
stopListening();
} else {
startListening();
}
}
/// 开始聆听 /// 开始聆听
void startListening() { void startListening() {
if (!isConnected.value || isListening.value) return; if (!isConnected.value || isListening.value) return;
@ -302,103 +347,56 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
}); });
} }
/// 开始音频数据更新 /// 切换聆听状态
void _startAudioUpdates() { void toggleListening() {
_audioUpdateTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) { if (!isConnected.value) return;
if (!isConnected.value) {
timer.cancel();
return;
}
_updateAudioLevels();
});
}
/// 停止音频数据更新
void _stopAudioUpdates() {
_audioUpdateTimer?.cancel();
_audioUpdateTimer = null;
}
/// 更新音频级别
void _updateAudioLevels() {
final random = Random();
// 更新用户语音级别
if (isListening.value) { if (isListening.value) {
userVoiceLevel.value = 0.3 + random.nextDouble() * 0.7; stopListening();
} else {
userVoiceLevel.value = userVoiceLevel.value * 0.8; // 衰减
}
// 更新AI语音级别
if (isSpeaking.value) {
aiVoiceLevel.value = 0.2 + random.nextDouble() * 0.8;
} else { } else {
aiVoiceLevel.value = aiVoiceLevel.value * 0.9; // 衰减 startListening();
} }
// 更新音频级别数组
final newLevels = List<double>.generate(20, (index) {
if (isListening.value || isSpeaking.value) {
return random.nextDouble() * (isListening.value ? userVoiceLevel.value : aiVoiceLevel.value);
}
return audioLevels[index] * 0.95; // 衰减
});
audioLevels.value = newLevels;
}
/// 重置音频级别
void _resetAudioLevels() {
userVoiceLevel.value = 0.0;
aiVoiceLevel.value = 0.0;
audioLevels.value = List.generate(20, (index) => 0.0);
} }
/// 关闭并返回上一页 /// 关闭并返回上一页
Future<void> closeAndGoBack() async { Future<void> closeAndGoBack() async {
// 立即返回上一页,避免阻塞UI
Get.back(); Get.back();
// 之后异步进行清理,不阻塞页面切换 // 异步清理资源
unawaited(_performShutdown()); Future.delayed(Duration.zero, () async {
} try {
if (isListening.value) {
/// 实际执行关闭逻辑(后台) await _realtimeService.stopRecording();
Future<void> _performShutdown() async { }
try { await _realtimeService.stopPlaying();
currentStatus.value = '正在关闭连接...'; await _realtimeService.disconnect();
await _realtimeService.dispose();
if (isListening.value) { _resetState();
await _realtimeService.stopRecording(); } catch (e) {
Logger.error('关闭连接失败: $e');
} }
await _realtimeService.stopPlaying(); });
await _realtimeService.disconnect();
_resetState();
Logger.info('实时语音连接已关闭');
} catch (e) {
Logger.error('关闭连接失败: $e');
}
} }
/// 重置状态 /// 重置状态
void _resetState() { void _resetState() {
isConnected.value = false;
isConnecting.value = false;
isListening.value = false; isListening.value = false;
isSpeaking.value = false; isSpeaking.value = false;
userSpeechText.value = ''; userSpeechText.value = '';
aiResponseText.value = ''; isUserSpeechFinal.value = true;
_resetAudioLevels(); aiResponseMessages.clear();
pulseAnimationController.stop(); latestUserMessage.value = '';
_stopAudioUpdates(); latestAiMessage.value = '';
partialUserMessage.value = '';
streamingAiMessage.value = '';
audioAmplitude.value = 0.0;
} }
/// 清理资源 /// 清理资源
void _cleanup() async { void _cleanup() async {
_eventSubscription?.cancel(); _eventSubscription?.cancel();
_stopAudioUpdates();
pulseAnimationController.dispose();
try { try {
await _realtimeService.dispose(); await _realtimeService.dispose();

452
lib/modules/realtime/views/realtime_view.dart

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/realtime_controller.dart'; import '../controllers/realtime_controller.dart';
import 'widgets/voice_mic_flow.dart'; import 'widgets/shader_mic_flow.dart';
class RealtimeView extends GetView<RealtimeController> { class RealtimeView extends GetView<RealtimeController> {
const RealtimeView({Key? key}) : super(key: key); const RealtimeView({Key? key}) : super(key: key);
@ -22,219 +22,311 @@ class RealtimeView extends GetView<RealtimeController> {
backgroundColor: Colors.white, backgroundColor: Colors.white,
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
children: [ children: [
// 右上角图标按钮 // 中间音频可视化
Positioned( Positioned.fill(
top: 16, child: Obx(() => AnimatedAlign(
right: 16, alignment: controller.isSubtitleMode.value
child: Row( ? const Alignment(0, -0.7) // 字幕模式时移到更上方
mainAxisSize: MainAxisSize.min, : const Alignment(0, -0.25),
children: [ duration: const Duration(milliseconds: 600),
// 分享按钮 curve: Curves.easeInOutCubic,
Icon( child: LayoutBuilder(
Icons.ios_share, builder: (context, constraints) {
color: Colors.grey[600], final targetSize = controller.isSubtitleMode.value
size: 24, ? MediaQuery.of(context).size.width * 0.55 // 字幕模式时较小
), : MediaQuery.of(context).size.width * 0.75; // 初始状态更大
const SizedBox(width: 20),
// 设置按钮 return TweenAnimationBuilder<double>(
Icon( tween: Tween(end: targetSize),
Icons.tune, duration: const Duration(milliseconds: 600),
color: Colors.grey[600], curve: Curves.easeInOutCubic,
size: 24, builder: (context, animatedSize, child) {
return Stack(
alignment: Alignment.center,
children: [
// 使用Shader动画
Obx(() => ShaderMicFlow(
size: animatedSize,
amplitude: controller.audioAmplitude.value,
)),
],
);
},
);
},
), ),
], )),
), ),
),
// 状态和字幕显示区域 // 字幕显示区域 - 只有在字幕模式开启时才显示,位于圆形下方
Positioned( if (controller.isSubtitleMode.value)
top: 100, Positioned(
left: 20, left: 20,
right: 20, right: 20,
child: Column( top: MediaQuery.of(context).size.height * 0.35, // 在圆形下方
children: [ bottom: 160,
// 当前状态显示 child: Padding(
Obx(() => Container( padding: const EdgeInsets.symmetric(horizontal: 24),
width: double.infinity, child: Obx(() => AnimatedOpacity(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), opacity: controller.isSubtitleMode.value ? 1.0 : 0.0,
margin: const EdgeInsets.only(bottom: 12), duration: const Duration(milliseconds: 300),
decoration: BoxDecoration( child: TweenAnimationBuilder<double>(
color: Colors.black.withOpacity(0.1), tween: Tween(
borderRadius: BorderRadius.circular(20), begin: 0.0,
), end: controller.isSubtitleMode.value ? 1.0 : 0.0,
child: Text( ),
controller.currentStatus.value, duration: const Duration(milliseconds: 900), // 延迟显示,让圆圈先移动
textAlign: TextAlign.center, curve: Curves.easeInOut,
style: const TextStyle( builder: (context, animationValue, child) {
fontSize: 16, return Transform.translate(
fontWeight: FontWeight.w500, offset: Offset(0, 20 * (1 - animationValue)), // 从下方滑入
color: Colors.black87, child: Opacity(
opacity: animationValue,
child: _buildChatSubtitles(),
),
);
},
), ),
), )),
)), ),
),
// 用户ASR内容显示
Obx(() => AnimatedContainer( // 底部控制按钮
duration: const Duration(milliseconds: 300), Positioned(
width: double.infinity, bottom: 80,
height: controller.userSpeechText.value.isNotEmpty ? null : 0, left: 0,
padding: controller.userSpeechText.value.isNotEmpty right: 0,
? const EdgeInsets.symmetric(horizontal: 16, vertical: 12) child: Center(
: EdgeInsets.zero, child: Row(
margin: controller.userSpeechText.value.isNotEmpty mainAxisAlignment: MainAxisAlignment.spaceEvenly,
? const EdgeInsets.only(bottom: 8) children: [
: EdgeInsets.zero, // 麦克风按钮
decoration: BoxDecoration( Obx(() => _buildMicButton()),
color: Colors.blue.withOpacity(0.1), // 字幕按钮
borderRadius: BorderRadius.circular(16), Obx(() => _buildMainButton(
border: Border.all( icon: controller.isSubtitleMode.value ? Icons.subtitles : Icons.subtitles_outlined,
color: Colors.blue.withOpacity(0.3), onTap: controller.toggleSubtitleMode,
width: 1, enabled: true,
isActive: controller.isSubtitleMode.value,
)),
// 清空聊天按钮
_buildMainButton(
icon: Icons.clear_all,
onTap: controller.clearChat,
enabled: true,
isActive: false,
), ),
), // 关闭按钮
child: controller.userSpeechText.value.isNotEmpty _buildMainButton(
? Column( icon: Icons.close,
crossAxisAlignment: CrossAxisAlignment.start, onTap: controller.closeAndGoBack,
children: [ enabled: true,
const Text( isActive: false,
'你说:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.blue,
),
),
const SizedBox(height: 4),
Text(
controller.userSpeechText.value,
style: const TextStyle(
fontSize: 14,
color: Colors.black87,
),
),
],
)
: null,
)),
// AI回复内容显示
Obx(() => AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: double.infinity,
height: controller.aiResponseText.value.isNotEmpty ? null : 0,
padding: controller.aiResponseText.value.isNotEmpty
? const EdgeInsets.symmetric(horizontal: 16, vertical: 12)
: EdgeInsets.zero,
margin: controller.aiResponseText.value.isNotEmpty
? const EdgeInsets.only(bottom: 20)
: EdgeInsets.zero,
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.green.withOpacity(0.3),
width: 1,
), ),
), ],
child: controller.aiResponseText.value.isNotEmpty ),
? Column( ),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'AI回复:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.green,
),
),
const SizedBox(height: 4),
Text(
controller.aiResponseText.value,
style: const TextStyle(
fontSize: 14,
color: Colors.black87,
),
),
],
)
: null,
)),
],
), ),
),
// 中间会呼吸的云彩动画 // Home Indicator
Positioned.fill( Positioned(
child: Align( bottom: 8,
alignment: const Alignment(0, 0.1), left: 0,
child: const VoiceMicFlow(size: 280), right: 0,
child: Center(
child: Container(
width: 134,
height: 5,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(2.5),
),
),
),
), ),
), ],
),
),
);
}
// 底部控制按钮 /// 构建聊天字幕界面 - 上下两行显示用户输入和AI回复
Positioned( Widget _buildChatSubtitles() {
bottom: 80, return Obx(() {
left: 0, // 组合用户消息:优先显示实时部分转录,否则显示最终消息
right: 0, final String displayUserMessage = controller.partialUserMessage.value.isNotEmpty
child: Padding( ? controller.partialUserMessage.value
padding: const EdgeInsets.symmetric(horizontal: 40), : controller.latestUserMessage.value;
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // 组合AI消息:优先显示流式响应,否则显示最终消息
children: [ final String displayAiMessage = controller.streamingAiMessage.value.isNotEmpty
// 摄像头按钮 ? controller.streamingAiMessage.value
_buildBottomButton(Icons.videocam, null), : controller.latestAiMessage.value;
// 麦克风按钮
_buildBottomButton(Icons.mic_none, controller.toggleListening), final hasUserMessage = displayUserMessage.isNotEmpty;
// 更多按钮 final hasAiMessage = displayAiMessage.isNotEmpty;
_buildBottomButton(Icons.more_horiz, null),
// 关闭按钮 // 如果没有消息,显示空白
_buildBottomButton(Icons.close, controller.closeAndGoBack), if (!hasUserMessage && !hasAiMessage) {
], return Container();
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, // 让Column紧凑排列
children: [
// 用户输入行 - 蓝色,动态扩展
if (hasUserMessage)
Container(
width: double.infinity,
constraints: const BoxConstraints(
minHeight: 30, // 最小1行高度
maxHeight: 104, // 最大4行高度 (26*4)
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: SingleChildScrollView(
reverse: true,
child: Text(
displayUserMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
color: Colors.blue[600],
height: 1.4,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
),
), ),
), ),
), ),
// Home Indicator // 分隔线 - 只有在两个消息都存在时才显示
Positioned( if (hasUserMessage && hasAiMessage)
bottom: 8, Container(
left: 0, width: 60,
right: 0, height: 1,
child: Center( margin: const EdgeInsets.symmetric(vertical: 2), // 进一步减少分隔线间距
child: Container( decoration: BoxDecoration(
width: 134, color: Colors.grey[300],
height: 5, borderRadius: BorderRadius.circular(0.5),
decoration: BoxDecoration( ),
color: Colors.black, ),
borderRadius: BorderRadius.circular(2.5),
// AI回复行 - 绿色,动态扩展
if (hasAiMessage)
Container(
width: double.infinity,
constraints: const BoxConstraints(
minHeight: 30, // 最小1行高度
maxHeight: 104, // 最大4行高度 (26*4)
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: SingleChildScrollView(
reverse: true,
child: Text(
displayAiMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
color: Colors.green[600],
height: 1.4,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
), ),
), ),
), ),
), ),
], ],
);
});
}
/// 构建麦克风按钮 - 根据连接状态和录音状态显示不同的状态
Widget _buildMicButton() {
// 获取当前状态
final isConnected = controller.isConnected.value;
final isConnecting = controller.isConnecting.value;
final isListening = controller.isListening.value;
// 确定按钮状态
IconData icon;
Color? backgroundColor;
Color? iconColor;
VoidCallback? onTap;
if (!isConnected && !isConnecting) {
// 未连接状态 - 禁用
icon = Icons.mic_off;
backgroundColor = Colors.grey[300];
iconColor = Colors.grey[600];
onTap = null;
} else if (isConnecting) {
// 连接中状态 - 禁用但显示连接中
icon = Icons.sync;
backgroundColor = Colors.orange[200];
iconColor = Colors.orange[700];
onTap = null;
} else if (isListening) {
// 录音中状态 - 激活状态
icon = Icons.mic;
backgroundColor = Theme.of(Get.context!).primaryColor;
iconColor = Colors.white;
onTap = controller.toggleListening;
} else {
// 已连接但未录音状态 - 正常状态
icon = Icons.mic_none;
backgroundColor = Colors.grey[200];
iconColor = Colors.black;
onTap = controller.toggleListening;
}
return GestureDetector(
onTap: onTap,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: Icon(
icon,
color: iconColor,
size: 26,
), ),
), ),
); );
} }
/// 构建底部圆形按钮 /// 构建主要控制按钮
Widget _buildBottomButton(IconData icon, VoidCallback? onTap) { Widget _buildMainButton({
required IconData icon,
VoidCallback? onTap,
required bool enabled,
required bool isActive,
}) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: enabled ? onTap : null,
child: Container( child: Container(
width: 60, width: 60,
height: 60, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[200], color: enabled
? (isActive ? Theme.of(Get.context!).primaryColor : Colors.grey[200])
: Colors.grey[300],
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
icon, icon,
color: Colors.black, color: enabled
? (isActive ? Colors.white : Colors.black)
: Colors.grey[600],
size: 26, size: 26,
), ),
), ),
); );
} }
} }

104
lib/modules/realtime/views/widgets/shader_mic_flow.dart

@ -0,0 +1,104 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// 使用Shader实现的麦克风流动动画组件
class ShaderMicFlow extends StatefulWidget {
final double size;
final double amplitude;
const ShaderMicFlow({
super.key,
required this.size,
this.amplitude = 0.0,
});
@override
State<ShaderMicFlow> createState() => _ShaderMicFlowState();
}
class _ShaderMicFlowState extends State<ShaderMicFlow>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
FragmentShader? _shader;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 60),
vsync: this,
)..repeat();
_loadShader();
}
Future<void> _loadShader() async {
try {
final program = await FragmentProgram.fromAsset('shaders/mic_flow.frag');
setState(() {
_shader = program.fragmentShader();
});
} catch (e) {
debugPrint('Failed to load shader: $e');
}
}
@override
void dispose() {
_controller.dispose();
_shader?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: _shader != null
? _ShaderPainter(
shader: _shader!,
time: _controller.value * 60.0,
amplitude: widget.amplitude,
)
: null,
size: Size.square(widget.size),
);
},
),
);
}
}
class _ShaderPainter extends CustomPainter {
final FragmentShader shader;
final double time;
final double amplitude;
_ShaderPainter({
required this.shader,
required this.time,
required this.amplitude,
});
@override
void paint(Canvas canvas, Size size) {
// 设置shader uniforms
shader.setFloat(0, size.width); // u_resolution.x
shader.setFloat(1, size.height); // u_resolution.y
shader.setFloat(2, time); // u_time
shader.setFloat(3, amplitude); // u_amp
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant _ShaderPainter oldDelegate) {
return oldDelegate.time != time || oldDelegate.amplitude != amplitude;
}
}

89
lib/modules/realtime/views/widgets/voice_mic_flow.dart

@ -1,89 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_shaders/flutter_shaders.dart';
class VoiceMicFlow extends StatefulWidget {
const VoiceMicFlow({super.key, this.size = 280, this.amp = 0.0});
final double size;
final double amp; // 音量振幅 0.0-1.0
@override
State<VoiceMicFlow> createState() => _VoiceMicFlowState();
}
class _VoiceMicFlowState extends State<VoiceMicFlow>
with SingleTickerProviderStateMixin {
late final AnimationController _ctl;
@override
void initState() {
super.initState();
_ctl = AnimationController(
duration: const Duration(seconds: 100), // 100秒的长周期
vsync: this,
)..repeat();
}
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClipOval(
child: ShaderBuilder(
assetKey: 'shaders/mic_flow.frag',
(context, shader, child) {
return AnimatedBuilder(
animation: _ctl,
builder: (_, __) {
// 将0.0-1.0的动画值映射到更大的时间范围
final time = _ctl.value * 100.0;
shader
..setFloat(0, widget.size) // u_resolution.x
..setFloat(1, widget.size) // u_resolution.y
..setFloat(2, time) // u_time
..setFloat(3, widget.amp); // u_amp (音量振幅)
return CustomPaint(
size: Size.square(widget.size),
painter: _ShaderPainter(shader),
);
},
);
},
// 着色器编译时的占位符
child: Container(
width: widget.size,
height: widget.size,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1565C0), // 深蓝
Color(0xFFE3F2FD), // 浅蓝
],
),
),
),
),
);
}
}
class _ShaderPainter extends CustomPainter {
_ShaderPainter(this.shader);
final FragmentShader shader;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant _ShaderPainter oldDelegate) => true;
}

26
lib/modules/settings/controllers/settings_controller.dart

@ -52,6 +52,32 @@ class SettingsController extends GetxController {
name: 'malay', alias: 'Bahasa Melayu', value: 'ms_MY'), // 添加马来语选项 name: 'malay', alias: 'Bahasa Melayu', value: 'ms_MY'), // 添加马来语选项
LanguageModel(name: 'turkish', alias: 'Türkçe', value: 'tr_TR'), // 添加土耳其语选项 LanguageModel(name: 'turkish', alias: 'Türkçe', value: 'tr_TR'), // 添加土耳其语选项
LanguageModel(name: 'polish', alias: 'Polski', value: 'pl_PL'), // 添加波兰语选项 LanguageModel(name: 'polish', alias: 'Polski', value: 'pl_PL'), // 添加波兰语选项
LanguageModel(name: 'filipino', alias: 'Filipino', value: 'tl_PH'), // 菲律宾语
LanguageModel(name: 'dutch', alias: 'Nederlands', value: 'nl_NL'), // 荷兰语
LanguageModel(name: 'swedish', alias: 'Svenska', value: 'sv_SE'), // 瑞典语
LanguageModel(name: 'danish', alias: 'Dansk', value: 'da_DK'), // 丹麦语
LanguageModel(name: 'finnish', alias: 'Suomi', value: 'fi_FI'), // 芬兰语
LanguageModel(name: 'czech', alias: 'Čeština', value: 'cs_CZ'), // 捷克语
LanguageModel(name: 'hungarian', alias: 'Magyar', value: 'hu_HU'), // 匈牙利语
LanguageModel(name: 'norwegian', alias: 'Norsk', value: 'nb_NO'), // 挪威语
LanguageModel(name: 'romanian', alias: 'Română', value: 'ro_RO'), // 罗马尼亚语
LanguageModel(
name: 'ukrainian', alias: 'Українська', value: 'uk_UA'), // 乌克兰语
LanguageModel(name: 'greek', alias: 'Ελληνικά', value: 'el_GR'), // 希腊语
LanguageModel(
name: 'bulgarian', alias: 'Български', value: 'bg_BG'), // 保加利亚语
LanguageModel(name: 'croatian', alias: 'Hrvatski', value: 'hr_HR'), // 克罗地亚语
LanguageModel(name: 'slovak', alias: 'Slovenský', value: 'sk_SK'), // 斯洛伐克语
LanguageModel(
name: 'slovenian', alias: 'Slovenščina', value: 'sl_SI'), // 斯洛文尼亚语
LanguageModel(
name: 'lithuanian', alias: 'Lietuvių', value: 'lt_LT'), // 立陶宛语
LanguageModel(name: 'latvian', alias: 'Latviešu', value: 'lv_LV'), // 拉脱维亚语
LanguageModel(name: 'estonian', alias: 'Eesti', value: 'et_EE'), // 爱沙尼亚语
LanguageModel(
name: 'serbian', alias: 'Srpski', value: 'sr_RS'), // 塞尔维亚语(拉丁)
LanguageModel(
name: 'icelandic', alias: 'Íslenska', value: 'is_IS'), // 新增冰岛语
]; ];
Rx<LanguageModel> language = LanguageModel().obs; Rx<LanguageModel> language = LanguageModel().obs;

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

@ -512,8 +512,7 @@ class SettingsView extends GetView<SettingsController> {
iconBgColor: isDarkMode iconBgColor: isDarkMode
? Colors.red[900]!.withOpacity(0.3) ? Colors.red[900]!.withOpacity(0.3)
: Colors.red[100]!, : Colors.red[100]!,
iconColor: iconColor: isDarkMode ? Colors.red[300]! : Colors.red[600]!,
isDarkMode ? Colors.red[300]! : Colors.red[600]!,
titleColor: isDarkMode ? Colors.white : null, titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null, subtitleColor: isDarkMode ? Colors.white70 : null,
onTap: () { onTap: () {
@ -550,7 +549,6 @@ class SettingsView extends GetView<SettingsController> {
? Colors.white.withOpacity(0.1) ? Colors.white.withOpacity(0.1)
: Colors.grey[200]), : Colors.grey[200]),
// BLE测试 // BLE测试
_buildSimpleNavigationSetting( _buildSimpleNavigationSetting(
title: 'BLE蓝牙测试', title: 'BLE蓝牙测试',
@ -627,8 +625,6 @@ class SettingsView extends GetView<SettingsController> {
? Colors.white.withOpacity(0.1) ? Colors.white.withOpacity(0.1)
: Colors.grey[200]), : Colors.grey[200]),
// 退出登录 // 退出登录
// _buildSimpleNavigationSetting( // _buildSimpleNavigationSetting(
// title: 'logout'.tr, // 退出登录 // title: 'logout'.tr, // 退出登录
@ -917,7 +913,8 @@ class SettingsView extends GetView<SettingsController> {
), ),
), ),
SizedBox(height: 4.h), SizedBox(height: 4.h),
if (Get.locale?.languageCode == 'zh') if (Get.locale?.languageCode == 'zh' &&
Get.locale?.countryCode == 'CN')
InkWell( InkWell(
onTap: () => Get.to( onTap: () => Get.to(
() => const WebViewPage( () => const WebViewPage(

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

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

324
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.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit 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语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 * Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别
@ -65,9 +58,13 @@ class AzureAsrHelper(private val context: Context) {
private var microphoneStream: MicrophoneStream? = null private var microphoneStream: MicrophoneStream? = null
private var externalAudioStream: ExternalAudioPullStream? = null private var externalAudioStream: ExternalAudioPullStream? = null
// 音频模式管理
private var audioManager: AudioManager? = null
private var originalAudioMode = AudioManager.MODE_NORMAL
// 录音文件处理 // 录音文件处理
var recordfile: RecordingFile? = null var recordfile: RecordFile? = null
private var isRecording = false
/** /**
* 音频来源类型 * 音频来源类型
@ -105,6 +102,10 @@ class AzureAsrHelper(private val context: Context) {
// 释放之前的资源 // 释放之前的资源
dispose() dispose()
// 初始化音频管理器
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL
// 保存配置 // 保存配置
this.subscriptionKey = subscriptionKey this.subscriptionKey = subscriptionKey
this.region = region this.region = region
@ -145,7 +146,7 @@ class AzureAsrHelper(private val context: Context) {
setProperty("Speech_SegmentationStrategy", "Time") setProperty("Speech_SegmentationStrategy", "Time")
} }
// 录音文件类 // 录音文件类
recordfile = RecordingFile() recordfile = RecordFile;
// 创建识别器 // 创建识别器
val setupSuccess = setupRecognizer() val setupSuccess = setupRecognizer()
if (setupSuccess) { if (setupSuccess) {
@ -171,36 +172,9 @@ class AzureAsrHelper(private val context: Context) {
// 设置音频配置 // 设置音频配置
when (audioSourceType) { when (audioSourceType) {
AudioSourceType.MICROPHONE -> { AudioSourceType.MICROPHONE -> {
// 判断是否使用手机扬声器输出和耳机连接状态 // 总是使用拉流方式并启用回音消除
val audioManager = Log.i(tag, "使用拉流方式并启用回音消除")
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager setupMicrophoneStream()
val isSpeakerphoneOn = audioManager.isSpeakerphoneOn
// 检查是否连接了有线耳机
val isWiredHeadsetOn = audioManager.isWiredHeadsetOn
// 检查是否连接了蓝牙设备(需要注意兼容性)
val isBluetoothConnected = try {
val method = AudioManager::class.java.getMethod("isBluetoothA2dpOn")
method.invoke(audioManager) as Boolean
} catch (e: Exception) {
false
}
val isHeadsetConnected = isWiredHeadsetOn || isBluetoothConnected
// 如果使用手机扬声器输出且没有连接耳机,可能会产生回音,需启动回音消除功能
val needEchoCancellation = !isHeadsetConnected
if (needEchoCancellation) {
Log.i(tag, "使用拉流方式进行回音消除")
// 使用拉流方式进行回音消除
setupMicrophoneStream()
} else {
Log.i(tag, "使用拉流方式进行回音消除2")
audioConfig = AudioConfig.fromDefaultMicrophoneInput()
setupMicrophoneStream()
}
} }
AudioSourceType.EXTERNAL -> { AudioSourceType.EXTERNAL -> {
@ -379,10 +353,7 @@ class AzureAsrHelper(private val context: Context) {
// 开始连续识别 // 开始连续识别
recognizer?.startContinuousRecognitionAsync() recognizer?.startContinuousRecognitionAsync()
isContinuousRecognitionActive = true isContinuousRecognitionActive = true
Log.e(tag, "开始连续语音识别 是否录音${isRecording}")
if (isRecording) {
enableRecord()
}
return true return true
} catch (e: Exception) { } catch (e: Exception) {
stopAudioProcessing() stopAudioProcessing()
@ -529,7 +500,7 @@ class AzureAsrHelper(private val context: Context) {
// 停止音频处理 // 停止音频处理
stopAudioProcessing() stopAudioProcessing()
// 停止录音 // 停止录音
recordfile!!.closeFile() recordfile!!.closeFile(true)
// 释放recognizer // 释放recognizer
recognizer?.close() recognizer?.close()
recognizer = null recognizer = null
@ -541,7 +512,7 @@ class AzureAsrHelper(private val context: Context) {
// 释放音频配置 // 释放音频配置
audioConfig?.close() audioConfig?.close()
audioConfig = null audioConfig = null
isRecording = false
// 确保状态被重置 // 确保状态被重置
isContinuousRecognitionActive = false isContinuousRecognitionActive = false
microphoneStream = null microphoneStream = null
@ -551,7 +522,7 @@ class AzureAsrHelper(private val context: Context) {
speechConfig = null speechConfig = null
} catch (e: Exception) { } catch (e: Exception) {
// 确保状态被重置 // 确保状态被重置
isRecording = false
isContinuousRecognitionActive = false isContinuousRecognitionActive = false
microphoneStream = null microphoneStream = null
externalAudioStream = null externalAudioStream = null
@ -607,41 +578,56 @@ class AzureAsrHelper(private val context: Context) {
/** /**
* 开启录音 * 开启录音
*/ */
fun enableRecord() { fun enableRecord(filePath: String) {
Log.i(tag, "开启录音:") 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() { fun pauseRecord() {
Log.i(tag, "停止连续录音:") Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=true recordfile!!.isPause = true
} }
/** /**
* 停止录音 * 关闭录音
*/ */
fun stopRecord() { fun stopRecord(isSave: Boolean) {
Log.i(tag, "停止连续录音:") Log.i(tag, "关闭录音:")
isRecording = false
recordfile!!.isPause=false recordfile!!.isPause = false
recordfile!!.closeFile() recordfile!!.closeFile(isSave)
} }
/** /**
@ -651,6 +637,8 @@ else{
private inner class MicrophoneStream : PullAudioInputStreamCallback() { private inner class MicrophoneStream : PullAudioInputStreamCallback() {
private var audioRecord: AudioRecord? = null private var audioRecord: AudioRecord? = null
private var echoCanceler: AcousticEchoCanceler? = null private var echoCanceler: AcousticEchoCanceler? = null
private var noiseSuppressor: NoiseSuppressor? = null
private var automaticGainControl: AutomaticGainControl? = null
// 音频配置 // 音频配置
private val sampleRate = 16000 private val sampleRate = 16000
@ -715,19 +703,45 @@ else{
} }
/** /**
* 优化:条件性应用音频效果 * 应用音频效果 - 总是启用所有音频增强功能
*/ */
private fun applyAudioEffects() { private fun applyAudioEffects() {
val sessionId = audioRecord?.audioSessionId ?: return val sessionId = audioRecord?.audioSessionId ?: return
// 优化:只在真正需要时启用回音消除 try {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager // 设置音频模式为通信模式
if (audioManager.isSpeakerphoneOn) { this@AzureAsrHelper.audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
echoCanceler = AcousticEchoCanceler.create(sessionId)
echoCanceler?.enabled = true // 启用声学回音消除
Log.d(tag, "启用回音消除") if (AcousticEchoCanceler.isAvailable()) {
} else { echoCanceler = AcousticEchoCanceler.create(sessionId)
Log.d(tag, "跳过回音消除(使用耳机)") echoCanceler?.enabled = true
Log.i(tag, "声学回音消除已启用")
} else {
Log.w(tag, "设备不支持声学回音消除")
}
// 启用噪声抑制
if (NoiseSuppressor.isAvailable()) {
noiseSuppressor = NoiseSuppressor.create(sessionId)
noiseSuppressor?.enabled = true
Log.i(tag, "噪声抑制已启用")
} else {
Log.w(tag, "设备不支持噪声抑制")
}
// 启用自动增益控制
if (AutomaticGainControl.isAvailable()) {
automaticGainControl = AutomaticGainControl.create(sessionId)
automaticGainControl?.enabled = true
Log.i(tag, "自动增益控制已启用")
} else {
Log.w(tag, "设备不支持自动增益控制")
}
Log.i(tag, "回音消除和音频增强功能已全部启用")
} catch (e: Exception) {
Log.e(tag, "设置音频效果失败: ${e.message}")
} }
} }
@ -777,13 +791,34 @@ else{
} }
// 释放回音消除器 // 释放回音消除器
echoCanceler?.release() echoCanceler?.let {
echoCanceler = null it.enabled = false
it.release()
echoCanceler = null
}
// 释放噪声抑制器
noiseSuppressor?.let {
it.enabled = false
it.release()
noiseSuppressor = null
}
// 释放自动增益控制
automaticGainControl?.let {
it.enabled = false
it.release()
automaticGainControl = null
}
// 恢复音频模式
this@AzureAsrHelper.audioManager?.mode = this@AzureAsrHelper.originalAudioMode
// 释放录音实例 // 释放录音实例
audioRecord?.release() audioRecord?.release()
audioRecord = null audioRecord = null
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "释放音频资源失败: ${e.message}")
e.printStackTrace() e.printStackTrace()
} }
} }
@ -911,133 +946,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
}
}
}
} }

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

@ -11,7 +11,6 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import com.microsoft.cognitiveservices.speech.audio.*
import com.deep_voice.speech.tts.TtsEvent import com.deep_voice.speech.tts.TtsEvent
import com.deep_voice.speech.tts.TtsEventListener import com.deep_voice.speech.tts.TtsEventListener
import com.deep_voice.speech.tts.TtsEventType import com.deep_voice.speech.tts.TtsEventType
@ -354,24 +353,57 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
result.success(true) result.success(true)
} }
"renameFile" -> {
val filePath = call.argument<String>("filePath") ?: ""
val newName = call.argument<String>("newName") ?: ""
try {
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" -> { "enableRecord" -> {
val fileName = call.argument<String>("fileName")?: "" val filePath = call.argument<String>("filePath") ?: ""
try { try {
FileLogger.d(tag, "音频文件名称为: ${fileName}") // FileLogger.d(tag, "音频文件名称为: ${filePath}") //
azureAsrHelper.recordfile?.fileName= fileName!!
azureAsrHelper.enableRecord() azureAsrHelper.enableRecord(filePath)
result.success(true) result.success(true)
} catch (e: Exception) { } catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null) result.error("ENABLERECORD_ERROR", e.message, null)
} }
} }
"pauseRecord" -> {
"pauseRecord" -> {
azureAsrHelper.pauseRecord() azureAsrHelper.pauseRecord()
result.success(true) result.success(true)
} }
"stopRecord" -> { "stopRecord" -> {
azureAsrHelper.stopRecord() val isSave = call.argument<Boolean>("isSave") ?: false
result.success(true) try {
azureAsrHelper.stopRecord(isSave)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
} }
else -> { else -> {

44
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt

@ -71,15 +71,21 @@ class AzureTtsHelper(private val context: Context) : ITtsService {
// 创建语音配置 // 创建语音配置
speechConfig = SpeechConfig.fromSubscription(ttsAppToken, ttsResource) speechConfig = SpeechConfig.fromSubscription(ttsAppToken, ttsResource)
// 优化:使用更低延迟的音频格式(从24KHz降到16KHz) // 使用标准的音频格式(16KHz, 16bit, 单声道)
speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff16Khz16BitMonoPcm) speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff16Khz16BitMonoPcm)
// 确保使用正确的语音输出
speechConfig?.setProperty("SPEECH-AudioOutputFormat", "riff-16khz-16bit-mono-pcm")
// 优化:设置低延迟连接属性 // 优化:设置低延迟连接属性
speechConfig?.setProperty("SpeechServiceConnection_InitialSilenceTimeoutMs", "300") speechConfig?.setProperty("SpeechServiceConnection_InitialSilenceTimeoutMs", "300")
speechConfig?.setProperty("SpeechServiceConnection_EndSilenceTimeoutMs", "300") speechConfig?.setProperty("SpeechServiceConnection_EndSilenceTimeoutMs", "300")
speechConfig?.setSpeechSynthesisVoiceName(currentVoice) speechConfig?.setSpeechSynthesisVoiceName(currentVoice)
// 添加日志记录语音格式
FileLogger.d(TAG, "语音设置: voice=$currentVoice, format=Riff16Khz16BitMonoPcm")
// 创建音频配置 // 创建音频配置
val audioConfig = if (customAudioOutputStream != null) { val audioConfig = if (customAudioOutputStream != null) {
// 使用自定义音频输出流 // 使用自定义音频输出流
@ -185,21 +191,47 @@ class AzureTtsHelper(private val context: Context) : ITtsService {
private fun setupEventListeners() { private fun setupEventListeners() {
synthesizer?.apply { synthesizer?.apply {
// 合成开始事件 // 合成开始事件
SynthesisStarted?.addEventListener { _, _ -> SynthesisStarted?.addEventListener { _, eventArgs ->
FileLogger.d(TAG, "语音合成开始: resultId=${eventArgs.result.resultId}")
notifyEvent(TtsEventType.SYNTHESIS_STARTED) notifyEvent(TtsEventType.SYNTHESIS_STARTED)
} }
// 合成中事件(接收音频数据)
Synthesizing?.addEventListener { _, eventArgs ->
val audioData = eventArgs.result.audioData
FileLogger.d(TAG, "接收到音频数据: ${audioData.size} 字节")
// 通知音频数据监听器
if (audioData.isNotEmpty()) {
val listeners = ArrayList(audioDataListeners)
for (listener in listeners) {
try {
listener.onAudioData(audioData)
} catch (e: Exception) {
FileLogger.e(TAG, "通知音频数据异常: ${e.message}")
}
}
}
}
// 合成完成事件 // 合成完成事件
SynthesisCompleted?.addEventListener { _, _ -> SynthesisCompleted?.addEventListener { _, eventArgs ->
FileLogger.d(TAG, "语音合成完成: resultId=${eventArgs.result.resultId}, 音频长度=${eventArgs.result.audioLength} 字节")
isSpeaking = false isSpeaking = false
notifyEvent(TtsEventType.SYNTHESIS_COMPLETED) notifyEvent(TtsEventType.SYNTHESIS_COMPLETED)
} }
// 合成取消事件 // 合成取消事件
SynthesisCanceled?.addEventListener { _, eventArgs -> SynthesisCanceled?.addEventListener { _, eventArgs ->
FileLogger.d(TAG, "语音合成取消") val reason = eventArgs.result.reason
FileLogger.e(TAG, "语音合成取消: reason=$reason")
if (reason == ResultReason.Canceled) {
val cancellation = SpeechSynthesisCancellationDetails.fromResult(eventArgs.result)
FileLogger.e(TAG, "取消详情: reason=${cancellation.reason}, errorCode=${cancellation.errorCode}, errorDetails=${cancellation.errorDetails}")
}
isSpeaking = false isSpeaking = false
notifyEvent(TtsEventType.SYNTHESIS_CANCELED) notifyEvent(TtsEventType.SYNTHESIS_CANCELED)
} }
} }
@ -397,6 +429,8 @@ class AzureTtsHelper(private val context: Context) : ITtsService {
isSpeaking = true isSpeaking = true
FileLogger.d(TAG, "开始语音合成,文本长度: ${text.length}")
// 直接调用SpeakSsmlAsync,SDK内部已经是异步的 // 直接调用SpeakSsmlAsync,SDK内部已经是异步的
synthesizer?.SpeakSsmlAsync(ssml) synthesizer?.SpeakSsmlAsync(ssml)

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

@ -0,0 +1,254 @@
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
}
}

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

@ -16,14 +16,10 @@ import com.jieli.jl_audio_decode.exceptions.OpusException
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import android.os.Handler import android.os.Handler
import android.os.HandlerThread
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log import android.util.Log
/** /**

2
local_plugins/location_service/pubspec.yaml

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.8.1 sdk: '>=3.8.0'
flutter: '>=3.3.0' flutter: '>=3.3.0'
dependencies: dependencies:

55
local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt

@ -268,32 +268,36 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler {
streamSink = null streamSink = null
} }
}) })
// 初始化 otaManager 和 bluetoothHelper
ConfigHelper.initialize(context)
otaManager = OTAManager(context)
bluetoothHelper = BluetoothHelper(context)
// 如果需要额外的初始化逻辑,请在这里添加
otaManager.registerBluetoothCallback(bluetoothCallback)
// 初始化BleService
// 2. 初始化BleService并注册回调
if (BleService.initialize(context)) {
if (BleService.addCallback(this)) {
isRegisteredToBle = true
Log.d(TAG, "已注册BLE回调")
} else {
Log.w(TAG, "BLE回调注册失败")
}
} else {
Log.e(TAG, "BLE服务初始化失败")
}
Log.i("TAG", "onAttachedToEngine")
} }
override fun onMethodCall(call: MethodCall, result: Result) { override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "startUpgrading") { if (call.method == "init") {
// 初始化 otaManager 和 bluetoothHelper
ConfigHelper.initialize(context)
otaManager = OTAManager(context)
bluetoothHelper = BluetoothHelper(context)
// 如果需要额外的初始化逻辑,请在这里添加
otaManager.registerBluetoothCallback(bluetoothCallback)
// 初始化BleService
// 2. 初始化BleService并注册回调
if (BleService.initialize(context)) {
if (BleService.addCallback(this)) {
isRegisteredToBle = true
Log.d(TAG, "已注册BLE回调")
} else {
Log.w(TAG, "BLE回调注册失败")
}
} else {
Log.e(TAG, "BLE服务初始化失败")
}
Log.i("TAG", "onAttachedToEngine")
result.success("")
} else if (call.method == "startUpgrading") {
Log.d("TAG", "startUpgrading") Log.d("TAG", "startUpgrading")
// 接收传入的参数 // 接收传入的参数
val arguments = call.arguments as? Map<String, Any> val arguments = call.arguments as? Map<String, Any>
@ -326,13 +330,16 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler {
BleService.isEnterOta = false BleService.isEnterOta = false
} }
result.success("")
} else if (call.method == "onOtaClose") { } else if (call.method == "onOtaClose") {
BleService.isEnterOta = false BleService.isEnterOta = false
isDisconnect = false isDisconnect = false
otaManager.bluetoothHelper.stopScan() otaManager?.bluetoothHelper?.stopScan()
otaManager.bluetoothHelper.disconnectDevice(otaDevice)
otaManager?.bluetoothHelper?.disconnectDevice(otaDevice)
result.success("")
} else { } else {
result.notImplemented() result.notImplemented()
} }

270
local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt

@ -2,9 +2,17 @@ package com.yunqiinnovation.realtime
import android.content.Context import android.content.Context
import android.media.* import android.media.*
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.util.Log import android.util.Log
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import kotlin.math.sqrt
/** /**
* 实时音频管理器 * 实时音频管理器
@ -29,10 +37,19 @@ class RealtimeAudioManager(private val context: Context) {
private var isPlaying = false private var isPlaying = false
private var playJob: Job? = null private var playJob: Job? = null
private val playbackQueue = LinkedBlockingQueue<ByteArray>() private val playbackQueue = LinkedBlockingQueue<ByteArray>()
private val playbackInternalBuffer = ByteArrayOutputStream()
// 回音消除相关
private var acousticEchoCanceler: AcousticEchoCanceler? = null
private var noiseSuppressor: NoiseSuppressor? = null
private var automaticGainControl: AutomaticGainControl? = null
private var audioManager: AudioManager? = null
private var originalAudioMode = AudioManager.MODE_NORMAL
// 回调 // 回调
var onAudioData: ((ByteArray) -> Unit)? = null var onAudioData: ((ByteArray) -> Unit)? = null
var onStatusChanged: ((VoiceStatus) -> Unit)? = null var onStatusChanged: ((VoiceStatus) -> Unit)? = null
var onRmsChanged: ((Float) -> Unit)? = null // 新增RMS回调
// 协程作用域 // 协程作用域
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -40,6 +57,12 @@ class RealtimeAudioManager(private val context: Context) {
/// 初始化音频管理器 /// 初始化音频管理器
fun initialize(config: AudioConfig): Boolean { fun initialize(config: AudioConfig): Boolean {
this.audioConfig = config this.audioConfig = config
// 初始化系统音频管理器
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// 保存原始音频模式
originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL
return setupAudioRecord(config) && setupAudioTrack(config) return setupAudioRecord(config) && setupAudioTrack(config)
} }
@ -117,13 +140,14 @@ class RealtimeAudioManager(private val context: Context) {
return false return false
} }
// 总是使用VOICE_COMMUNICATION模式以获得最佳语音处理效果
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
audioTrack = AudioTrack.Builder() audioTrack = AudioTrack.Builder()
.setAudioAttributes( .setAudioAttributes(audioAttributes)
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat( .setAudioFormat(
AudioFormat.Builder() AudioFormat.Builder()
.setSampleRate(config.sampleRate) .setSampleRate(config.sampleRate)
@ -140,7 +164,7 @@ class RealtimeAudioManager(private val context: Context) {
return false return false
} }
Log.i(TAG, "AudioTrack初始化成功") Log.i(TAG, "AudioTrack初始化成功, 启用语音通信模式")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "设置AudioTrack失败: ${e.message}", e) Log.e(TAG, "设置AudioTrack失败: ${e.message}", e)
@ -155,6 +179,9 @@ class RealtimeAudioManager(private val context: Context) {
} }
try { try {
// 设置回音消除(总是启用)
setupEchoCancellation()
audioRecord?.startRecording() audioRecord?.startRecording()
isRecording = true isRecording = true
onStatusChanged?.invoke(VoiceStatus.RECORDING) onStatusChanged?.invoke(VoiceStatus.RECORDING)
@ -164,7 +191,7 @@ class RealtimeAudioManager(private val context: Context) {
loopRecordSend() loopRecordSend()
} }
Log.i(TAG, "开始录音") Log.i(TAG, "开始录音, 回音消除: ${acousticEchoCanceler?.enabled}")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "开始录音失败: ${e.message}", e) Log.e(TAG, "开始录音失败: ${e.message}", e)
@ -185,12 +212,16 @@ class RealtimeAudioManager(private val context: Context) {
if (bytesRead > 0) { if (bytesRead > 0) {
// 确保读取的数据长度正确 // 确保读取的数据长度正确
val audioData = if (bytesRead == frameSize) { val audioData = if (bytesRead == frameSize) {
buffer.copyOf() buffer
} else { } else {
buffer.copyOf(bytesRead) buffer.copyOf(bytesRead)
} }
// 回调音频数据 // 计算RMS值
val rms = calculateRMS(audioData)
onRmsChanged?.invoke(rms)
// 直接回调音频数据到Flutter层
onAudioData?.invoke(audioData) onAudioData?.invoke(audioData)
} else { } else {
Log.w(TAG, "录音读取数据失败: $bytesRead") Log.w(TAG, "录音读取数据失败: $bytesRead")
@ -212,9 +243,12 @@ class RealtimeAudioManager(private val context: Context) {
isRecording = false isRecording = false
recordJob?.cancel() recordJob?.cancel()
audioRecord?.stop() audioRecord?.stop()
// 清理回音消除
cleanupEchoCancellation()
onStatusChanged?.invoke(VoiceStatus.IDLE) onStatusChanged?.invoke(VoiceStatus.IDLE)
Log.i(TAG, "录音已停止")
Log.i(TAG, "停止录音")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "停止录音失败: ${e.message}", e) Log.e(TAG, "停止录音失败: ${e.message}", e)
@ -225,9 +259,11 @@ class RealtimeAudioManager(private val context: Context) {
/// 播放音频数据 /// 播放音频数据
fun playAudioData(data: ByteArray) { fun playAudioData(data: ByteArray) {
if (data.isNotEmpty()) { if (data.isNotEmpty()) {
playbackQueue.offer(data) // 计算播放数据的RMS值
val rms = calculateRMS(data)
onRmsChanged?.invoke(rms)
// 如果没在播放,开始播放 playbackQueue.offer(data)
if (!isPlaying) { if (!isPlaying) {
startPlayback() startPlayback()
} }
@ -241,70 +277,79 @@ class RealtimeAudioManager(private val context: Context) {
} }
try { try {
audioTrack?.play()
isPlaying = true isPlaying = true
onStatusChanged?.invoke(VoiceStatus.PLAYING) onStatusChanged?.invoke(VoiceStatus.PLAYING)
audioTrack?.play()
// 启动播放协程
playJob = scope.launch { playJob = scope.launch {
loopPlayback() loopPlayback()
} }
Log.i(TAG, "开始播放") Log.i(TAG, "开始播放")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "开始播放失败: ${e.message}", e) Log.e(TAG, "开始播放失败: ${e.message}", e)
} }
} }
/// 播放循环(对应Android示例中的loopPlayback) /// 播放循环
private suspend fun loopPlayback() { private suspend fun loopPlayback() {
while (isPlaying && audioTrack != null) { Log.d(TAG, "启动播放循环")
while (isPlaying) {
try { try {
val audioData = playbackQueue.poll() // take() 会一直等待直到队列中有数据
val data = playbackQueue.take()
if (audioData != null) {
val bytesWritten = audioTrack!!.write(audioData, 0, audioData.size) // 直接播放整个数据块,让AudioTrack自己处理时序
audioTrack?.write(data, 0, data.size)
if (bytesWritten < 0) {
Log.w(TAG, "音频播放写入失败: $bytesWritten") } catch (e: InterruptedException) {
} Log.d(TAG, "播放循环被中断")
} else { break
// 队列为空,检查是否应该停止播放
delay(10) // 短暂等待
if (playbackQueue.isEmpty()) {
// 队列持续为空,停止播放
break
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "播放循环异常: ${e.message}", e) Log.e(TAG, "播放循环异常: ${e.message}", e)
break
} }
} }
Log.d(TAG, "播放循环结束")
// 播放结束 stopPlaybackInternal()
stopPlayback()
} }
/// 停止播放 /// 内部停止播放方法
fun stopPlaying(): Boolean { private fun stopPlaybackInternal() {
return stopPlayback() if (!isPlaying) return
isPlaying = false
playbackQueue.clear()
// 使用try-catch保护,因为AudioTrack状态可能不稳定
try {
if (audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) {
audioTrack?.pause()
audioTrack?.flush()
}
// 不要在这里调用stop,stop是用来释放资源的,pause/flush更适合流式播放的暂停
} catch (e: Exception) {
Log.e(TAG, "暂停和清空AudioTrack失败: ${e.message}", e)
}
onStatusChanged?.invoke(VoiceStatus.IDLE)
Log.i(TAG, "播放已通过内部调用停止")
} }
private fun stopPlayback(): Boolean { /// 停止播放(外部调用)
fun stopPlaying(): Boolean {
if (!isPlaying) { if (!isPlaying) {
return false return true
} }
try { try {
isPlaying = false isPlaying = false
playJob?.cancel() playJob?.cancel() // 取消协程
audioTrack?.stop() playJob = null
playbackQueue.clear() stopPlaybackInternal()
onStatusChanged?.invoke(VoiceStatus.IDLE)
// 恢复音频模式
Log.i(TAG, "停止播放") restoreAudioMode()
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "停止播放失败: ${e.message}", e) Log.e(TAG, "停止播放失败: ${e.message}", e)
@ -319,7 +364,7 @@ class RealtimeAudioManager(private val context: Context) {
stopRecording() stopRecording()
} }
if (isPlaying) { if (isPlaying) {
stopPlayback() stopPlaying()
} }
// 释放旧的音频对象 // 释放旧的音频对象
@ -344,7 +389,11 @@ class RealtimeAudioManager(private val context: Context) {
/// 释放资源 /// 释放资源
fun dispose() { fun dispose() {
stopRecording() stopRecording()
stopPlayback() stopPlaying()
// 清理回音消除和恢复音频模式
cleanupEchoCancellation()
restoreAudioMode()
scope.cancel() scope.cancel()
releaseAudioObjects() releaseAudioObjects()
@ -352,4 +401,119 @@ class RealtimeAudioManager(private val context: Context) {
Log.i(TAG, "音频管理器已释放") Log.i(TAG, "音频管理器已释放")
} }
/// 设置回音消除(总是启用)
private fun setupEchoCancellation() {
val record = audioRecord ?: return
try {
// 设置音频模式为通信模式
audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
// 设置声学回音消除
if (AcousticEchoCanceler.isAvailable()) {
acousticEchoCanceler = AcousticEchoCanceler.create(record.audioSessionId)
acousticEchoCanceler?.enabled = true
Log.i(TAG, "声学回音消除已启用")
} else {
Log.w(TAG, "设备不支持声学回音消除")
}
// 设置噪声抑制
if (NoiseSuppressor.isAvailable()) {
noiseSuppressor = NoiseSuppressor.create(record.audioSessionId)
noiseSuppressor?.enabled = true
Log.i(TAG, "噪声抑制已启用")
}
// 设置自动增益控制
if (AutomaticGainControl.isAvailable()) {
automaticGainControl = AutomaticGainControl.create(record.audioSessionId)
automaticGainControl?.enabled = true
Log.i(TAG, "自动增益控制已启用")
}
Log.i(TAG, "回音消除和音频增强功能已全部启用")
} catch (e: Exception) {
Log.e(TAG, "设置回音消除失败: ${e.message}", e)
}
}
/// 清理回音消除
private fun cleanupEchoCancellation() {
try {
acousticEchoCanceler?.let {
it.enabled = false
it.release()
acousticEchoCanceler = null
Log.d(TAG, "声学回音消除已释放")
}
noiseSuppressor?.let {
it.enabled = false
it.release()
noiseSuppressor = null
Log.d(TAG, "噪声抑制已释放")
}
automaticGainControl?.let {
it.enabled = false
it.release()
automaticGainControl = null
Log.d(TAG, "自动增益控制已释放")
}
} catch (e: Exception) {
Log.e(TAG, "清理回音消除失败: ${e.message}", e)
}
}
/// 恢复音频模式
private fun restoreAudioMode() {
try {
audioManager?.mode = originalAudioMode
Log.d(TAG, "音频模式已恢复为: $originalAudioMode")
} catch (e: Exception) {
Log.e(TAG, "恢复音频模式失败: ${e.message}", e)
}
}
fun destroy() {
Log.d(TAG, "销毁AudioManager")
stopRecording()
stopPlaying()
scope.cancel() // 确保所有协程都已取消
audioRecord?.release()
audioTrack?.release()
releaseAudioObjects()
playbackQueue.clear()
Log.i(TAG, "音频管理器已释放")
}
/// 计算音频数据的RMS值
private fun calculateRMS(audioData: ByteArray): Float {
if (audioData.isEmpty()) return 0f
var sum = 0.0
// 处理16位PCM数据
for (i in 0 until audioData.size step 2) {
if (i + 1 < audioData.size) {
// Little-endian: 低字节在前
val sample = (audioData[i].toInt() and 0xFF) or
(audioData[i + 1].toInt() shl 8)
// 转换为有符号16位整数
val signedSample = sample.toShort().toFloat()
// 归一化到[-1, 1]
val normalizedSample = signedSample / 32768f
sum += normalizedSample * normalizedSample
}
}
val mean = sum / (audioData.size / 2)
return sqrt(mean).toFloat()
}
} }

266
local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt

@ -1,6 +1,8 @@
package com.yunqiinnovation.realtime package com.yunqiinnovation.realtime
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
@ -9,6 +11,7 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.json.JSONObject
/** /**
* 实时语音聊天插件 * 实时语音聊天插件
@ -28,12 +31,15 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
private lateinit var eventChannel: EventChannel private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null private var eventSink: EventChannel.EventSink? = null
// 核心组件 // 核心组件:设为可空,因为它们的生命周期与页面会话绑定
private lateinit var audioManager: RealtimeAudioManager private var audioManager: RealtimeAudioManager? = null
private lateinit var webSocketManager: RealtimeWebSocketManager private var webSocketManager: RealtimeWebSocketManager? = null
// 协程作用域 // 协程作用域:需要可变,因为在重用插件时需要重新创建
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 主线程Handler,用于向Flutter发送事件与回调
private val mainHandler = Handler(Looper.getMainLooper())
// 配置参数 // 配置参数
private var serverUrl: String = "" private var serverUrl: String = ""
@ -47,6 +53,11 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
@Volatile @Volatile
private var voiceStatus: VoiceStatus = VoiceStatus.IDLE private var voiceStatus: VoiceStatus = VoiceStatus.IDLE
// 清理状态标志,确保清理逻辑只执行一次
@Volatile
private var isCleanedUp = false
private val cleanupLock = Any()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext context = flutterPluginBinding.applicationContext
@ -55,68 +66,96 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL) eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this) eventChannel.setStreamHandler(this)
// 初始化核心组件
audioManager = RealtimeAudioManager(context)
webSocketManager = RealtimeWebSocketManager()
setupCallbacks()
Log.i(TAG, "Realtime插件已附加到引擎") Log.i(TAG, "Realtime插件已附加到引擎")
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Log.i(TAG, "插件正在从引擎分离,执行清理...")
performCleanup()
methodChannel.setMethodCallHandler(null) methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null) eventChannel.setStreamHandler(null)
// 清理资源
scope.cancel()
audioManager.dispose()
webSocketManager.dispose()
Log.i(TAG, "Realtime插件已从引擎分离") Log.i(TAG, "Realtime插件已从引擎分离")
} }
/// 设置各组件的回调 /// 设置各组件的回调
private fun setupCallbacks() { private fun setupCallbacks() {
Log.d(TAG, "setupCallbacks: 正在为新的管理器实例设置回调...")
// 音频管理器回调 // 音频管理器回调
audioManager.onAudioData = { data -> audioManager?.onAudioData = { data ->
webSocketManager.sendAudioData(data) // 发送到WebSocket
webSocketManager?.sendAudioData(data)
// 发送录音PCM数据到Flutter用于可视化
val pcm16Data = convertByteArrayToPCM16(data)
sendEvent("recordingPcmData", pcm16Data)
} }
audioManager.onStatusChanged = { status -> audioManager?.onStatusChanged = { status ->
voiceStatus = status voiceStatus = status
sendEvent("voiceStatusChanged", status.value) sendEvent("voiceStatusChanged", status.value)
} }
// 新增RMS回调
audioManager?.onRmsChanged = { rms ->
sendEvent("audioRms", rms)
}
// WebSocket管理器回调 // WebSocket管理器回调
webSocketManager.onConnectionStatusChanged = { status -> webSocketManager?.onConnectionStatusChanged = { status ->
connectionStatus = status connectionStatus = status
sendEvent("connectionStatusChanged", status.value) sendEvent("connectionStatusChanged", status.value)
} }
webSocketManager.onAudioReceived = { data -> webSocketManager?.onAudioReceived = { data ->
audioManager.playAudioData(data) // 添加音频数据前50字节的日志输出
val dataPreview = if (data.size > 50) {
data.sliceArray(0..49).joinToString(" ") { "%02x".format(it) } + "..."
} else {
data.joinToString(" ") { "%02x".format(it) }
}
Log.d(TAG, "收到音频数据前50字节: $dataPreview")
Log.d(TAG, "音频数据总长度: ${data.size} 字节")
audioManager?.playAudioData(data)
// 发送播放PCM数据到Flutter用于可视化
val pcm16Data = convertByteArrayToPCM16(data)
sendEvent("playbackPcmData", pcm16Data)
} }
webSocketManager.onTextReceived = { text -> webSocketManager?.onTextReceived = { text ->
// 直接发送原始JSON文本到Flutter层处理
sendEvent("textReceived", text) sendEvent("textReceived", text)
} }
webSocketManager.onError = { error -> webSocketManager?.onError = { error ->
sendEvent("error", error) sendEvent("error", error)
} }
} }
/// 发送事件到Flutter端 /// 发送事件到Flutter端
private fun sendEvent(type: String, data: Any?) { private fun sendEvent(type: String, data: Any?) {
val sink = eventSink
if (sink == null) {
Log.d(TAG, "事件流已关闭,忽略事件: $type")
return
}
val event = mapOf( val event = mapOf(
"type" to type, "type" to type,
"data" to data "data" to data
) )
scope.launch { try {
eventSink?.success(event) mainHandler.post {
try {
sink.success(event)
} catch (e: Exception) {
Log.w(TAG, "发送事件失败: \\${e.message}")
}
}
} catch (e: Exception) {
Log.w(TAG, "发送事件时出现异常: \\${e.message}")
} }
} }
@ -139,21 +178,46 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
private fun handleInitialize(call: MethodCall, result: Result) { private fun handleInitialize(call: MethodCall, result: Result) {
try { try {
Log.d(TAG, "handleInitialize: 开始新一轮的初始化...")
// 1. 如果之前的协程作用域已被取消,则创建一个全新的
if (!scope.isActive) {
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
Log.i(TAG, "handleInitialize: 检测到协程作用域已失效,已创建新实例。")
}
// 2. 重置清理状态标志,允许下一次的清理操作
synchronized(cleanupLock) {
if (isCleanedUp) {
isCleanedUp = false
Log.i(TAG, "handleInitialize: 清理状态已重置,插件可再次使用。")
}
}
// 3. 为新会话创建全新的管理器实例
Log.d(TAG, "handleInitialize: 正在创建新的AudioManager和WebSocketManager实例...")
audioManager = RealtimeAudioManager(context)
webSocketManager = RealtimeWebSocketManager()
// 4. 为新实例设置回调
setupCallbacks()
val args = call.arguments as? Map<String, Any> val args = call.arguments as? Map<String, Any>
val serverUrl = args?.get("serverUrl") as? String val serverUrl = args?.get("serverUrl") as? String
?: return result.error("INVALID_ARGUMENTS", "服务器地址不能为空", null) ?: return result.error("INVALID_ARGUMENTS", "服务器地址不能为空", null)
this.serverUrl = serverUrl this.serverUrl = serverUrl
this.sampleRate = args["sampleRate"] as? Int ?: 16000
this.channels = args["channels"] as? Int ?: 1 args["sampleRate"]?.let { this.sampleRate = (it as Number).toInt() }
this.bitsPerSample = args["bitsPerSample"] as? Int ?: 16 args["channels"]?.let { this.channels = (it as Number).toInt() }
args["bitsPerSample"]?.let { this.bitsPerSample = (it as Number).toInt() }
// 初始化音频管理器 // 初始化音频管理器
val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample) val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample)
val success = audioManager.initialize(audioConfig) val success = audioManager?.initialize(audioConfig) ?: false
if (success) { if (success) {
webSocketManager.initialize(serverUrl) webSocketManager?.initialize(serverUrl)
Log.i(TAG, "实时语音插件初始化成功") Log.i(TAG, "实时语音插件初始化成功")
} else { } else {
Log.e(TAG, "实时语音插件初始化失败") Log.e(TAG, "实时语音插件初始化失败")
@ -167,13 +231,14 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
} }
private fun handleConnect(result: Result) { private fun handleConnect(result: Result) {
Log.d(TAG, "handleConnect: 收到连接请求。scope是否活跃? ${scope.isActive}")
scope.launch { scope.launch {
try { try {
val success = webSocketManager.connect() val success = webSocketManager?.connect() ?: false
result.success(success) mainHandler.post { result.success(success) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "连接失败: ${e.message}", e) Log.e(TAG, "连接失败: ${e.message}", e)
result.error("CONNECTION_ERROR", "连接失败: ${e.message}", null) mainHandler.post { result.error("CONNECTION_ERROR", "连接失败: ${e.message}", null) }
} }
} }
} }
@ -181,11 +246,11 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
private fun handleDisconnect(result: Result) { private fun handleDisconnect(result: Result) {
scope.launch { scope.launch {
try { try {
webSocketManager.disconnect() webSocketManager?.disconnect()
result.success(true) mainHandler.post { result.success(true) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "断开连接失败: ${e.message}", e) Log.e(TAG, "断开连接失败: ${e.message}", e)
result.error("DISCONNECTION_ERROR", "断开连接失败: ${e.message}", null) mainHandler.post { result.error("DISCONNECTION_ERROR", "断开连接失败: ${e.message}", null) }
} }
} }
} }
@ -193,11 +258,11 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
private fun handleStartRecording(result: Result) { private fun handleStartRecording(result: Result) {
scope.launch { scope.launch {
try { try {
val success = audioManager.startRecording() val success = audioManager?.startRecording() ?: false
result.success(success) mainHandler.post { result.success(success) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "开始录音失败: ${e.message}", e) Log.e(TAG, "开始录音失败: ${e.message}", e)
result.error("RECORDING_ERROR", "开始录音失败: ${e.message}", null) mainHandler.post { result.error("RECORDING_ERROR", "开始录音失败: ${e.message}", null) }
} }
} }
} }
@ -205,11 +270,11 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
private fun handleStopRecording(result: Result) { private fun handleStopRecording(result: Result) {
scope.launch { scope.launch {
try { try {
val success = audioManager.stopRecording() val success = audioManager?.stopRecording() ?: false
result.success(success) mainHandler.post { result.success(success) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "停止录音失败: ${e.message}", e) Log.e(TAG, "停止录音失败: ${e.message}", e)
result.error("RECORDING_ERROR", "停止录音失败: ${e.message}", null) mainHandler.post { result.error("RECORDING_ERROR", "停止录音失败: ${e.message}", null) }
} }
} }
} }
@ -217,11 +282,11 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
private fun handleStopPlaying(result: Result) { private fun handleStopPlaying(result: Result) {
scope.launch { scope.launch {
try { try {
val success = audioManager.stopPlaying() val success = audioManager?.stopPlaying() ?: false
result.success(success) mainHandler.post { result.success(success) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "停止播放失败: ${e.message}", e) Log.e(TAG, "停止播放失败: ${e.message}", e)
result.error("PLAYBACK_ERROR", "停止播放失败: ${e.message}", null) mainHandler.post { result.error("PLAYBACK_ERROR", "停止播放失败: ${e.message}", null) }
} }
} }
} }
@ -232,7 +297,7 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
val message = args?.get("message") as? String val message = args?.get("message") as? String
?: return result.error("INVALID_ARGUMENTS", "消息内容不能为空", null) ?: return result.error("INVALID_ARGUMENTS", "消息内容不能为空", null)
val success = webSocketManager.sendTextMessage(message) val success = webSocketManager?.sendTextMessage(message) ?: false
result.success(success) result.success(success)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "发送文本消息失败: ${e.message}", e) Log.e(TAG, "发送文本消息失败: ${e.message}", e)
@ -245,12 +310,23 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
val args = call.arguments as? Map<String, Any> val args = call.arguments as? Map<String, Any>
?: return result.error("INVALID_ARGUMENTS", "参数无效", null) ?: return result.error("INVALID_ARGUMENTS", "参数无效", null)
args["sampleRate"]?.let { this.sampleRate = it as Int } Log.d(TAG, "收到的音频配置参数: $args")
args["channels"]?.let { this.channels = it as Int }
args["bitsPerSample"]?.let { this.bitsPerSample = it as Int } args["sampleRate"]?.let {
Log.d(TAG, "sampleRate: $it, type: ${it.javaClass.name}")
this.sampleRate = (it as Number).toInt()
}
args["channels"]?.let {
Log.d(TAG, "channels: $it, type: ${it.javaClass.name}")
this.channels = (it as Number).toInt()
}
args["bitsPerSample"]?.let {
Log.d(TAG, "bitsPerSample: $it, type: ${it.javaClass.name}")
this.bitsPerSample = (it as Number).toInt()
}
val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample) val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample)
val success = audioManager.updateConfig(audioConfig) val success = audioManager?.updateConfig(audioConfig) ?: false
result.success(success) result.success(success)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "设置音频配置失败: ${e.message}", e) Log.e(TAG, "设置音频配置失败: ${e.message}", e)
@ -259,14 +335,84 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
} }
private fun handleDispose(result: Result) { private fun handleDispose(result: Result) {
scope.launch { Log.i(TAG, "Flutter端请求释放资源...")
try { performCleanup()
audioManager.dispose() // 立即返回,不等待异步清理完成
webSocketManager.dispose() result.success(null)
result.success(null) }
} catch (e: Exception) {
Log.e(TAG, "释放资源失败: ${e.message}", e) /**
result.error("DISPOSE_ERROR", "释放资源失败: ${e.message}", null) * 将字节数组转换为PCM16整数数组
* PCM16格式:16-bit signed integers, little-endian
*/
private fun convertByteArrayToPCM16(data: ByteArray): List<Int> {
val pcm16List = mutableListOf<Int>()
// 确保数据长度是偶数(每2个字节一个采样)
val validSize = data.size and 0xFFFFFFFE.toInt() // 清除最低位,确保偶数
// 每2个字节组成一个16-bit采样
for (i in 0 until validSize step 2) {
if (i + 1 < data.size) { // 安全检查
// Little-endian: 低字节在前
val low = data[i].toInt() and 0xFF
val high = data[i + 1].toInt() shl 8
val sample = (high or low).toShort().toInt() // 转换为有符号16位整数
pcm16List.add(sample)
}
}
return pcm16List
}
/**
* 执行核心清理操作。
* 此方法是幂等的(可重复调用)和线程安全的。
*/
private fun performCleanup() {
synchronized(cleanupLock) {
if (isCleanedUp) {
Log.d(TAG, "资源已清理,跳过。")
return
}
Log.i(TAG, "开始执行核心资源清理...")
// 立即标记,防止重入
isCleanedUp = true
// 立即停止接收事件
eventSink = null
// 在后台IO线程中执行所有耗时操作,避免阻塞主线程
CoroutineScope(Dispatchers.IO).launch {
runCatching { audioManager?.stopRecording() }
.onFailure { Log.w(TAG, "停止录音时异常: ${it.message}") }
runCatching { audioManager?.stopPlaying() }
.onFailure { Log.w(TAG, "停止播放时异常: ${it.message}") }
runCatching { webSocketManager?.disconnect() }
.onFailure { Log.w(TAG, "断开WebSocket时异常: ${it.message}") }
// 短暂延迟,以确保挂起的操作有时间完成
// delay(100L)
runCatching { audioManager?.dispose() }
.onFailure { Log.w(TAG, "释放音频管理器时异常: ${it.message}") }
runCatching { webSocketManager?.dispose() }
.onFailure { Log.w(TAG, "释放WebSocket管理器时异常: ${it.message}") }
runCatching {
scope.cancel()
Log.i(TAG, "插件协程作用域已取消。")
}.onFailure { Log.w(TAG, "取消协程作用域时异常: ${it.message}") }
// 清理完成,将实例置空
audioManager = null
webSocketManager = null
Log.i(TAG, "核心资源清理完成。")
} }
} }
} }

109
local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt

@ -43,6 +43,7 @@ class RealtimeWebSocketManager : WebSocketListener() {
/// 初始化WebSocket管理器 /// 初始化WebSocket管理器
fun initialize(serverUrl: String) { fun initialize(serverUrl: String) {
Log.d(TAG, "initialize: 开始初始化。当前httpClient是否为null? ${okHttpClient == null}")
this.serverUrl = serverUrl this.serverUrl = serverUrl
// 创建OkHttp客户端 // 创建OkHttp客户端
@ -53,20 +54,24 @@ class RealtimeWebSocketManager : WebSocketListener() {
.retryOnConnectionFailure(true) .retryOnConnectionFailure(true)
.build() .build()
Log.i(TAG, "WebSocket管理器初始化,服务器地址: $serverUrl") Log.i(TAG, "initialize: WebSocket管理器初始化完成。服务器地址: $serverUrl")
} }
/// 连接到服务器 /// 连接到服务器
suspend fun connect(): Boolean = withContext(Dispatchers.IO) { suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
Log.d(TAG, "connect: 尝试连接。当前状态 - isConnected: $isConnected, isConnecting: $isConnecting")
if (isConnected || isConnecting) { if (isConnected || isConnecting) {
Log.w(TAG, "connect: 连接请求被拒绝,因为已有连接或正在连接中。")
return@withContext false return@withContext false
} }
val client = okHttpClient ?: run { val client = okHttpClient ?: run {
Log.e(TAG, "OkHttp客户端未初始化") Log.e(TAG, "connect: 连接失败,因为OkHttp客户端为null。请确保已调用initialize()。")
onError?.invoke("客户端未初始化") onError?.invoke("客户端未初始化")
return@withContext false return@withContext false
} }
Log.d(TAG, "connect: OkHttpClient实例存在,准备发起新连接。")
try { try {
isConnecting = true isConnecting = true
@ -76,6 +81,7 @@ class RealtimeWebSocketManager : WebSocketListener() {
.url(serverUrl) .url(serverUrl)
.build() .build()
Log.d(TAG, "connect: 正在创建新的WebSocket实例...")
webSocket = client.newWebSocket(request, this@RealtimeWebSocketManager) webSocket = client.newWebSocket(request, this@RealtimeWebSocketManager)
// 等待连接建立 // 等待连接建立
@ -86,10 +92,10 @@ class RealtimeWebSocketManager : WebSocketListener() {
} }
if (isConnected) { if (isConnected) {
Log.i(TAG, "WebSocket连接成功") Log.i(TAG, "connect: WebSocket连接成功")
return@withContext true return@withContext true
} else { } else {
Log.e(TAG, "WebSocket连接超时") Log.e(TAG, "connect: WebSocket连接超时")
isConnecting = false isConnecting = false
onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR)
return@withContext false return@withContext false
@ -126,8 +132,14 @@ class RealtimeWebSocketManager : WebSocketListener() {
} }
try { try {
val byteString = ByteString.of(*data) // 将音频数据编码为Base64并包装成Vocode WebSocket格式
val success = webSocket?.send(byteString) ?: false val base64AudioData = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP)
val audioMessage = org.json.JSONObject().apply {
put("type", "websocket_audio")
put("data", base64AudioData)
}
val success = webSocket?.send(audioMessage.toString()) ?: false
if (!success) { if (!success) {
Log.w(TAG, "发送音频数据失败") Log.w(TAG, "发送音频数据失败")
@ -160,9 +172,35 @@ class RealtimeWebSocketManager : WebSocketListener() {
fun dispose() { fun dispose() {
disconnect() disconnect()
scope.cancel() // 立即取消scope,不等待
try {
scope.cancel()
} catch (e: Exception) {
Log.w(TAG, "取消scope时出现异常: ${e.message}")
}
okHttpClient?.dispatcher?.executorService?.shutdown() // 立即强制关闭WebSocket
try {
webSocket?.close(1001, "强制关闭")
webSocket = null
} catch (e: Exception) {
Log.w(TAG, "强制关闭WebSocket时出现异常: ${e.message}")
}
// 异步关闭线程池,设置更短的超时时间
okHttpClient?.dispatcher?.executorService?.let { executor ->
Thread {
try {
executor.shutdownNow() // 立即强制关闭
// 最多等待1秒,避免长时间等待
if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
Log.w(TAG, "线程池强制关闭超时,放弃等待")
}
} catch (e: Exception) {
Log.w(TAG, "关闭线程池时出现异常: ${e.message}")
}
}.start()
}
okHttpClient = null okHttpClient = null
Log.i(TAG, "WebSocket管理器已释放") Log.i(TAG, "WebSocket管理器已释放")
@ -177,13 +215,60 @@ class RealtimeWebSocketManager : WebSocketListener() {
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "收到文本消息: $text") // 添加收到文本消息的日志输出,显示消息类型和前20字符
onTextReceived?.invoke(text) val textPreview = if (text.length > 50) text.substring(0, 50) + "..." else text
Log.d(TAG, "收到文本消息前20字符: $textPreview")
try {
// 尝试解析为JSON来检查消息类型
val json = org.json.JSONObject(text)
val messageType = json.optString("type", "")
// 添加消息类型的日志输出
Log.d(TAG, "消息类型: $messageType")
when (messageType) {
"websocket_audio" -> {
// 处理音频消息
val audioDataString = json.optString("data", "")
if (audioDataString.isNotEmpty()) {
// 将Base64编码的音频数据解码为字节数组
val audioData = android.util.Base64.decode(audioDataString, android.util.Base64.DEFAULT)
onAudioReceived?.invoke(audioData)
}
}
"websocket_transcript" -> {
// 处理统一的文本消息格式
// 包含sender、is_final、timestamp等字段
onTextReceived?.invoke(text)
}
"websocket_ready" -> {
// 连接就绪消息
Log.i(TAG, "WebSocket连接就绪")
}
"websocket_stop" -> {
// 会话停止消息
Log.i(TAG, "会话已结束")
}
"websocket_start" -> {
// 会话开始消息
Log.i(TAG, "会话已开始")
}
else -> {
// 其他消息类型,当作普通文本消息处理
onTextReceived?.invoke(text)
}
}
} catch (e: Exception) {
// 如果不是JSON格式,当作普通文本消息处理
Log.d(TAG, "非JSON格式消息,当作普通文本处理")
onTextReceived?.invoke(text)
}
} }
override fun onMessage(webSocket: WebSocket, bytes: ByteString) { override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
Log.d(TAG, "收到音频数据: ${bytes.size} 字节") val audioData = bytes.toByteArray()
onAudioReceived?.invoke(bytes.toByteArray()) onAudioReceived?.invoke(audioData)
} }
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {

30
local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift

@ -30,6 +30,7 @@ class RealtimeAudioManager {
// 回调 // 回调
var onAudioData: ((Data) -> Void)? var onAudioData: ((Data) -> Void)?
var onStatusChanged: ((VoiceStatus) -> Void)? var onStatusChanged: ((VoiceStatus) -> Void)?
var onRmsChanged: ((Float) -> Void)?
// 录音缓冲区 // 录音缓冲区
private var recordingBuffer: AVAudioPCMBuffer? private var recordingBuffer: AVAudioPCMBuffer?
@ -163,6 +164,10 @@ class RealtimeAudioManager {
// 转换为Data // 转换为Data
let data = Data(bytes: channelData, count: dataLength) let data = Data(bytes: channelData, count: dataLength)
// 计算RMS值
let rms = calculateRMS(buffer: buffer)
onRmsChanged?(rms)
// 回调音频数据 // 回调音频数据
onAudioData?(data) onAudioData?(data)
} }
@ -217,6 +222,12 @@ class RealtimeAudioManager {
channelData.update(from: int16Pointer.baseAddress!, count: Int(frameCount)) channelData.update(from: int16Pointer.baseAddress!, count: Int(frameCount))
} }
// 计算播放数据的RMS值
let rms = calculateRMS(buffer: buffer)
DispatchQueue.main.async { [weak self] in
self?.onRmsChanged?(rms)
}
// 加入播放队列 // 加入播放队列
audioBufferQueue.append(buffer) audioBufferQueue.append(buffer)
@ -311,4 +322,23 @@ class RealtimeAudioManager {
os_log("音频管理器已释放", log: log, type: .info) os_log("音频管理器已释放", log: log, type: .info)
} }
/// 计算音频缓冲区的RMS值
private func calculateRMS(buffer: AVAudioPCMBuffer) -> Float {
guard let channelData = buffer.int16ChannelData?[0],
buffer.frameLength > 0 else {
return 0.0
}
let frameCount = Int(buffer.frameLength)
var rms: Float = 0.0
// 使用Accelerate框架高效计算RMS
vDSP_measqv(channelData, 1, &rms, vDSP_Length(frameCount))
// 计算平方根并归一化
rms = sqrt(rms / Float(frameCount)) / 32768.0
return rms
}
} }

45
local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift

@ -54,7 +54,10 @@ import os.log
private func setupCallbacks() { private func setupCallbacks() {
// 音频管理器回调 // 音频管理器回调
audioManager.onAudioData = { [weak self] data in audioManager.onAudioData = { [weak self] data in
// 发送到WebSocket
self?.websocketManager.sendAudioData(data) self?.websocketManager.sendAudioData(data)
// 同时发送PCM数据到Flutter用于可视化
self?.sendPCMDataToFlutter(data)
} }
audioManager.onStatusChanged = { [weak self] status in audioManager.onStatusChanged = { [weak self] status in
@ -62,6 +65,11 @@ import os.log
self?.sendEvent(type: "voiceStatusChanged", data: status.rawValue) self?.sendEvent(type: "voiceStatusChanged", data: status.rawValue)
} }
// 新增RMS回调
audioManager.onRmsChanged = { [weak self] rms in
self?.sendEvent(type: "audioRms", data: rms)
}
// WebSocket管理器回调 // WebSocket管理器回调
websocketManager.onConnectionStatusChanged = { [weak self] status in websocketManager.onConnectionStatusChanged = { [weak self] status in
self?.connectionStatus = status self?.connectionStatus = status
@ -69,10 +77,20 @@ import os.log
} }
websocketManager.onAudioReceived = { [weak self] data in websocketManager.onAudioReceived = { [weak self] data in
// 添加音频数据前50字节的日志输出
let dataCount = min(data.count, 50)
let dataPreview = data.prefix(dataCount).map { String(format: "%02x", $0) }.joined(separator: " ")
let previewText = data.count > 50 ? dataPreview + "..." : dataPreview
self?.os_log("收到音频数据前50字节: %@", log: self?.log ?? OSLog.default, type: .debug, previewText)
self?.os_log("音频数据总长度: %d 字节", log: self?.log ?? OSLog.default, type: .debug, data.count)
self?.audioManager.playAudioData(data) self?.audioManager.playAudioData(data)
// 同时发送接收到的PCM数据到Flutter用于可视化
self?.sendPCMDataToFlutter(data)
} }
websocketManager.onTextReceived = { [weak self] text in websocketManager.onTextReceived = { [weak self] text in
// 直接发送原始JSON文本到Flutter层处理
self?.sendEvent(type: "textReceived", data: text) self?.sendEvent(type: "textReceived", data: text)
} }
@ -94,6 +112,33 @@ import os.log
eventSink(event) eventSink(event)
} }
} }
/// 将PCM数据发送到Flutter用于可视化
private func sendPCMDataToFlutter(_ data: Data) {
// 确保数据长度是偶数(每2个字节一个16位采样)
let validSize = data.count & ~1 // 清除最低位,确保偶数
guard validSize > 0 else { return }
// 将Data转换为Int16数组 (PCM16格式)
let pcm16Array = data.prefix(validSize).withUnsafeBytes { bytes in
let int16Pointer = bytes.bindMemory(to: Int16.self)
return Array(UnsafeBufferPointer(start: int16Pointer.baseAddress, count: validSize / 2))
}
// 转换为Int数组以便传递给Flutter
let pcmData = pcm16Array.map { Int($0) }
sendEvent(type: "pcmData", data: pcmData)
}
/// 辅助日志方法
private func os_log(_ message: String, log: OSLog, type: OSLogType, _ args: CVarArg...) {
if args.isEmpty {
os_log("%@", log: log, type: type, message)
} else {
os_log(message, log: log, type: type, args)
}
}
} }
// MARK: - FlutterPlugin // MARK: - FlutterPlugin

71
local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift

@ -138,11 +138,56 @@ class RealtimeWebSocketManager: NSObject {
private func handleReceivedMessage(_ message: URLSessionWebSocketTask.Message) { private func handleReceivedMessage(_ message: URLSessionWebSocketTask.Message) {
switch message { switch message {
case .data(let data): case .data(let data):
// 音频数据 // 保留对二进制数据的支持以防万一
onAudioReceived?(data) onAudioReceived?(data)
case .string(let text): case .string(let text):
// 文本数据 // 添加收到文本消息的日志输出,显示前20字符
onTextReceived?(text) let textPreview = text.count > 20 ? String(text.prefix(20)) + "..." : text
os_log("收到文本消息前20字符: %@", log: log, type: .debug, textPreview)
// 尝试解析为JSON来检查消息类型
do {
if let jsonData = text.data(using: .utf8),
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let messageType = json["type"] as? String {
// 添加消息类型的日志输出
os_log("消息类型: %@", log: log, type: .debug, messageType)
switch messageType {
case "websocket_audio":
// 处理音频消息
if let audioDataString = json["data"] as? String,
let audioData = Data(base64Encoded: audioDataString) {
onAudioReceived?(audioData)
}
case "websocket_transcript":
// 处理统一的文本消息格式
// 包含sender、is_final、timestamp等字段
onTextReceived?(text)
case "websocket_ready":
// 连接就绪消息
os_log("WebSocket连接就绪", log: log, type: .info)
case "websocket_stop":
// 会话停止消息
os_log("会话已结束", log: log, type: .info)
case "websocket_start":
// 会话开始消息
os_log("会话已开始", log: log, type: .info)
default:
// 其他消息类型,当作普通文本消息处理
onTextReceived?(text)
}
} else {
// 如果不是JSON格式,当作普通文本消息处理
os_log("非JSON格式消息,当作普通文本处理", log: log, type: .debug)
onTextReceived?(text)
}
} catch {
// 如果解析失败,当作普通文本消息处理
os_log("非JSON格式消息,当作普通文本处理", log: log, type: .debug)
onTextReceived?(text)
}
@unknown default: @unknown default:
os_log("收到未知类型消息", log: log, type: .error) os_log("收到未知类型消息", log: log, type: .error)
} }
@ -165,10 +210,24 @@ class RealtimeWebSocketManager: NSObject {
guard isConnected else { return } guard isConnected else { return }
sendQueue.async { [weak self] in sendQueue.async { [weak self] in
self?.webSocketTask?.send(.data(data)) { error in // 将音频数据编码为Base64并包装成Vocode WebSocket格式
if let error = error { let base64AudioData = data.base64EncodedString()
self?.os_log("发送音频数据失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription) let audioMessage: [String: Any] = [
"type": "websocket_audio",
"data": base64AudioData
]
do {
let jsonData = try JSONSerialization.data(withJSONObject: audioMessage)
if let jsonString = String(data: jsonData, encoding: .utf8) {
self?.webSocketTask?.send(.string(jsonString)) { error in
if let error = error {
self?.os_log("发送音频数据失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription)
}
}
} }
} catch {
self?.os_log("音频数据JSON序列化失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription)
} }
} }
} }

Loading…
Cancel
Save