Browse Source

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

newdev_shunjiawei
tanlongsheng 1 year ago
parent
commit
408e83eb5f
  1. 11
      ios/Podfile.lock
  2. 128
      lib/core/translations/app_translations.dart
  3. 5
      lib/core/translations/language/bg_bg.dart
  4. 5
      lib/core/translations/language/cs_cz.dart
  5. 4
      lib/core/translations/language/da_dk.dart
  6. 5
      lib/core/translations/language/el_gr.dart
  7. 5
      lib/core/translations/language/et_ee.dart
  8. 5
      lib/core/translations/language/fi_fi.dart
  9. 5
      lib/core/translations/language/hr_hr.dart
  10. 5
      lib/core/translations/language/hu_hu.dart
  11. 12
      lib/core/translations/language/is_is.dart
  12. 5
      lib/core/translations/language/lt_lt.dart
  13. 5
      lib/core/translations/language/lv_lv.dart
  14. 5
      lib/core/translations/language/nb_no.dart
  15. 4
      lib/core/translations/language/nl_nl.dart
  16. 5
      lib/core/translations/language/ro_ro.dart
  17. 5
      lib/core/translations/language/sk_sk.dart
  18. 5
      lib/core/translations/language/sl_si.dart
  19. 12
      lib/core/translations/language/sr_rs.dart
  20. 4
      lib/core/translations/language/sv_se.dart
  21. 5
      lib/core/translations/language/tl_ph.dart
  22. 5
      lib/core/translations/language/uk_ua.dart
  23. 10
      lib/data/services/asr_service.dart
  24. 42
      lib/data/services/speech_impl/azure_asr_service.dart
  25. 16
      lib/data/services/speech_impl/volcano_asr_api_service.dart
  26. 16
      lib/data/services/speech_impl/volcano_asr_service.dart
  27. 16
      lib/data/services/speech_impl/xunfei_asr_service.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. 19
      lib/modules/settings/views/settings_view.dart
  42. 42
      lib/modules/translation/controllers/translation_controller.dart
  43. 37
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  44. 324
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  45. 52
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  46. 44
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt
  47. 254
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt
  48. 4
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  49. 25
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt
  50. 2
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift
  51. 131
      local_plugins/location_service/ios/Classes/LocationService.swift
  52. 44
      local_plugins/location_service/ios/Classes/LocationServicePlugin.swift
  53. 6
      local_plugins/location_service/ios/location_service.podspec
  54. 32
      local_plugins/location_service/ios/location_service/Package.swift
  55. 19
      local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift
  56. 14
      local_plugins/location_service/ios/location_service/Sources/location_service/PrivacyInfo.xcprivacy
  57. 27
      local_plugins/location_service/lib/location_service.dart
  58. 45
      local_plugins/location_service/pubspec.yaml
  59. 74
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  60. 32
      local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift
  61. 55
      local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt
  62. 270
      local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt
  63. 266
      local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt
  64. 109
      local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt
  65. 30
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift
  66. 45
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift
  67. 71
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift

11
ios/Podfile.lock

@ -3,6 +3,8 @@ PODS:
- AMapNavi (~> 10.0.900)
- Flutter
- AMapFoundation (1.8.2)
- AMapLocation (2.10.0):
- AMapFoundation (>= 1.8.0)
- AMapNavi (10.0.900):
- AMapFoundation (>= 1.8.2)
- audio_waveforms (0.0.1):
@ -54,6 +56,9 @@ PODS:
- libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0):
- libwebp/sharpyuv
- location_service (0.0.1):
- AMapLocation (~> 2.10.0)
- Flutter
- Mantle (2.2.0):
- Mantle/extobjc (= 2.2.0)
- Mantle/extobjc (2.2.0)
@ -98,6 +103,7 @@ DEPENDENCIES:
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
- fluwx (from `.symlinks/plugins/fluwx/ios`)
- haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`)
- location_service (from `.symlinks/plugins/location_service/ios`)
- ota (from `.symlinks/plugins/ota/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_darwin (from `.symlinks/plugins/record_darwin/ios`)
@ -107,6 +113,7 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- AMapFoundation
- AMapLocation
- AMapNavi
- FBAEMKit
- FBSDKCoreKit
@ -146,6 +153,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/fluwx/ios"
haptic_feedback:
:path: ".symlinks/plugins/haptic_feedback/ios"
location_service:
:path: ".symlinks/plugins/location_service/ios"
ota:
:path: ".symlinks/plugins/ota/ios"
permission_handler_apple:
@ -160,6 +169,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
amap_service: 1c3e7e07f51cbbdff03719a4a079c60d015c1c0b
AMapFoundation: 9885c48fc3a78fdfb84a0299a2293e56ea3c9fec
AMapLocation: 5248aec2455ebb5d104b367813c946430a2ee033
AMapNavi: fceb4715f9a3b5a25f330facebee2aa2bdba42dd
audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf
classic_bluetooth: c146e1618e867a47fa7ab79a3bf262e7d69d557d
@ -177,6 +187,7 @@ SPEC CHECKSUMS:
fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b
haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
location_service: fab4ef23d473296a215c49e12c129e466574d94e
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
ota: 20c3e522c57e7ba70ce118109e73650acc9fec76
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d

128
lib/core/translations/app_translations.dart

@ -3,38 +3,58 @@ import 'dart:ui';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'language/en_us.dart';
import 'language/zh_cn.dart';
import 'language/es_es.dart';
import 'language/zh_hk.dart';
import 'language/pt_pt.dart'; // 添加葡萄牙语导入
import 'language/ja_jp.dart'; // 添加日语导入
import 'language/ko_kr.dart'; // 添加韩语导入
import 'language/fr_fr.dart'; // 添加法语导入
import 'language/de_de.dart'; // 添加德语导入
import 'language/ru_ru.dart'; // 添加俄语导入
import 'language/vi_vn.dart'; // 添加越南语导入
import 'language/it_it.dart'; // 添加意大利语导入
import 'language/en_us.dart'; // 英语
import 'language/zh_cn.dart'; // 简体中文
import 'language/es_es.dart'; // 西班牙语
import 'language/zh_hk.dart'; // 繁体中文
import 'language/pt_pt.dart'; // 添加葡萄牙语导入
import 'language/ja_jp.dart'; // 添加日语导入
import 'language/ko_kr.dart'; // 添加韩语导入
import 'language/fr_fr.dart'; // 添加法语导入
import 'language/de_de.dart'; // 添加德语导入
import 'language/ru_ru.dart'; // 添加俄语导入
import 'language/vi_vn.dart'; // 添加越南语导入
import 'language/it_it.dart'; // 添加意大利语导入
import 'language/hi_in.dart'; // 添加印度语导入
import 'language/id_id.dart'; // 添加印尼语导入
import 'language/th_Th.dart'; // 添加泰语导入
import 'language/ms_MY.dart'; // 添加马来语导入
import 'language/tr_TR.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 {
static const fallbackLocale = Locale('zh', 'CN');
static final supportedLocales = [
const Locale('zh', 'CN'),
const Locale('zh', 'HK'), // 添加繁体中文支持
const Locale('en', 'US'),
const Locale('zh', 'CN'), // 简体中文
const Locale('zh', 'HK'), // 添加繁体中文支持
const Locale('en', 'US'), // 英语
const Locale('es', 'ES'), // 添加西班牙语
const Locale('pt', 'PT'), // 添加葡萄牙语支持
const Locale('ja', 'JP'), // 添加日语支持
const Locale('ko', 'KR'), // 添加韩语支持
const Locale('fr', 'FR'), // 添加法语支持
const Locale('de', 'DE'), // 添加德语支持
const Locale('ru', 'RU'), // 添加俄语支持
const Locale('pt', 'PT'), // 添加葡萄牙语支持
const Locale('ja', 'JP'), // 添加日语支持
const Locale('ko', 'KR'), // 添加韩语支持
const Locale('fr', 'FR'), // 添加法语支持
const Locale('de', 'DE'), // 添加德语支持
const Locale('ru', 'RU'), // 添加俄语支持
const Locale('vi', 'VN'), // 添加越南语支持
const Locale('it', 'IT'), // 添加意大利语支持
const Locale('hi', 'IN'), // 添加印度语支持
@ -43,6 +63,26 @@ class AppTranslations extends Translations {
const Locale('ms', 'MY'), // 添加马来语支持
const Locale('tr', 'TR'), // 添加土耳其语支持
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() {
@ -58,23 +98,43 @@ class AppTranslations extends Translations {
@override
Map<String, Map<String, String>> get keys => {
'en_US': enUS,
'zh_CN': zhCN,
'zh_HK': zhHK, // 添加繁体中文映射
'es_ES': esES, // 添加西班牙语翻译映射
'pt_PT': ptPT, // 添加葡萄牙语映射
'ja_JP': jaJP, // 添加日语映射
'ko_KR': koKR, // 添加韩语映射
'fr_FR': frFR, // 添加法语映射
'de_DE': deDE, // 添加德语映射
'ru_RU': ruRU, // 添加俄语映射
'vi_VN': viVN, // 添加越南语映射
'it_IT': itIT, // 添加意大利语映射
'en_US': enUS,
'zh_CN': zhCN,
'zh_HK': zhHK, // 添加繁体中文映射
'es_ES': esES, // 添加西班牙语翻译映射
'pt_PT': ptPT, // 添加葡萄牙语映射
'ja_JP': jaJP, // 添加日语映射
'ko_KR': koKR, // 添加韩语映射
'fr_FR': frFR, // 添加法语映射
'de_DE': deDE, // 添加德语映射
'ru_RU': ruRU, // 添加俄语映射
'vi_VN': viVN, // 添加越南语映射
'it_IT': itIT, // 添加意大利语映射
'hi_IN': hiIN, // 添加印度语映射
'id_ID': idID, // 添加印尼语映射
'th_TH': thTH, // 添加泰语映射
'ms_MY': msMY, // 添加马来语映射
'tr_TR': trTR, // 添加土耳其语映射
'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();
/// 开始录音
Future<bool> enableRecord(String fileName);
Future<bool> enableRecord(String filePath);
/// 暂停录音
Future<bool> pauseRecord();
// /// 移动文件到新路径
Future<bool> moveFile(String sourcePath, String destPath);
// /// 重命名指定路径的音频文件
Future<bool> renameFile(String filePath, String newName);
/// 结束录音
Future<bool> stopRecord();
Future<bool> stopRecord(bool isSave);
/// 释放资源
Future<void> dispose();

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

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

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

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

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

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

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

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

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

@ -231,10 +231,12 @@ class LoginController extends GetxController {
style: TextStyle(fontSize: 14),
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GestureDetector(
onTap: () {
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';
}
Get.to(() => WebViewPage(
@ -256,10 +258,12 @@ class LoginController extends GetxController {
style: TextStyle(fontSize: 14),
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GestureDetector(
onTap: () {
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';
}
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 '../controllers/login_controller.dart';
import '../../common/webview_page.dart';
import 'package:get_storage/get_storage.dart';
class LoginView extends GetView<LoginController> {
const LoginView({Key? key}) : super(key: key);
@ -575,7 +576,8 @@ class LoginView extends GetView<LoginController> {
GestureDetector(
onTap: () {
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';
}
Get.to(() => WebViewPage(
@ -601,9 +603,12 @@ class LoginView extends GetView<LoginController> {
GestureDetector(
onTap: () {
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';
}
Get.to(() => WebViewPage(
url: url,
title: 'privacyPolicy'.tr,

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

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

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

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

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

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

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:get/get.dart';
import 'package:haptic_feedback/haptic_feedback.dart';
import 'package:path_provider/path_provider.dart';
import '../../../core/theme/app_colors.dart';
import '../../../routes/app_routes.dart';
import '../controllers/meeting_controller.dart';
import '../model/meeting_model.dart';
import 'meeting_record_view.dart';
class MeetingView extends GetView<MeetingController> {
const MeetingView({super.key});
@ -563,10 +561,13 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '音/视频录音',
onTap: () {
title: '现场录音',
onTap: () async {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 0,
});
controller.addMeetingAudio();
// TODO: 实现从文件导入逻辑
},
),
@ -576,11 +577,13 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '现场录音',
onTap: () {
title: '音、视频录音',
onTap: () async {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 1,
});
controller.addMeetingAudio();
// TODO: 实现从相册导入逻辑
},
),
@ -602,9 +605,14 @@ class MeetingView extends GetView<MeetingController> {
],
),
title: '通话录音',
onTap: () {
onTap: () async {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 2,
});
controller.addMeetingAudio();
// 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,
onTap: () {
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';
}
Get.to(() => WebViewPage(
@ -657,7 +658,8 @@ class EditProfileView extends GetView<ProfileController> {
titleColor: isDarkMode ? Colors.white : Colors.black87,
onTap: () {
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';
}
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
// 跳转到实时语音对话页面(模拟功能)
// 跳转到实时语音对话页面
Get.toNamed(Routes.realtime);
```
### 体验模拟效果
### 交互流程
1. 点击中央按钮开始模拟聆听
2. 3秒后自动切换到AI思考状态
3. 2秒后切换到语音播放状态
4. 3秒后回到初始状态
1. **自动连接** - 页面加载后自动尝试连接AI服务器
2. **等待连接** - 录音按钮在连接成功前保持不可用状态
3. **开始录音** - 连接成功后自动开始录音,录音按钮变为可用
4. **语音对话** - 用户说话 → AI处理 → 语音回复
5. **字幕控制** - 可随时通过右上角按钮切换字幕显示
## 技术架构
### 控制器 (RealtimeController)
- **状态管理**: `isListening`, `isSpeaking`, `isProcessing`
- **模拟逻辑**: 定时器控制状态切换
- **动画控制**: 波形数据生成和更新
- **生命周期**: 基础资源管理
- **连接管理**: WebSocket连接到AI服务器
- **状态同步**: `isConnected`, `isListening`, `isSpeaking`
- **字幕控制**: `isSubtitleMode` 默认为false,用户手动控制
- **消息管理**: 智能去重,避免流式响应重复显示
- **音频处理**: 原生层计算RMS值,Flutter层接收并控制动画
### 视图组件
- **RealtimeView** - 主视图容器
- **VoiceVisualizer** - 语音可视化组件(简化版)
- **ControlButton** - 交互控制按钮(模拟功能)
- **RealtimeView** - 主视图容器,响应式布局
- **ShaderMicFlow** - 基于GLSL Shader的流动云动画组件
- **AutoScrollList** - 自动滚动的字幕列表
### 待实现功能
- **AsrService** - 语音识别服务(插件)
- **TtsService** - 语音合成服务(插件)
- **OpenAIServiceAdapter** - AI对话服务(插件)
### 原生集成
- **RealtimeService** - 原生音频处理和WebSocket通信
- **音频格式** - 16kHz, 1声道, 16位PCM
- **实时传输** - 低延迟音频流处理
- **RMS计算** - 原生层实时计算音频振幅
## 模拟流程
## 默认行为
1. **点击开始** → 模拟语音识别启动
2. **动态波形** → 显示模拟的语音输入效果
3. **自动停止** → 3秒后自动结束聆听
4. **AI处理** → 模拟AI思考过程
5. **语音输出** → 模拟语音播放效果
6. **完成循环** → 返回待机状态
## 开发计划
### 字幕模式
- **初始状态**: 关闭 (`isSubtitleMode.value = false`)
- **显示逻辑**: 仅在用户手动开启时显示字幕内容
- **空间占用**: 关闭时字幕区域高度为0,不影响其他UI元素
- **切换效果**: 平滑动画过渡,圆形动画位置和大小同步调整
### 阶段一:界面框架 ✅
- [x] 基础UI布局
- [x] 状态管理
- [x] 动画效果
- [x] 模拟交互
### 按钮状态
- **录音按钮**: 默认不可用,连接成功且开始录音后才可点击
- **状态指示**: 通过颜色和图标变化反映当前可用性
- **交互反馈**: 不可用时点击无效果,避免误操作
### 阶段二:插件集成 ⏳
- [ ] 语音识别插件集成
- [ ] AI对话服务集成
- [ ] 语音合成插件集成
- [ ] 实时流式处理
## 开发计划
### 阶段三:功能完善 ⏳
- [ ] 错误处理优化
- [ ] 性能优化
- [ ] 用户体验增强
- [ ] 多语言支持
### 已完成功能 ✅
- [x] 完整语音交互流程
- [x] 实时WebSocket通信
- [x] Shader流动云动画
- [x] RMS音频振幅计算
- [x] 智能UI状态管理
- [x] 字幕模式控制
- [x] 消息去重优化
### 计划增强 ⏳
- [ ] 多语言语音识别
- [ ] 语音中断检测
- [ ] 音频质量自适应
- [ ] 离线降级处理
- [ ] 语音情感识别
## 注意事项
1. **当前为演示版** - 所有功能均为模拟效果
2. **无需权限** - 暂不涉及麦克风等硬件权限
3. **无网络依赖** - 模拟功能不需要网络连接
4. **保留扩展性** - 架构设计支持后续插件集成
1. **网络依赖** - 需要连接到AI服务器 (ws://192.168.1.11:8000/ws)
2. **麦克风权限** - 需要获取麦克风使用权限
3. **音频会话** - 自动管理音频会话,支持蓝牙设备
4. **资源管理** - 页面关闭时自动清理音频资源和网络连接
## 自定义扩展
## 自定义配置
### 修改模拟参数
### 修改服务器地址
```dart
// 在RealtimeController中调整时间参数
Timer(const Duration(seconds: 3), () { // 聆听时长
stopListening();
});
// 在RealtimeController中修改连接URL
await _realtimeService.initialize(
serverUrl: 'ws://your-server:port/ws',
// ... 其他参数
);
```
await Future.delayed(const Duration(seconds: 2)); // AI处理时长
await Future.delayed(const Duration(seconds: 3)); // 播放时长
### 调整UI布局
```dart
// 修改字幕模式下圆形动画位置
alignment: controller.isSubtitleMode.value
? const Alignment(0, -0.9) // 调整Y轴位置
: const Alignment(0, -0.25),
```
### 添加新的视觉效果
### 音频参数调优
```dart
// 在VoiceWavePainter中添加新的绘制方法
void _drawCustomEffect(Canvas canvas, Offset center, double radius) {
// 自定义绘制逻辑
}
// 在初始化时调整音频参数
sampleRate: 16000, // 采样率
channels: 1, // 声道数
bitsPerSample: 16, // 位深度
```
## 技术栈
- **Flutter SDK**: >= 3.0.0
- **GetX**: >= 4.6.5 (状态管理)
- **Material Design**: UI组件
- **CustomPainter**: 自定义绘制
- **Flutter Shaders**: >= 0.0.6 (Shader动画)
- **原生插件**: Realtime服务 (音频处理)
- **WebSocket**: 实时通信协议
## 版本信息
- **当前版本**: v0.1.0 (Preview)
- **当前版本**: v1.0.0 (Production)
- **最后更新**: 2024年
- **状态**: 模拟功能演示
- **状态**: 完整功能实现

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

@ -1,15 +1,22 @@
import 'dart:async';
import 'dart:math';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:realtime/realtime.dart';
import '../../../core/utils/logger.dart';
class RealtimeController extends GetxController with GetTickerProviderStateMixin {
static const int _sampleRate = 16000;
// Realtime服务实例
final RealtimeService _realtimeService = RealtimeService();
// 音频振幅值(RMS)
final RxDouble audioAmplitude = 0.0.obs;
// 连接状态
final RxBool isConnected = false.obs;
final RxBool isConnecting = false.obs;
@ -18,31 +25,29 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
final RxBool isListening = false.obs;
final RxBool isSpeaking = false.obs;
// 状态和字幕显示
final RxString currentStatus = '正在初始化...'.obs;
// UI状态
final RxString currentStatus = ''.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 RxDouble userVoiceLevel = 0.0.obs;
final RxDouble aiVoiceLevel = 0.0.obs;
// 字幕显示用的最新消息
final RxString latestUserMessage = ''.obs;
final RxString latestAiMessage = ''.obs;
// 动画控制器
late AnimationController pulseAnimationController;
late Animation<double> pulseAnimation;
// 实时字幕更新用的临时消息
final RxString partialUserMessage = ''.obs;
final RxString streamingAiMessage = ''.obs;
// 状态监听
StreamSubscription<RealtimeEvent>? _eventSubscription;
Timer? _audioUpdateTimer;
@override
void onInit() {
super.onInit();
_initializeAnimations();
_initializeAudioLevels();
_setupEventListener();
Logger.info('Realtime模块初始化完成');
// 自动开始连接
WidgetsBinding.instance.addPostFrameCallback((_) {
@ -56,27 +61,6 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
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() {
_eventSubscription = _realtimeService.eventStream.listen(
@ -101,6 +85,9 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
case 'textReceived':
_handleTextReceived(event.data);
break;
case 'audioRms':
_handleAudioRms(event.data);
break;
case 'error':
_handleError(event.data);
break;
@ -109,7 +96,7 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
/// 自动连接
Future<void> _autoConnect() async {
currentStatus.value = '正在连接AI服务器...';
currentStatus.value = ''; // 不显示任何文字
await connect();
}
@ -119,9 +106,7 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
case 'connected':
isConnected.value = true;
isConnecting.value = false;
currentStatus.value = '已连接,正在开始录音...';
_startAudioUpdates();
Logger.info('已连接到Vocode服务器');
currentStatus.value = ''; // 不显示任何文字
// 连接成功后自动开始录音
Timer(const Duration(milliseconds: 500), () {
startListening();
@ -129,20 +114,18 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
break;
case 'connecting':
isConnecting.value = true;
currentStatus.value = '正在连接AI服务器...';
Logger.info('正在连接Vocode服务器...');
currentStatus.value = ''; // 不显示任何文字
break;
case 'disconnected':
isConnected.value = false;
isConnecting.value = false;
currentStatus.value = '已断开连接';
currentStatus.value = ''; // 不显示任何文字
_resetState();
Logger.info('已断开Vocode服务器连接');
break;
case 'error':
isConnected.value = false;
isConnecting.value = false;
currentStatus.value = '连接失败,请检查网络';
currentStatus.value = '连接失败,请检查网络'; // 保留错误提示
_resetState();
Logger.error('Vocode服务器连接错误');
break;
@ -155,60 +138,139 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
case 'recording':
isListening.value = true;
isSpeaking.value = false;
currentStatus.value = '正在聆听,请说话...';
pulseAnimationController.repeat(reverse: true);
Logger.info('开始录音');
currentStatus.value = ''; // 不显示任何文字
break;
case 'processing':
// 用户说话结束,等待AI回应
isListening.value = false;
currentStatus.value = 'AI正在思考中...';
currentStatus.value = ''; // 不显示任何文字
break;
case 'playing':
isListening.value = false;
isSpeaking.value = true;
currentStatus.value = 'AI正在回复...';
pulseAnimationController.repeat(reverse: true);
Logger.info('AI开始说话');
currentStatus.value = ''; // 不显示任何文字
break;
case 'idle':
isListening.value = false;
isSpeaking.value = false;
currentStatus.value = isConnected.value ? '等待用户说话...' : '已断开连接';
pulseAnimationController.stop();
Logger.info('语音状态空闲');
currentStatus.value = ''; // 不显示任何文字
// 空闲时音频振幅归零
audioAmplitude.value = 0.0;
break;
}
}
/// 处理收到的文本消息
void _handleTextReceived(String text) {
Logger.info('收到文本消息: $text');
// 根据消息来源更新相应的文本
if (text.startsWith('[USER]')) {
userSpeechText.value = text.replaceFirst('[USER]', '').trim();
} else if (text.startsWith('[AI]')) {
aiResponseText.value = text.replaceFirst('[AI]', '').trim();
/// 处理收到的文本消息 - 标准Vocode WebSocket协议
void _handleTextReceived(dynamic payload) {
if (payload is String) {
try {
// 解析JSON消息
final Map<String, dynamic> json = jsonDecode(payload);
final String messageType = json['type'] ?? '';
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 {
// 默认认为是AI回复
aiResponseText.value = text;
Logger.error('收到非字符串类型的payload: ${payload.runtimeType}');
}
}
/// 处理音频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) {
Logger.error('Realtime错误: $error');
// 可以显示错误提示
}
/// 切换连接状态
Future<void> toggleConnection() async {
if (isConnected.value) {
await disconnect();
} else {
await connect();
}
/// 切换字幕模式
void toggleSubtitleMode() {
isSubtitleMode.value = !isSubtitleMode.value;
}
/// 清空聊天记录
void clearChat() {
userSpeechText.value = '';
isUserSpeechFinal.value = true;
aiResponseMessages.clear();
latestUserMessage.value = '';
latestAiMessage.value = '';
partialUserMessage.value = '';
streamingAiMessage.value = '';
HapticFeedback.lightImpact();
}
/// 连接到AI服务
@ -219,13 +281,14 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
HapticFeedback.lightImpact();
// 初始化Realtime服务
final initialized = await _realtimeService.initialize(
serverUrl: 'ws://192.168.1.11:8000/ws', // 本地开发服务器使用ws协议
sampleRate: 16000,
bool initialized = await _realtimeService.initialize(
// serverUrl: 'ws://mcp.ideapsound.com:8000/ws',
serverUrl: 'ws://192.168.1.11:8000/ws',
sampleRate: _sampleRate,
channels: 1,
bitsPerSample: 16,
);
if (!initialized) {
throw Exception('初始化Realtime服务失败');
}
@ -248,36 +311,18 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
try {
HapticFeedback.mediumImpact();
// 停止录音和播放
if (isListening.value) {
await _realtimeService.stopRecording();
}
await _realtimeService.stopPlaying();
// 断开连接
await _realtimeService.disconnect();
// 停止本地更新
_stopAudioUpdates();
pulseAnimationController.stop();
} catch (e) {
Logger.error('断开连接失败: $e');
}
}
/// 开始/停止聆听
void toggleListening() {
if (!isConnected.value) return;
if (isListening.value) {
stopListening();
} else {
startListening();
}
}
/// 开始聆听
void startListening() {
if (!isConnected.value || isListening.value) return;
@ -302,103 +347,56 @@ class RealtimeController extends GetxController with GetTickerProviderStateMixin
});
}
/// 开始音频数据更新
void _startAudioUpdates() {
_audioUpdateTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (!isConnected.value) {
timer.cancel();
return;
}
_updateAudioLevels();
});
}
/// 停止音频数据更新
void _stopAudioUpdates() {
_audioUpdateTimer?.cancel();
_audioUpdateTimer = null;
}
/// 更新音频级别
void _updateAudioLevels() {
final random = Random();
/// 切换聆听状态
void toggleListening() {
if (!isConnected.value) return;
// 更新用户语音级别
if (isListening.value) {
userVoiceLevel.value = 0.3 + random.nextDouble() * 0.7;
} else {
userVoiceLevel.value = userVoiceLevel.value * 0.8; // 衰减
}
// 更新AI语音级别
if (isSpeaking.value) {
aiVoiceLevel.value = 0.2 + random.nextDouble() * 0.8;
stopListening();
} 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 {
// 立即返回上一页,避免阻塞UI
Get.back();
// 之后异步进行清理,不阻塞页面切换
unawaited(_performShutdown());
}
/// 实际执行关闭逻辑(后台)
Future<void> _performShutdown() async {
try {
currentStatus.value = '正在关闭连接...';
if (isListening.value) {
await _realtimeService.stopRecording();
// 异步清理资源
Future.delayed(Duration.zero, () async {
try {
if (isListening.value) {
await _realtimeService.stopRecording();
}
await _realtimeService.stopPlaying();
await _realtimeService.disconnect();
await _realtimeService.dispose();
_resetState();
} catch (e) {
Logger.error('关闭连接失败: $e');
}
await _realtimeService.stopPlaying();
await _realtimeService.disconnect();
_resetState();
Logger.info('实时语音连接已关闭');
} catch (e) {
Logger.error('关闭连接失败: $e');
}
});
}
/// 重置状态
void _resetState() {
isConnected.value = false;
isConnecting.value = false;
isListening.value = false;
isSpeaking.value = false;
userSpeechText.value = '';
aiResponseText.value = '';
_resetAudioLevels();
pulseAnimationController.stop();
_stopAudioUpdates();
isUserSpeechFinal.value = true;
aiResponseMessages.clear();
latestUserMessage.value = '';
latestAiMessage.value = '';
partialUserMessage.value = '';
streamingAiMessage.value = '';
audioAmplitude.value = 0.0;
}
/// 清理资源
void _cleanup() async {
_eventSubscription?.cancel();
_stopAudioUpdates();
pulseAnimationController.dispose();
try {
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:get/get.dart';
import '../controllers/realtime_controller.dart';
import 'widgets/voice_mic_flow.dart';
import 'widgets/shader_mic_flow.dart';
class RealtimeView extends GetView<RealtimeController> {
const RealtimeView({Key? key}) : super(key: key);
@ -22,219 +22,311 @@ class RealtimeView extends GetView<RealtimeController> {
backgroundColor: Colors.white,
body: SafeArea(
child: Stack(
children: [
// 右上角图标按钮
Positioned(
top: 16,
right: 16,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 分享按钮
Icon(
Icons.ios_share,
color: Colors.grey[600],
size: 24,
),
const SizedBox(width: 20),
// 设置按钮
Icon(
Icons.tune,
color: Colors.grey[600],
size: 24,
children: [
// 中间音频可视化
Positioned.fill(
child: Obx(() => AnimatedAlign(
alignment: controller.isSubtitleMode.value
? const Alignment(0, -0.7) // 字幕模式时移到更上方
: const Alignment(0, -0.25),
duration: const Duration(milliseconds: 600),
curve: Curves.easeInOutCubic,
child: LayoutBuilder(
builder: (context, constraints) {
final targetSize = controller.isSubtitleMode.value
? MediaQuery.of(context).size.width * 0.55 // 字幕模式时较小
: MediaQuery.of(context).size.width * 0.75; // 初始状态更大
return TweenAnimationBuilder<double>(
tween: Tween(end: targetSize),
duration: const Duration(milliseconds: 600),
curve: Curves.easeInOutCubic,
builder: (context, animatedSize, child) {
return Stack(
alignment: Alignment.center,
children: [
// 使用Shader动画
Obx(() => ShaderMicFlow(
size: animatedSize,
amplitude: controller.audioAmplitude.value,
)),
],
);
},
);
},
),
],
)),
),
),
// 状态和字幕显示区域
Positioned(
top: 100,
left: 20,
right: 20,
child: Column(
children: [
// 当前状态显示
Obx(() => Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
controller.currentStatus.value,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
// 字幕显示区域 - 只有在字幕模式开启时才显示,位于圆形下方
if (controller.isSubtitleMode.value)
Positioned(
left: 20,
right: 20,
top: MediaQuery.of(context).size.height * 0.35, // 在圆形下方
bottom: 160,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Obx(() => AnimatedOpacity(
opacity: controller.isSubtitleMode.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: TweenAnimationBuilder<double>(
tween: Tween(
begin: 0.0,
end: controller.isSubtitleMode.value ? 1.0 : 0.0,
),
duration: const Duration(milliseconds: 900), // 延迟显示,让圆圈先移动
curve: Curves.easeInOut,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - animationValue)), // 从下方滑入
child: Opacity(
opacity: animationValue,
child: _buildChatSubtitles(),
),
);
},
),
),
)),
// 用户ASR内容显示
Obx(() => AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: double.infinity,
height: controller.userSpeechText.value.isNotEmpty ? null : 0,
padding: controller.userSpeechText.value.isNotEmpty
? const EdgeInsets.symmetric(horizontal: 16, vertical: 12)
: EdgeInsets.zero,
margin: controller.userSpeechText.value.isNotEmpty
? const EdgeInsets.only(bottom: 8)
: EdgeInsets.zero,
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.blue.withOpacity(0.3),
width: 1,
)),
),
),
// 底部控制按钮
Positioned(
bottom: 80,
left: 0,
right: 0,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 麦克风按钮
Obx(() => _buildMicButton()),
// 字幕按钮
Obx(() => _buildMainButton(
icon: controller.isSubtitleMode.value ? Icons.subtitles : Icons.subtitles_outlined,
onTap: controller.toggleSubtitleMode,
enabled: true,
isActive: controller.isSubtitleMode.value,
)),
// 清空聊天按钮
_buildMainButton(
icon: Icons.clear_all,
onTap: controller.clearChat,
enabled: true,
isActive: false,
),
),
child: controller.userSpeechText.value.isNotEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'你说:',
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,
// 关闭按钮
_buildMainButton(
icon: Icons.close,
onTap: controller.closeAndGoBack,
enabled: true,
isActive: false,
),
),
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,
)),
],
],
),
),
),
),
// 中间会呼吸的云彩动画
Positioned.fill(
child: Align(
alignment: const Alignment(0, 0.1),
child: const VoiceMicFlow(size: 280),
// Home Indicator
Positioned(
bottom: 8,
left: 0,
right: 0,
child: Center(
child: Container(
width: 134,
height: 5,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(2.5),
),
),
),
),
),
],
),
),
);
}
// 底部控制按钮
Positioned(
bottom: 80,
left: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 摄像头按钮
_buildBottomButton(Icons.videocam, null),
// 麦克风按钮
_buildBottomButton(Icons.mic_none, controller.toggleListening),
// 更多按钮
_buildBottomButton(Icons.more_horiz, null),
// 关闭按钮
_buildBottomButton(Icons.close, controller.closeAndGoBack),
],
/// 构建聊天字幕界面 - 上下两行显示用户输入和AI回复
Widget _buildChatSubtitles() {
return Obx(() {
// 组合用户消息:优先显示实时部分转录,否则显示最终消息
final String displayUserMessage = controller.partialUserMessage.value.isNotEmpty
? controller.partialUserMessage.value
: controller.latestUserMessage.value;
// 组合AI消息:优先显示流式响应,否则显示最终消息
final String displayAiMessage = controller.streamingAiMessage.value.isNotEmpty
? controller.streamingAiMessage.value
: controller.latestAiMessage.value;
final hasUserMessage = displayUserMessage.isNotEmpty;
final hasAiMessage = displayAiMessage.isNotEmpty;
// 如果没有消息,显示空白
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(
bottom: 8,
left: 0,
right: 0,
child: Center(
child: Container(
width: 134,
height: 5,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(2.5),
// 分隔线 - 只有在两个消息都存在时才显示
if (hasUserMessage && hasAiMessage)
Container(
width: 60,
height: 1,
margin: const EdgeInsets.symmetric(vertical: 2), // 进一步减少分隔线间距
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(0.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(
onTap: onTap,
onTap: enabled ? onTap : null,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.grey[200],
color: enabled
? (isActive ? Theme.of(Get.context!).primaryColor : Colors.grey[200])
: Colors.grey[300],
shape: BoxShape.circle,
),
child: Icon(
icon,
color: Colors.black,
color: enabled
? (isActive ? Colors.white : Colors.black)
: Colors.grey[600],
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'), // 添加马来语选项
LanguageModel(name: 'turkish', alias: 'Türkçe', value: 'tr_TR'), // 添加土耳其语选项
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;

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

@ -512,8 +512,7 @@ class SettingsView extends GetView<SettingsController> {
iconBgColor: isDarkMode
? Colors.red[900]!.withOpacity(0.3)
: Colors.red[100]!,
iconColor:
isDarkMode ? Colors.red[300]! : Colors.red[600]!,
iconColor: isDarkMode ? Colors.red[300]! : Colors.red[600]!,
titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null,
onTap: () {
@ -550,7 +549,6 @@ class SettingsView extends GetView<SettingsController> {
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
// BLE测试
_buildSimpleNavigationSetting(
title: 'BLE蓝牙测试',
@ -627,8 +625,6 @@ class SettingsView extends GetView<SettingsController> {
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
// 退出登录
// _buildSimpleNavigationSetting(
// title: 'logout'.tr, // 退出登录
@ -917,7 +913,8 @@ class SettingsView extends GetView<SettingsController> {
),
),
SizedBox(height: 4.h),
if (Get.locale?.languageCode == 'zh')
if (Get.locale?.languageCode == 'zh' &&
Get.locale?.countryCode == 'CN')
InkWell(
onTap: () => Get.to(
() => const WebViewPage(
@ -933,6 +930,16 @@ class SettingsView extends GetView<SettingsController> {
),
),
),
//------------测试使用标记 start---------------//
SizedBox(height: 4.h),
Text(
'${Platform.operatingSystem}-test-${DateTime.now().month.toString().padLeft(2, '0')}${DateTime.now().day.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12.sp,
color: const Color.fromARGB(255, 143, 94, 94),
),
),
//------------测试使用标记 end---------------//
],
),
),

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

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

37
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -12,7 +12,11 @@ import music_service
protocol AgentServiceListener: AnyObject {
func onEvent(eventName: String, data: [String: Any])
}
extension Notification.Name {
static let navigationdEvent = Notification.Name("navigationdEvent") //导航事件
static let locationstartEvent = Notification.Name("locationstartEvent") //启动定位
static let updatelocationEvent = Notification.Name("updatelocationEvent") //更新位置事件
}
class AgentServiceImpl: NSObject {
private let TAG = "AgentServiceImpl"
@ -55,6 +59,7 @@ class AgentServiceImpl: NSObject {
private let maxIdleSeconds: TimeInterval = 10
internal var audioPlayer: AudioPlayer?
private var locationInfo:[String: String]?
internal let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AgentServiceImpl")
@ -161,7 +166,9 @@ class AgentServiceImpl: NSObject {
sendError("Azure语音区域缺失", code: "CONFIG_ERROR")
return false
}
//启动定位服务
NotificationCenter.default.post(name: .locationstartEvent, object: nil)
addlocationmonitor()
if let mcpServer = config["mcpServer"] as? String {
self.mcpServer = mcpServer
}
@ -441,7 +448,10 @@ class AgentServiceImpl: NSObject {
var messages: [[String: Any]] = []
if !systemPrompt.isEmpty {
messages.append(chatApiService.createSystemMessage(content: systemPrompt))
let info: [String: String] = self.locationInfo ?? ["user_location": "还未获取到位置信息", "user_lat": "未开启定位", "user_lng": "未开启定位"]
let _systemPrompt = fillTemplate(systemPrompt, with: info)
messages.append(chatApiService.createSystemMessage(content: _systemPrompt))
os_log("添加系统提示消息", log: logger, type: .info)
}
@ -690,6 +700,27 @@ class AgentServiceImpl: NSObject {
MusicService.shared.playPlaylist(songs: songs)
}
// 添加定位监听
private func addlocationmonitor(){
// 通常放在启动后或模块初始化时
NotificationCenter.default.addObserver(
forName: .updatelocationEvent,
object: nil,
queue: .main
) { notification in
if let info = notification.userInfo as? [String: String] {
print("📤 地址信息:\(info)")
self.locationInfo = info
}
}
}
func fillTemplate(_ template: String, with values: [String: String]) -> String {
var result = template
for (key, value) in values {
result = result.replacingOccurrences(of: "{\(key)}", with: value)
}
return result
}
func clearChatHistory() -> Bool {
chatHistory.removeAll()

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.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import com.yunqiinnovation.azure_speech.tools.RecordFile
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import android.os.Handler
import android.os.HandlerThread
/**
* Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别
@ -65,9 +58,13 @@ class AzureAsrHelper(private val context: Context) {
private var microphoneStream: MicrophoneStream? = null
private var externalAudioStream: ExternalAudioPullStream? = null
// 音频模式管理
private var audioManager: AudioManager? = null
private var originalAudioMode = AudioManager.MODE_NORMAL
// 录音文件处理
var recordfile: RecordingFile? = null
private var isRecording = false
var recordfile: RecordFile? = null
/**
* 音频来源类型
@ -105,6 +102,10 @@ class AzureAsrHelper(private val context: Context) {
// 释放之前的资源
dispose()
// 初始化音频管理器
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL
// 保存配置
this.subscriptionKey = subscriptionKey
this.region = region
@ -145,7 +146,7 @@ class AzureAsrHelper(private val context: Context) {
setProperty("Speech_SegmentationStrategy", "Time")
}
// 录音文件类
recordfile = RecordingFile()
recordfile = RecordFile;
// 创建识别器
val setupSuccess = setupRecognizer()
if (setupSuccess) {
@ -171,36 +172,9 @@ class AzureAsrHelper(private val context: Context) {
// 设置音频配置
when (audioSourceType) {
AudioSourceType.MICROPHONE -> {
// 判断是否使用手机扬声器输出和耳机连接状态
val audioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
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()
}
// 总是使用拉流方式并启用回音消除
Log.i(tag, "使用拉流方式并启用回音消除")
setupMicrophoneStream()
}
AudioSourceType.EXTERNAL -> {
@ -379,10 +353,7 @@ class AzureAsrHelper(private val context: Context) {
// 开始连续识别
recognizer?.startContinuousRecognitionAsync()
isContinuousRecognitionActive = true
Log.e(tag, "开始连续语音识别 是否录音${isRecording}")
if (isRecording) {
enableRecord()
}
return true
} catch (e: Exception) {
stopAudioProcessing()
@ -529,7 +500,7 @@ class AzureAsrHelper(private val context: Context) {
// 停止音频处理
stopAudioProcessing()
// 停止录音
recordfile!!.closeFile()
recordfile!!.closeFile(true)
// 释放recognizer
recognizer?.close()
recognizer = null
@ -541,7 +512,7 @@ class AzureAsrHelper(private val context: Context) {
// 释放音频配置
audioConfig?.close()
audioConfig = null
isRecording = false
// 确保状态被重置
isContinuousRecognitionActive = false
microphoneStream = null
@ -551,7 +522,7 @@ class AzureAsrHelper(private val context: Context) {
speechConfig = null
} catch (e: Exception) {
// 确保状态被重置
isRecording = false
isContinuousRecognitionActive = false
microphoneStream = null
externalAudioStream = null
@ -607,41 +578,56 @@ class AzureAsrHelper(private val context: Context) {
/**
* 开启录音
*/
fun enableRecord() {
fun enableRecord(filePath: String) {
Log.i(tag, "开启录音:")
if( recordfile!!.isPause)
{
recordfile!!.isPause=false
}
else{
recordfile!!.closeFile()
isRecording = true
if (isContinuousRecognitionActive) {
recordfile!!.creatingFiles()
}
}
recordfile!!.closeFile(true)
recordfile!!.creatingFiles(filePath)
}
/**
* 移动文件到新路径
*/
fun moveFile(sourcePath: String, destPath: String): Boolean {
Log.i(tag, "移动文件到新路径:")
recordfile!!.moveFile(sourcePath, destPath)
return true
}
/**
* 重命名指定路径的音频文件
*/
fun renameFile(filePath: String, newName: String): Boolean {
Log.i(tag, "重命名指定路径的音频文件:")
recordfile!!.renameFile(filePath, newName)
return true
}
/**
/**
* 停止录音
*/
fun pauseRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=true
recordfile!!.isPause = true
}
/**
* 停止录音
* 关闭录音
*/
fun stopRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=false
recordfile!!.closeFile()
fun stopRecord(isSave: Boolean) {
Log.i(tag, "关闭录音:")
recordfile!!.isPause = false
recordfile!!.closeFile(isSave)
}
/**
@ -651,6 +637,8 @@ else{
private inner class MicrophoneStream : PullAudioInputStreamCallback() {
private var audioRecord: AudioRecord? = null
private var echoCanceler: AcousticEchoCanceler? = null
private var noiseSuppressor: NoiseSuppressor? = null
private var automaticGainControl: AutomaticGainControl? = null
// 音频配置
private val sampleRate = 16000
@ -715,19 +703,45 @@ else{
}
/**
* 优化:条件性应用音频效果
* 应用音频效果 - 总是启用所有音频增强功能
*/
private fun applyAudioEffects() {
val sessionId = audioRecord?.audioSessionId ?: return
// 优化:只在真正需要时启用回音消除
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (audioManager.isSpeakerphoneOn) {
echoCanceler = AcousticEchoCanceler.create(sessionId)
echoCanceler?.enabled = true
Log.d(tag, "启用回音消除")
} else {
Log.d(tag, "跳过回音消除(使用耳机)")
try {
// 设置音频模式为通信模式
this@AzureAsrHelper.audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
// 启用声学回音消除
if (AcousticEchoCanceler.isAvailable()) {
echoCanceler = AcousticEchoCanceler.create(sessionId)
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 = null
echoCanceler?.let {
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 = null
} catch (e: Exception) {
Log.e(tag, "释放音频资源失败: ${e.message}")
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.Result
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.TtsEventListener
import com.deep_voice.speech.tts.TtsEventType
@ -354,24 +353,57 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
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" -> {
val fileName = call.argument<String>("fileName")?: ""
val filePath = call.argument<String>("filePath") ?: ""
try {
FileLogger.d(tag, "音频文件名称为: ${fileName}") //
azureAsrHelper.recordfile?.fileName= fileName!!
azureAsrHelper.enableRecord()
result.success(true)
} catch (e: Exception) {
FileLogger.d(tag, "音频文件名称为: ${filePath}") //
azureAsrHelper.enableRecord(filePath)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
"pauseRecord" -> {
"pauseRecord" -> {
azureAsrHelper.pauseRecord()
result.success(true)
}
"stopRecord" -> {
azureAsrHelper.stopRecord()
result.success(true)
val isSave = call.argument<Boolean>("isSave") ?: false
try {
azureAsrHelper.stopRecord(isSave)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
else -> {

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)
// 优化:使用更低延迟的音频格式(从24KHz降到16KHz)
// 使用标准的音频格式(16KHz, 16bit, 单声道)
speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff16Khz16BitMonoPcm)
// 确保使用正确的语音输出
speechConfig?.setProperty("SPEECH-AudioOutputFormat", "riff-16khz-16bit-mono-pcm")
// 优化:设置低延迟连接属性
speechConfig?.setProperty("SpeechServiceConnection_InitialSilenceTimeoutMs", "300")
speechConfig?.setProperty("SpeechServiceConnection_EndSilenceTimeoutMs", "300")
speechConfig?.setSpeechSynthesisVoiceName(currentVoice)
// 添加日志记录语音格式
FileLogger.d(TAG, "语音设置: voice=$currentVoice, format=Riff16Khz16BitMonoPcm")
// 创建音频配置
val audioConfig = if (customAudioOutputStream != null) {
// 使用自定义音频输出流
@ -185,21 +191,47 @@ class AzureTtsHelper(private val context: Context) : ITtsService {
private fun setupEventListeners() {
synthesizer?.apply {
// 合成开始事件
SynthesisStarted?.addEventListener { _, _ ->
SynthesisStarted?.addEventListener { _, eventArgs ->
FileLogger.d(TAG, "语音合成开始: resultId=${eventArgs.result.resultId}")
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
notifyEvent(TtsEventType.SYNTHESIS_COMPLETED)
}
// 合成取消事件
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
notifyEvent(TtsEventType.SYNTHESIS_CANCELED)
}
}
@ -397,6 +429,8 @@ class AzureTtsHelper(private val context: Context) : ITtsService {
isSpeaking = true
FileLogger.d(TAG, "开始语音合成,文本长度: ${text.length}")
// 直接调用SpeakSsmlAsync,SDK内部已经是异步的
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.FileOutputStream
import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import android.os.Handler
import android.os.HandlerThread
import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log
/**

25
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt

@ -8,7 +8,9 @@ import android.net.Uri
import android.os.Build
import android.provider.CalendarContract
import android.telephony.SmsManager
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED
import java.text.SimpleDateFormat
import java.util.*
@ -393,7 +395,10 @@ private class GetCurrentLocationHandler(private val context: Context?) : Functio
private class MediaPlayHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
// 发送媒体播放广播
context?.sendBroadcast(Intent("com.yunqiinnovation.deepsound.MEDIA_PLAY"))
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PLAY").apply {
setPackage(context?.packageName) // 设置包名使其成为显式 Intent
}
context?.sendBroadcast(intent)
return "{\"result\": \"已开始播放媒体\"}"
}
}
@ -403,8 +408,12 @@ private class MediaPlayHandler(private val context: Context?) : FunctionHandler
*/
private class MediaPauseHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
Log.d("MediaPauseHandler", "📤 正在发送广播 context = $context")
// 发送媒体暂停广播
context?.sendBroadcast(Intent("com.yunqiinnovation.deepsound.MEDIA_PAUSE"))
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PAUSE").apply {
setPackage(context?.packageName) // 设置包名使其成为显式 Intent
}
context?.sendBroadcast(intent)
return "{\"result\": \"已暂停媒体播放\"}"
}
}
@ -415,7 +424,11 @@ private class MediaPauseHandler(private val context: Context?) : FunctionHandler
private class MediaPreviousHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
// 发送切换上一首广播
context?.sendBroadcast(Intent("com.yunqiinnovation.deepsound.MEDIA_PREVIOUS"))
// context?.applicationContext?.sendBroadcast(Intent("com.yunqiinnovation.music_service.MEDIA_PREVIOUS"))
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PREVIOUS").apply {
setPackage(context?.packageName) // 设置包名使其成为显式 Intent
}
context?.sendBroadcast(intent)
return "{\"result\": \"已切换到上一首\"}"
}
}
@ -426,7 +439,11 @@ private class MediaPreviousHandler(private val context: Context?) : FunctionHand
private class MediaNextHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
// 发送切换下一首广播
context?.sendBroadcast(Intent("com.yunqiinnovation.deepsound.MEDIA_NEXT"))
// context?.applicationContext?.sendBroadcast(Intent("com.yunqiinnovation.music_service.MEDIA_NEXT"))
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_NEXT").apply {
setPackage(context?.packageName) // 设置包名使其成为显式 Intent
}
context?.sendBroadcast(intent)
return "{\"result\": \"已切换到下一首\"}"
}
}

2
local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift

@ -67,7 +67,7 @@ public class SystemFunctionHandler: NSObject {
registerGetCurrentTimeFunction(client: client)
// 注册获取当前位置函数
registerGetCurrentLocationFunction(client: client)
// registerGetCurrentLocationFunction(client: client)
// 注册媒体控制函数
registerMediaPlayFunction(client: client)

131
local_plugins/location_service/ios/Classes/LocationService.swift

@ -0,0 +1,131 @@
import Foundation
import AMapLocationKit
extension Notification.Name {
static let locationstartEvent = Notification.Name("locationstartEvent") //启动定位
static let updatelocationEvent = Notification.Name("updatelocationEvent") //更新位置事件
}
class LocationService: NSObject {
static let shared = LocationService()
private let TAG = "LocationService"
private var locationManager: AMapLocationManager!
private var currentLocation: CLLocation?
private var currentReGeocode: AMapLocationReGeocode?
private var initialized = false
private override init() {
super.init()
}
// 初始化定位服务
func initialize() {
if initialized { return }
print("\(TAG)--------- 初始化高德定位服务")
locationManager = AMapLocationManager()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest // 定位精度
locationManager.distanceFilter = 10.0 // 移动距离阈值(单位:米)
locationManager.locatingWithReGeocode = true // 是否返回逆地理编码信息
locationManager.allowsBackgroundLocationUpdates = true // 是否允许后台定位
locationManager.locationTimeout = 10 // 定位超时时间(秒)
locationManager.reGeocodeTimeout = 5 // 逆地理编码超时时间(秒)
locationManager.startUpdatingLocation()
initialized = true
}
// 获取缓存的位置信息
func getCachedLocation() -> [String: String]? {
guard let location = currentLocation else { return nil }
var locationInfo: [String: String] = [
"user_lat": "\(location.coordinate.latitude)",
"user_lng": "\(location.coordinate.longitude)"
]
if let regeocode = currentReGeocode {
let address = [
regeocode.province, // 省
regeocode.city, // 市
regeocode.district, // 区
regeocode.street, // 街道
regeocode.number // 门牌号
].compactMap { $0 }.joined(separator: "")
locationInfo["user_location"] = address
}
return locationInfo
}
// 获取当前位置(带回调)
func getCurrentLocation(completion: @escaping ([String: String]?, Error?) -> Void) {
if let cached = getCachedLocation() {
completion(cached, nil)
} else {
let error = NSError(
domain: "LocationService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "当前位置还未获取"]
)
completion(nil, error)
}
}
// 停止定位服务
func stop() {
locationManager.stopUpdatingLocation()
locationManager = nil
currentLocation = nil
currentReGeocode = nil
initialized = false
}
}
// MARK: - AMapLocationManagerDelegate
extension LocationService: AMapLocationManagerDelegate {
// 定位更新回调
func amapLocationManager(_ manager: AMapLocationManager!, didUpdate location: CLLocation!, reGeocode: AMapLocationReGeocode!) {
currentLocation = location
currentReGeocode = reGeocode
print("\(TAG)--------- 定位更新: \(location.coordinate.latitude), \(location.coordinate.longitude)")
var locationInfo: [String: String] = [
"user_lat": "\(location.coordinate.latitude)",
"user_lng": "\(location.coordinate.longitude)"
]
if let regeocode = currentReGeocode {
let address = [
reGeocode.province, // 省
reGeocode.city, // 市
reGeocode.district, // 区
reGeocode.street, // 街道
reGeocode.number // 门牌号
].compactMap { $0 }.joined(separator: "")
print("\(TAG)--------- 定位地址: \(address)")
locationInfo["user_location"] = address
}
NotificationCenter.default.post(
name: .updatelocationEvent,
object: nil,
userInfo: locationInfo
)
}
// 定位失败回调
func amapLocationManager(_ manager: AMapLocationManager!, didFailWithError error: Error!) {
print("\(TAG)--------- 定位失败: \(error.localizedDescription)")
}
// 权限变更回调
func amapLocationManager(_ manager: AMapLocationManager!, doRequireLocationAuth locationManager: CLLocationManager!) {
locationManager.requestWhenInUseAuthorization()
}
}

44
local_plugins/location_service/ios/Classes/LocationServicePlugin.swift

@ -0,0 +1,44 @@
import Flutter
import UIKit
import AMapLocationKit
public class LocationServicePlugin: NSObject, FlutterPlugin {
private static var channel: FlutterMethodChannel?
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.location_service", binaryMessenger: registrar.messenger())
let instance = LocationServicePlugin()
// 必须先设置隐私合规
DispatchQueue.main.async {
// 2. API Key
AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03"
AMapServices.shared().enableHTTPS = true
// 3. 打印验证(调试用)
print("高德SDK初始化完成")
}
registrar.addMethodCallDelegate(instance, channel: channel)
self.channel = channel
instance.addstartlocationmonitor()
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "location":
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
private func addstartlocationmonitor(){
// 通常放在启动后或模块初始化时
NotificationCenter.default.addObserver(
forName: .locationstartEvent,
object: nil,
queue: .main
) { notification in
print("📥 收到 启动定位服务事件")
LocationService.shared.initialize()
}
}
}

6
local_plugins/location_service/ios/location_service.podspec

@ -13,10 +13,10 @@ A new Flutter project.
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.source = { :path => '.' }
s.source_files = 'location_service/Sources/location_service/**/*'
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
s.dependency 'AMapNavi', '~> 10.0.900'
s.dependency 'AMapLocation', '~> 2.10.0'
s.static_framework = true
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',

32
local_plugins/location_service/ios/location_service/Package.swift

@ -1,32 +0,0 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "location_service",
platforms: [
.iOS("12.0")
],
products: [
.library(name: "location-service", targets: ["location_service"])
],
dependencies: [],
targets: [
.target(
name: "location_service",
dependencies: [],
resources: [
// If your plugin requires a privacy manifest, for example if it uses any required
// reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's
// privacy impact, and then uncomment these lines. For more information, see
// https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
// .process("PrivacyInfo.xcprivacy"),
// If you have other resources that need to be bundled with your plugin, refer to
// the following instructions to add them:
// https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package
]
)
]
)

19
local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift

@ -1,19 +0,0 @@
import Flutter
import UIKit
public class LocationServicePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "location_service", binaryMessenger: registrar.messenger())
let instance = LocationServicePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
result("iOS " + UIDevice.current.systemVersion)
default:
result(FlutterMethodNotImplemented)
}
}
}

14
local_plugins/location_service/ios/location_service/Sources/location_service/PrivacyInfo.xcprivacy

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

27
local_plugins/location_service/lib/location_service.dart

@ -11,33 +11,8 @@ class LocationService {
'com.yunqiinnovation.location_service',
);
// 事件通道 - 简化为只使用数据和状态通道
late final EventChannel _dataEventChannel;
late final EventChannel _statusEventChannel;
// 事件流
late final Stream<dynamic> _dataStream;
late final Stream<dynamic> _statusStream;
// 获取事件流
Stream<dynamic> get onDataReceived => _dataStream;
// 状态流直接传递原始数据
Stream<dynamic> get onStatusUpdated => _statusStream;
// 内部构造函数
LocationService._internal() {
_dataEventChannel = const EventChannel(
'com.yunqiinnovation.music_service/data_events',
);
_statusEventChannel = const EventChannel(
'com.yunqiinnovation.music_service/status_events',
);
// 初始化事件流
_dataStream = _dataEventChannel.receiveBroadcastStream();
_statusStream = _statusEventChannel.receiveBroadcastStream();
}
LocationService._internal() {}
Future<Map<String, double>> location() async {
final result = await _methodChannel.invokeMethod<Map>('location');

45
local_plugins/location_service/pubspec.yaml

@ -4,7 +4,7 @@ version: 0.0.1
homepage:
environment:
sdk: ^3.8.1
sdk: '>=3.8.0'
flutter: '>=3.3.0'
dependencies:
@ -17,21 +17,8 @@ dev_dependencies:
sdk: flutter
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# This section identifies this Flutter project as a plugin project.
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
# which should be registered in the plugin registry. This is required for
# using method channels.
# The Android 'package' specifies package in which the registered class is.
# This is required for using method channels on Android.
# The 'ffiPlugin' specifies that native code should be built and bundled.
# This is required for using `dart:ffi`.
# All these are used by the tooling to maintain consistency when
# adding or updating assets for this project.
plugin:
platforms:
android:
@ -40,33 +27,3 @@ flutter:
ios:
pluginClass: LocationServicePlugin
# To add assets to your plugin package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/to/asset-from-package
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# To add custom fonts to your plugin package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/to/font-from-package

74
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt

@ -4,8 +4,10 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.BitmapFactory
import android.media.AudioManager
import android.media.MediaPlayer
@ -42,7 +44,7 @@ class MusicService : MediaBrowserServiceCompat() {
fun isPlaying() = currentStatus
fun getCurrentUrl(): String? = currentUrlInternal
}
private var isReceiverRegistered = false
private lateinit var mediaSession: MediaSessionCompat
private lateinit var audioManager: AudioManager
private var mediaPlayer: MediaPlayer? = null
@ -53,6 +55,22 @@ class MusicService : MediaBrowserServiceCompat() {
setupMediaSession()
startForeground(NOTIFICATION_ID, createNotification("等待播放","DeepSound",""))
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
// 注册广播监听器
val filter = IntentFilter().apply {
addAction("com.yunqiinnovation.music_service.MEDIA_PLAY")
addAction("com.yunqiinnovation.music_service.MEDIA_PAUSE")
addAction("com.yunqiinnovation.music_service.MEDIA_PREVIOUS")
addAction("com.yunqiinnovation.music_service.MEDIA_NEXT")
}
// Android 14+ 需要指定 flag
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(controlReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(controlReceiver, filter)
}
isReceiverRegistered = true
Log.i(TAG, "MediaService 启动完成")
}
@ -101,6 +119,32 @@ class MusicService : MediaBrowserServiceCompat() {
return START_STICKY
}
private val controlReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.i(TAG, "接受到通知事件 ${intent?.action}")
when (intent?.action) {
"com.yunqiinnovation.music_service.MEDIA_PAUSE" -> {
Log.i(TAG, "收到 MEDIA_PAUSE 广播")
pause()
}
"com.yunqiinnovation.music_service.MEDIA_PLAY" -> {
Log.i(TAG, "收到 MEDIA_PLAY 广播")
resume()
}
"com.yunqiinnovation.music_service.MEDIA_PREVIOUS" -> {
Log.i(TAG, "收到 MEDIA_PREVIOUS 广播")
playPrevious()
}
"com.yunqiinnovation.music_service.MEDIA_NEXT" -> {
Log.i(TAG, "收到 MEDIA_NEXT 广播")
playNext()
}
// 你也可以扩展支持更多指令
}
}
}
private fun setupMediaSession() {
mediaSession = MediaSessionCompat(this, "MusicMediaSession").apply {
setFlags(
@ -209,19 +253,33 @@ class MusicService : MediaBrowserServiceCompat() {
return builder.build()
}
fun mediaButtonPendingIntent(action: Long): PendingIntent {
val intent = Intent(this, MusicService::class.java).apply {
this.action = Intent.ACTION_MEDIA_BUTTON
putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, action.toKeyCode()))
val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, action.toKeyCode())
val intent = Intent(Intent.ACTION_MEDIA_BUTTON).apply {
setPackage(packageName)
putExtra(Intent.EXTRA_KEY_EVENT, keyEvent)
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
return PendingIntent.getService(this, action.toInt(), intent, flags)
return PendingIntent.getBroadcast(this, action.toInt(), intent, flags)
}
// fun mediaButtonPendingIntent(action: Long): PendingIntent {
// val intent = Intent(this, MusicService::class.java).apply {
// this.action = Intent.ACTION_MEDIA_BUTTON
// putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, action.toKeyCode()))
// }
// val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
// } else {
// PendingIntent.FLAG_UPDATE_CURRENT
// }
// return PendingIntent.getService(this, action.toInt(), intent, flags)
// }
// 转换 PlaybackStateCompat.ACTION 到 KeyEvent
fun Long.toKeyCode(): Int = when (this) {
PlaybackStateCompat.ACTION_PLAY -> KeyEvent.KEYCODE_MEDIA_PLAY
@ -358,6 +416,10 @@ class MusicService : MediaBrowserServiceCompat() {
) = result.sendResult(mutableListOf())
override fun onDestroy() {
if (isReceiverRegistered) {
unregisterReceiver(controlReceiver)
isReceiverRegistered = false
}
mediaPlayer?.release()
stopForeground(true)
isRunning.set(false)

32
local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift

@ -9,6 +9,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
let instance = MusicServicePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
self.channel = channel
instance.addNSNotification()
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
@ -68,4 +69,35 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
result(FlutterMethodNotImplemented)
}
}
private func addNSNotification(){
NotificationCenter.default.addObserver(
forName: NSNotification.Name("MediaPause"),
object: nil,
queue: .main
) { _ in
MusicService.shared.pause()
}
NotificationCenter.default.addObserver(
forName: NSNotification.Name("MediaPlay"),
object: nil,
queue: .main
) { _ in
MusicService.shared.resume()
}
NotificationCenter.default.addObserver(
forName: NSNotification.Name("MediaPrevious"),
object: nil,
queue: .main
) { _ in
MusicService.shared.previous()
}
NotificationCenter.default.addObserver(
forName: NSNotification.Name("MediaNext"),
object: nil,
queue: .main
) { _ in
MusicService.shared.next()
}
}
}

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
}
})
// 初始化 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) {
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")
// 接收传入的参数
val arguments = call.arguments as? Map<String, Any>
@ -326,13 +330,16 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler {
BleService.isEnterOta = false
}
result.success("")
} else if (call.method == "onOtaClose") {
BleService.isEnterOta = false
isDisconnect = false
otaManager.bluetoothHelper.stopScan()
otaManager.bluetoothHelper.disconnectDevice(otaDevice)
otaManager?.bluetoothHelper?.stopScan()
otaManager?.bluetoothHelper?.disconnectDevice(otaDevice)
result.success("")
} else {
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.media.*
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import kotlinx.coroutines.*
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
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 playJob: Job? = null
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 onStatusChanged: ((VoiceStatus) -> Unit)? = null
var onRmsChanged: ((Float) -> Unit)? = null // 新增RMS回调
// 协程作用域
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -40,6 +57,12 @@ class RealtimeAudioManager(private val context: Context) {
/// 初始化音频管理器
fun initialize(config: AudioConfig): Boolean {
this.audioConfig = config
// 初始化系统音频管理器
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// 保存原始音频模式
originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL
return setupAudioRecord(config) && setupAudioTrack(config)
}
@ -117,13 +140,14 @@ class RealtimeAudioManager(private val context: Context) {
return false
}
// 总是使用VOICE_COMMUNICATION模式以获得最佳语音处理效果
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
audioTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioAttributes(audioAttributes)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(config.sampleRate)
@ -140,7 +164,7 @@ class RealtimeAudioManager(private val context: Context) {
return false
}
Log.i(TAG, "AudioTrack初始化成功")
Log.i(TAG, "AudioTrack初始化成功, 启用语音通信模式")
return true
} catch (e: Exception) {
Log.e(TAG, "设置AudioTrack失败: ${e.message}", e)
@ -155,6 +179,9 @@ class RealtimeAudioManager(private val context: Context) {
}
try {
// 设置回音消除(总是启用)
setupEchoCancellation()
audioRecord?.startRecording()
isRecording = true
onStatusChanged?.invoke(VoiceStatus.RECORDING)
@ -164,7 +191,7 @@ class RealtimeAudioManager(private val context: Context) {
loopRecordSend()
}
Log.i(TAG, "开始录音")
Log.i(TAG, "开始录音, 回音消除: ${acousticEchoCanceler?.enabled}")
return true
} catch (e: Exception) {
Log.e(TAG, "开始录音失败: ${e.message}", e)
@ -185,12 +212,16 @@ class RealtimeAudioManager(private val context: Context) {
if (bytesRead > 0) {
// 确保读取的数据长度正确
val audioData = if (bytesRead == frameSize) {
buffer.copyOf()
buffer
} else {
buffer.copyOf(bytesRead)
}
// 回调音频数据
// 计算RMS值
val rms = calculateRMS(audioData)
onRmsChanged?.invoke(rms)
// 直接回调音频数据到Flutter层
onAudioData?.invoke(audioData)
} else {
Log.w(TAG, "录音读取数据失败: $bytesRead")
@ -212,9 +243,12 @@ class RealtimeAudioManager(private val context: Context) {
isRecording = false
recordJob?.cancel()
audioRecord?.stop()
// 清理回音消除
cleanupEchoCancellation()
onStatusChanged?.invoke(VoiceStatus.IDLE)
Log.i(TAG, "停止录音")
Log.i(TAG, "录音已停止")
return true
} catch (e: Exception) {
Log.e(TAG, "停止录音失败: ${e.message}", e)
@ -225,9 +259,11 @@ class RealtimeAudioManager(private val context: Context) {
/// 播放音频数据
fun playAudioData(data: ByteArray) {
if (data.isNotEmpty()) {
playbackQueue.offer(data)
// 计算播放数据的RMS值
val rms = calculateRMS(data)
onRmsChanged?.invoke(rms)
// 如果没在播放,开始播放
playbackQueue.offer(data)
if (!isPlaying) {
startPlayback()
}
@ -241,70 +277,79 @@ class RealtimeAudioManager(private val context: Context) {
}
try {
audioTrack?.play()
isPlaying = true
onStatusChanged?.invoke(VoiceStatus.PLAYING)
audioTrack?.play()
// 启动播放协程
playJob = scope.launch {
loopPlayback()
}
Log.i(TAG, "开始播放")
} catch (e: Exception) {
Log.e(TAG, "开始播放失败: ${e.message}", e)
}
}
/// 播放循环(对应Android示例中的loopPlayback)
/// 播放循环
private suspend fun loopPlayback() {
while (isPlaying && audioTrack != null) {
Log.d(TAG, "启动播放循环")
while (isPlaying) {
try {
val audioData = playbackQueue.poll()
// take() 会一直等待直到队列中有数据
val data = playbackQueue.take()
if (audioData != null) {
val bytesWritten = audioTrack!!.write(audioData, 0, audioData.size)
if (bytesWritten < 0) {
Log.w(TAG, "音频播放写入失败: $bytesWritten")
}
} else {
// 队列为空,检查是否应该停止播放
delay(10) // 短暂等待
if (playbackQueue.isEmpty()) {
// 队列持续为空,停止播放
break
}
}
// 直接播放整个数据块,让AudioTrack自己处理时序
audioTrack?.write(data, 0, data.size)
} catch (e: InterruptedException) {
Log.d(TAG, "播放循环被中断")
break
} catch (e: Exception) {
Log.e(TAG, "播放循环异常: ${e.message}", e)
break
}
}
// 播放结束
stopPlayback()
Log.d(TAG, "播放循环结束")
stopPlaybackInternal()
}
/// 停止播放
fun stopPlaying(): Boolean {
return stopPlayback()
/// 内部停止播放方法
private fun stopPlaybackInternal() {
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) {
return false
return true
}
try {
isPlaying = false
playJob?.cancel()
audioTrack?.stop()
playbackQueue.clear()
onStatusChanged?.invoke(VoiceStatus.IDLE)
Log.i(TAG, "停止播放")
playJob?.cancel() // 取消协程
playJob = null
stopPlaybackInternal()
// 恢复音频模式
restoreAudioMode()
return true
} catch (e: Exception) {
Log.e(TAG, "停止播放失败: ${e.message}", e)
@ -319,7 +364,7 @@ class RealtimeAudioManager(private val context: Context) {
stopRecording()
}
if (isPlaying) {
stopPlayback()
stopPlaying()
}
// 释放旧的音频对象
@ -344,7 +389,11 @@ class RealtimeAudioManager(private val context: Context) {
/// 释放资源
fun dispose() {
stopRecording()
stopPlayback()
stopPlaying()
// 清理回音消除和恢复音频模式
cleanupEchoCancellation()
restoreAudioMode()
scope.cancel()
releaseAudioObjects()
@ -352,4 +401,119 @@ class RealtimeAudioManager(private val context: Context) {
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
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
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.Result
import kotlinx.coroutines.*
import org.json.JSONObject
/**
* 实时语音聊天插件
@ -28,12 +31,15 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
// 核心组件
private lateinit var audioManager: RealtimeAudioManager
private lateinit var webSocketManager: RealtimeWebSocketManager
// 核心组件:设为可空,因为它们的生命周期与页面会话绑定
private var audioManager: RealtimeAudioManager? = null
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 = ""
@ -47,6 +53,11 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
@Volatile
private var voiceStatus: VoiceStatus = VoiceStatus.IDLE
// 清理状态标志,确保清理逻辑只执行一次
@Volatile
private var isCleanedUp = false
private val cleanupLock = Any()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
@ -55,68 +66,96 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
// 初始化核心组件
audioManager = RealtimeAudioManager(context)
webSocketManager = RealtimeWebSocketManager()
setupCallbacks()
Log.i(TAG, "Realtime插件已附加到引擎")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Log.i(TAG, "插件正在从引擎分离,执行清理...")
performCleanup()
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
// 清理资源
scope.cancel()
audioManager.dispose()
webSocketManager.dispose()
Log.i(TAG, "Realtime插件已从引擎分离")
}
/// 设置各组件的回调
private fun setupCallbacks() {
Log.d(TAG, "setupCallbacks: 正在为新的管理器实例设置回调...")
// 音频管理器回调
audioManager.onAudioData = { data ->
webSocketManager.sendAudioData(data)
audioManager?.onAudioData = { data ->
// 发送到WebSocket
webSocketManager?.sendAudioData(data)
// 发送录音PCM数据到Flutter用于可视化
val pcm16Data = convertByteArrayToPCM16(data)
sendEvent("recordingPcmData", pcm16Data)
}
audioManager.onStatusChanged = { status ->
audioManager?.onStatusChanged = { status ->
voiceStatus = status
sendEvent("voiceStatusChanged", status.value)
}
// 新增RMS回调
audioManager?.onRmsChanged = { rms ->
sendEvent("audioRms", rms)
}
// WebSocket管理器回调
webSocketManager.onConnectionStatusChanged = { status ->
webSocketManager?.onConnectionStatusChanged = { status ->
connectionStatus = status
sendEvent("connectionStatusChanged", status.value)
}
webSocketManager.onAudioReceived = { data ->
audioManager.playAudioData(data)
webSocketManager?.onAudioReceived = { 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)
}
webSocketManager.onError = { error ->
webSocketManager?.onError = { error ->
sendEvent("error", error)
}
}
/// 发送事件到Flutter端
private fun sendEvent(type: String, data: Any?) {
val sink = eventSink
if (sink == null) {
Log.d(TAG, "事件流已关闭,忽略事件: $type")
return
}
val event = mapOf(
"type" to type,
"data" to data
)
scope.launch {
eventSink?.success(event)
try {
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) {
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 serverUrl = args?.get("serverUrl") as? String
?: return result.error("INVALID_ARGUMENTS", "服务器地址不能为空", null)
this.serverUrl = serverUrl
this.sampleRate = args["sampleRate"] as? Int ?: 16000
this.channels = args["channels"] as? Int ?: 1
this.bitsPerSample = args["bitsPerSample"] as? Int ?: 16
args["sampleRate"]?.let { this.sampleRate = (it as Number).toInt() }
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 success = audioManager.initialize(audioConfig)
val success = audioManager?.initialize(audioConfig) ?: false
if (success) {
webSocketManager.initialize(serverUrl)
webSocketManager?.initialize(serverUrl)
Log.i(TAG, "实时语音插件初始化成功")
} else {
Log.e(TAG, "实时语音插件初始化失败")
@ -167,13 +231,14 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
}
private fun handleConnect(result: Result) {
Log.d(TAG, "handleConnect: 收到连接请求。scope是否活跃? ${scope.isActive}")
scope.launch {
try {
val success = webSocketManager.connect()
result.success(success)
val success = webSocketManager?.connect() ?: false
mainHandler.post { result.success(success) }
} catch (e: Exception) {
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) {
scope.launch {
try {
webSocketManager.disconnect()
result.success(true)
webSocketManager?.disconnect()
mainHandler.post { result.success(true) }
} catch (e: Exception) {
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) {
scope.launch {
try {
val success = audioManager.startRecording()
result.success(success)
val success = audioManager?.startRecording() ?: false
mainHandler.post { result.success(success) }
} catch (e: Exception) {
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) {
scope.launch {
try {
val success = audioManager.stopRecording()
result.success(success)
val success = audioManager?.stopRecording() ?: false
mainHandler.post { result.success(success) }
} catch (e: Exception) {
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) {
scope.launch {
try {
val success = audioManager.stopPlaying()
result.success(success)
val success = audioManager?.stopPlaying() ?: false
mainHandler.post { result.success(success) }
} catch (e: Exception) {
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
?: return result.error("INVALID_ARGUMENTS", "消息内容不能为空", null)
val success = webSocketManager.sendTextMessage(message)
val success = webSocketManager?.sendTextMessage(message) ?: false
result.success(success)
} catch (e: Exception) {
Log.e(TAG, "发送文本消息失败: ${e.message}", e)
@ -245,12 +310,23 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
val args = call.arguments as? Map<String, Any>
?: return result.error("INVALID_ARGUMENTS", "参数无效", null)
args["sampleRate"]?.let { this.sampleRate = it as Int }
args["channels"]?.let { this.channels = it as Int }
args["bitsPerSample"]?.let { this.bitsPerSample = it as Int }
Log.d(TAG, "收到的音频配置参数: $args")
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 success = audioManager.updateConfig(audioConfig)
val success = audioManager?.updateConfig(audioConfig) ?: false
result.success(success)
} catch (e: Exception) {
Log.e(TAG, "设置音频配置失败: ${e.message}", e)
@ -259,14 +335,84 @@ class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
}
private fun handleDispose(result: Result) {
scope.launch {
try {
audioManager.dispose()
webSocketManager.dispose()
result.success(null)
} catch (e: Exception) {
Log.e(TAG, "释放资源失败: ${e.message}", e)
result.error("DISPOSE_ERROR", "释放资源失败: ${e.message}", null)
Log.i(TAG, "Flutter端请求释放资源...")
performCleanup()
// 立即返回,不等待异步清理完成
result.success(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管理器
fun initialize(serverUrl: String) {
Log.d(TAG, "initialize: 开始初始化。当前httpClient是否为null? ${okHttpClient == null}")
this.serverUrl = serverUrl
// 创建OkHttp客户端
@ -53,20 +54,24 @@ class RealtimeWebSocketManager : WebSocketListener() {
.retryOnConnectionFailure(true)
.build()
Log.i(TAG, "WebSocket管理器初始化,服务器地址: $serverUrl")
Log.i(TAG, "initialize: WebSocket管理器初始化完成。服务器地址: $serverUrl")
}
/// 连接到服务器
suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
Log.d(TAG, "connect: 尝试连接。当前状态 - isConnected: $isConnected, isConnecting: $isConnecting")
if (isConnected || isConnecting) {
Log.w(TAG, "connect: 连接请求被拒绝,因为已有连接或正在连接中。")
return@withContext false
}
val client = okHttpClient ?: run {
Log.e(TAG, "OkHttp客户端未初始化")
Log.e(TAG, "connect: 连接失败,因为OkHttp客户端为null。请确保已调用initialize()。")
onError?.invoke("客户端未初始化")
return@withContext false
}
Log.d(TAG, "connect: OkHttpClient实例存在,准备发起新连接。")
try {
isConnecting = true
@ -76,6 +81,7 @@ class RealtimeWebSocketManager : WebSocketListener() {
.url(serverUrl)
.build()
Log.d(TAG, "connect: 正在创建新的WebSocket实例...")
webSocket = client.newWebSocket(request, this@RealtimeWebSocketManager)
// 等待连接建立
@ -86,10 +92,10 @@ class RealtimeWebSocketManager : WebSocketListener() {
}
if (isConnected) {
Log.i(TAG, "WebSocket连接成功")
Log.i(TAG, "connect: WebSocket连接成功")
return@withContext true
} else {
Log.e(TAG, "WebSocket连接超时")
Log.e(TAG, "connect: WebSocket连接超时")
isConnecting = false
onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR)
return@withContext false
@ -126,8 +132,14 @@ class RealtimeWebSocketManager : WebSocketListener() {
}
try {
val byteString = ByteString.of(*data)
val success = webSocket?.send(byteString) ?: false
// 将音频数据编码为Base64并包装成Vocode WebSocket格式
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) {
Log.w(TAG, "发送音频数据失败")
@ -160,9 +172,35 @@ class RealtimeWebSocketManager : WebSocketListener() {
fun dispose() {
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
Log.i(TAG, "WebSocket管理器已释放")
@ -177,13 +215,60 @@ class RealtimeWebSocketManager : WebSocketListener() {
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "收到文本消息: $text")
onTextReceived?.invoke(text)
// 添加收到文本消息的日志输出,显示消息类型和前20字符
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) {
Log.d(TAG, "收到音频数据: ${bytes.size} 字节")
onAudioReceived?.invoke(bytes.toByteArray())
val audioData = bytes.toByteArray()
onAudioReceived?.invoke(audioData)
}
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 onStatusChanged: ((VoiceStatus) -> Void)?
var onRmsChanged: ((Float) -> Void)?
// 录音缓冲区
private var recordingBuffer: AVAudioPCMBuffer?
@ -163,6 +164,10 @@ class RealtimeAudioManager {
// 转换为Data
let data = Data(bytes: channelData, count: dataLength)
// 计算RMS值
let rms = calculateRMS(buffer: buffer)
onRmsChanged?(rms)
// 回调音频数据
onAudioData?(data)
}
@ -217,6 +222,12 @@ class RealtimeAudioManager {
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)
@ -311,4 +322,23 @@ class RealtimeAudioManager {
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() {
// 音频管理器回调
audioManager.onAudioData = { [weak self] data in
// 发送到WebSocket
self?.websocketManager.sendAudioData(data)
// 同时发送PCM数据到Flutter用于可视化
self?.sendPCMDataToFlutter(data)
}
audioManager.onStatusChanged = { [weak self] status in
@ -62,6 +65,11 @@ import os.log
self?.sendEvent(type: "voiceStatusChanged", data: status.rawValue)
}
// 新增RMS回调
audioManager.onRmsChanged = { [weak self] rms in
self?.sendEvent(type: "audioRms", data: rms)
}
// WebSocket管理器回调
websocketManager.onConnectionStatusChanged = { [weak self] status in
self?.connectionStatus = status
@ -69,10 +77,20 @@ import os.log
}
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)
// 同时发送接收到的PCM数据到Flutter用于可视化
self?.sendPCMDataToFlutter(data)
}
websocketManager.onTextReceived = { [weak self] text in
// 直接发送原始JSON文本到Flutter层处理
self?.sendEvent(type: "textReceived", data: text)
}
@ -94,6 +112,33 @@ import os.log
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

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

@ -138,11 +138,56 @@ class RealtimeWebSocketManager: NSObject {
private func handleReceivedMessage(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .data(let data):
// 音频数据
// 保留对二进制数据的支持以防万一
onAudioReceived?(data)
case .string(let text):
// 文本数据
onTextReceived?(text)
// 添加收到文本消息的日志输出,显示前20字符
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:
os_log("收到未知类型消息", log: log, type: .error)
}
@ -165,10 +210,24 @@ class RealtimeWebSocketManager: NSObject {
guard isConnected else { return }
sendQueue.async { [weak self] in
self?.webSocketTask?.send(.data(data)) { error in
if let error = error {
self?.os_log("发送音频数据失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription)
// 将音频数据编码为Base64并包装成Vocode WebSocket格式
let base64AudioData = data.base64EncodedString()
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