diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dda0379b0..4ef33cfa7 100644 --- a/ios/Podfile.lock +++ b/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 diff --git a/lib/core/translations/app_translations.dart b/lib/core/translations/app_translations.dart index 3f7dd6290..58b82c4fa 100644 --- a/lib/core/translations/app_translations.dart +++ b/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> 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, // 新增冰岛语映射 + }; } diff --git a/lib/core/translations/language/bg_bg.dart b/lib/core/translations/language/bg_bg.dart new file mode 100644 index 000000000..b2eb103a3 --- /dev/null +++ b/lib/core/translations/language/bg_bg.dart @@ -0,0 +1,5 @@ +const Map bgBG = { + 'app_title': 'Deep Voice', + 'welcome': 'Добре дошли', + // 补充保加利亚语翻译... +}; diff --git a/lib/core/translations/language/cs_cz.dart b/lib/core/translations/language/cs_cz.dart new file mode 100644 index 000000000..e953323d8 --- /dev/null +++ b/lib/core/translations/language/cs_cz.dart @@ -0,0 +1,5 @@ +const Map csCZ = { + 'app_title': 'Deep Voice', + 'welcome': 'Vítejte', + // 补充捷克语翻译... +}; diff --git a/lib/core/translations/language/da_dk.dart b/lib/core/translations/language/da_dk.dart new file mode 100644 index 000000000..6481e6b36 --- /dev/null +++ b/lib/core/translations/language/da_dk.dart @@ -0,0 +1,4 @@ +const Map daDK = { + 'app_title': 'Deep Voice', + // 添加丹麦语翻译内容 +}; diff --git a/lib/core/translations/language/el_gr.dart b/lib/core/translations/language/el_gr.dart new file mode 100644 index 000000000..28e15ce33 --- /dev/null +++ b/lib/core/translations/language/el_gr.dart @@ -0,0 +1,5 @@ +const Map elGR = { + 'app_title': 'Deep Voice', + 'welcome': 'Καλώς ορίσατε', + // 补充希腊语翻译... +}; diff --git a/lib/core/translations/language/et_ee.dart b/lib/core/translations/language/et_ee.dart new file mode 100644 index 000000000..678345674 --- /dev/null +++ b/lib/core/translations/language/et_ee.dart @@ -0,0 +1,5 @@ +const Map etEE = { + 'app_title': 'Deep Voice', + 'welcome': 'Tere tulemast', + // 补充爱沙尼亚语翻译... +}; diff --git a/lib/core/translations/language/fi_fi.dart b/lib/core/translations/language/fi_fi.dart new file mode 100644 index 000000000..a2f517b51 --- /dev/null +++ b/lib/core/translations/language/fi_fi.dart @@ -0,0 +1,5 @@ +const Map fiFI = { + 'app_title': 'Deep Voice', + 'welcome': 'Tervetuloa', + // 补充芬兰语翻译... +}; diff --git a/lib/core/translations/language/hr_hr.dart b/lib/core/translations/language/hr_hr.dart new file mode 100644 index 000000000..5ca342afc --- /dev/null +++ b/lib/core/translations/language/hr_hr.dart @@ -0,0 +1,5 @@ +const Map hrHR = { + 'app_title': 'Deep Voice', + 'welcome': 'Dobrodošli', + // 补充克罗地亚语翻译... +}; diff --git a/lib/core/translations/language/hu_hu.dart b/lib/core/translations/language/hu_hu.dart new file mode 100644 index 000000000..9bfa69f96 --- /dev/null +++ b/lib/core/translations/language/hu_hu.dart @@ -0,0 +1,5 @@ +const Map huHU = { + 'app_title': 'Deep Voice', + 'welcome': 'Üdvözlet', + // 补充匈牙利语翻译... +}; diff --git a/lib/core/translations/language/is_is.dart b/lib/core/translations/language/is_is.dart new file mode 100644 index 000000000..fdeca337b --- /dev/null +++ b/lib/core/translations/language/is_is.dart @@ -0,0 +1,12 @@ +Map 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', + // 添加更多翻译键值对 +}; diff --git a/lib/core/translations/language/lt_lt.dart b/lib/core/translations/language/lt_lt.dart new file mode 100644 index 000000000..e1b832656 --- /dev/null +++ b/lib/core/translations/language/lt_lt.dart @@ -0,0 +1,5 @@ +const Map ltLT = { + 'app_title': 'Deep Voice', + 'welcome': 'Sveikas', + // 补充立陶宛语翻译... +}; diff --git a/lib/core/translations/language/lv_lv.dart b/lib/core/translations/language/lv_lv.dart new file mode 100644 index 000000000..7f3ed8ed5 --- /dev/null +++ b/lib/core/translations/language/lv_lv.dart @@ -0,0 +1,5 @@ +const Map lvLV = { + 'app_title': 'Deep Voice', + 'welcome': 'Sveiki', + // 补充拉脱维亚语翻译... +}; diff --git a/lib/core/translations/language/nb_no.dart b/lib/core/translations/language/nb_no.dart new file mode 100644 index 000000000..e543dbe7a --- /dev/null +++ b/lib/core/translations/language/nb_no.dart @@ -0,0 +1,5 @@ +const Map nbNO = { + 'app_title': 'Deep Voice', + 'welcome': 'Velkommen', + // 补充挪威语翻译... +}; diff --git a/lib/core/translations/language/nl_nl.dart b/lib/core/translations/language/nl_nl.dart new file mode 100644 index 000000000..1576839ea --- /dev/null +++ b/lib/core/translations/language/nl_nl.dart @@ -0,0 +1,4 @@ +const Map nlNL = { + 'app_title': 'Deep Voice', + // 添加荷兰语翻译内容 +}; diff --git a/lib/core/translations/language/ro_ro.dart b/lib/core/translations/language/ro_ro.dart new file mode 100644 index 000000000..607db3e34 --- /dev/null +++ b/lib/core/translations/language/ro_ro.dart @@ -0,0 +1,5 @@ +const Map roRO = { + 'app_title': 'Deep Voice', + 'welcome': 'Bine ați venit', + // 补充罗马尼亚语翻译... +}; diff --git a/lib/core/translations/language/sk_sk.dart b/lib/core/translations/language/sk_sk.dart new file mode 100644 index 000000000..7d94b206e --- /dev/null +++ b/lib/core/translations/language/sk_sk.dart @@ -0,0 +1,5 @@ +const Map skSK = { + 'app_title': 'Deep Voice', + 'welcome': 'Vitajte', + // 补充斯洛伐克语翻译... +}; diff --git a/lib/core/translations/language/sl_si.dart b/lib/core/translations/language/sl_si.dart new file mode 100644 index 000000000..38aaa9408 --- /dev/null +++ b/lib/core/translations/language/sl_si.dart @@ -0,0 +1,5 @@ +const Map slSI = { + 'app_title': 'Deep Voice', + 'welcome': 'Dobrodošli', + //补充斯洛文尼亚语翻译 +}; diff --git a/lib/core/translations/language/sr_rs.dart b/lib/core/translations/language/sr_rs.dart new file mode 100644 index 000000000..4350e8095 --- /dev/null +++ b/lib/core/translations/language/sr_rs.dart @@ -0,0 +1,12 @@ +Map 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', + // 添加更多翻译键值对 +}; diff --git a/lib/core/translations/language/sv_se.dart b/lib/core/translations/language/sv_se.dart new file mode 100644 index 000000000..97604e052 --- /dev/null +++ b/lib/core/translations/language/sv_se.dart @@ -0,0 +1,4 @@ +const Map svSE = { + 'app_title': 'Deep Voice', + // 添加瑞典语翻译内容 +}; diff --git a/lib/core/translations/language/tl_ph.dart b/lib/core/translations/language/tl_ph.dart new file mode 100644 index 000000000..ee4d5c594 --- /dev/null +++ b/lib/core/translations/language/tl_ph.dart @@ -0,0 +1,5 @@ +const Map tlPH = { + 'welcome': 'Maligayang pagdating', + 'loginTips': 'Mangyaring mag-log in o gumawa ng account...', + // 其他翻译条目... +}; diff --git a/lib/core/translations/language/uk_ua.dart b/lib/core/translations/language/uk_ua.dart new file mode 100644 index 000000000..afc0f728c --- /dev/null +++ b/lib/core/translations/language/uk_ua.dart @@ -0,0 +1,5 @@ +const Map ukUA = { + 'app_title': 'Deep Voice', + 'welcome': 'Ласкаво просимо', + // 补充乌克兰语翻译... +}; diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 6d09e8744..a9a9e6b52 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -30,13 +30,19 @@ abstract class AsrService { bool isContinuousRecognitionActive(); /// 开始录音 - Future enableRecord(String fileName); + Future enableRecord(String filePath); /// 暂停录音 Future pauseRecord(); + // /// 移动文件到新路径 + Future moveFile(String sourcePath, String destPath); + + // /// 重命名指定路径的音频文件 + Future renameFile(String filePath, String newName); + /// 结束录音 - Future stopRecord(); + Future stopRecord(bool isSave); /// 释放资源 Future dispose(); diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 317836638..39ac44aa9 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -344,10 +344,10 @@ class AzureAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String fileName) async { + Future 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 stopRecord() async { + Future 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 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 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; + } + } } diff --git a/lib/data/services/speech_impl/volcano_asr_api_service.dart b/lib/data/services/speech_impl/volcano_asr_api_service.dart index 50cb85f12..a15a4ae0b 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -813,13 +813,13 @@ class VolcanoAsrApiService implements AsrService { } @override - Future enableRecord(String fileName) { + Future enableRecord(String filePath) { // TODO: implement enableRecord throw UnimplementedError(); } @override - Future stopRecord() { + Future stopRecord(bool isSave) { // TODO: implement stopRecord throw UnimplementedError(); } @@ -829,4 +829,16 @@ class VolcanoAsrApiService implements AsrService { // TODO: implement pauseRecord throw UnimplementedError(); } + + @override + Future moveFile(String sourcePath, String destPath) { + // TODO: implement moveFile + throw UnimplementedError(); + } + + @override + Future renameFile(String filePath, String newName) { + // TODO: implement renameFile + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart index 923ede8f0..8571f9c4c 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -348,13 +348,13 @@ class VolcanoAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String fileName) { + Future enableRecord(String filePath) { // TODO: implement enableRecord throw UnimplementedError(); } @override - Future stopRecord() { + Future 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 moveFile(String sourcePath, String destPath) { + // TODO: implement moveFile + throw UnimplementedError(); + } + + @override + Future renameFile(String filePath, String newName) { + // TODO: implement renameFile + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index 4cd8ac171..f4d77ae50 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -272,13 +272,13 @@ class XunfeiAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String fileName) { + Future enableRecord(String filePath) { // TODO: implement enableRecord throw UnimplementedError(); } @override - Future stopRecord() { + Future 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 moveFile(String sourcePath, String destPath) { + // TODO: implement moveFile + throw UnimplementedError(); + } + + @override + Future renameFile(String filePath, String newName) { + // TODO: implement renameFile + throw UnimplementedError(); + } } diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 82c246def..c512d1eb7 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/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( diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index cba8ba6be..98cdfae0f 100644 --- a/lib/modules/login/views/login_view.dart +++ b/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 { const LoginView({Key? key}) : super(key: key); @@ -575,7 +576,8 @@ class LoginView extends GetView { 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 { 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, diff --git a/lib/modules/meeting/controllers/meeting_controller.dart b/lib/modules/meeting/controllers/meeting_controller.dart index f6d1d7ee9..5fc7b6ebc 100644 --- a/lib/modules/meeting/controllers/meeting_controller.dart +++ b/lib/modules/meeting/controllers/meeting_controller.dart @@ -28,7 +28,7 @@ class MeetingController extends GetxController { int addQuantity = 0; List selected = []; - final appDocDir = getApplicationSupportDirectory(); + final appDocDir = getApplicationDocumentsDirectory(); // final directory = Directory( // '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); List 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 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) { diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 1ad18656f..b1bb68481 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/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 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 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(); } } diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index f29de80c6..990d99f44 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/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 { 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 { child: TabBarView( controller: controller.tabController, children: [ - _buildAudioTab(context), - _buildTextTab(context), + _buildAudioTab(context, isDarkMode), + _buildTextTab(context, isDarkMode), ], ), ), @@ -43,7 +96,7 @@ class MeetingRecordView extends GetView { ); } - 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 { 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 { ), ), 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 { ), )), ), + // 录音按钮 GestureDetector( onTap: controller.toggleRecording, child: Obx(() => CircleAvatar( @@ -176,8 +232,9 @@ class MeetingRecordView extends GetView { ), )), ), + // 完成按钮 GestureDetector( - onTap: controller.saveRecording, + onTap: () => _bottomSheet(context, isDarkMode), // 改为匿名函数 child: const CircleAvatar( radius: 32, backgroundColor: Colors.white, @@ -191,6 +248,124 @@ class MeetingRecordView extends GetView { ], ); } + +// 底部编辑弹窗 + 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 { diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index b377232f7..83414157b 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/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 { const MeetingView({super.key}); @@ -563,10 +561,13 @@ class MeetingView extends GetView { 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 { 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 { ], ), title: '通话录音', - onTap: () { + onTap: () async { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord); + + await Get.toNamed(Routes.meetingRecord, arguments: { + 'audioTypes': 2, + }); + controller.addMeetingAudio(); + // TODO: 实现从其他APP导入逻辑 }, ), diff --git a/lib/modules/profile/views/profile_view.dart b/lib/modules/profile/views/profile_view.dart index 23b3e38ef..678135fd6 100644 --- a/lib/modules/profile/views/profile_view.dart +++ b/lib/modules/profile/views/profile_view.dart @@ -629,7 +629,8 @@ class EditProfileView extends GetView { 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 { 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( diff --git a/lib/modules/realtime/README.md b/lib/modules/realtime/README.md index 1962860f0..c41004a52 100644 --- a/lib/modules/realtime/README.md +++ b/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年 -- **状态**: 模拟功能演示 \ No newline at end of file +- **状态**: 完整功能实现 \ No newline at end of file diff --git a/lib/modules/realtime/controllers/realtime_controller.dart b/lib/modules/realtime/controllers/realtime_controller.dart index 5022a813c..1b4362445 100644 --- a/lib/modules/realtime/controllers/realtime_controller.dart +++ b/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 aiResponseMessages = [].obs; + final RxBool isSubtitleMode = true.obs; - // 音频可视化数据 - final RxList audioLevels = [].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 pulseAnimation; + // 实时字幕更新用的临时消息 + final RxString partialUserMessage = ''.obs; + final RxString streamingAiMessage = ''.obs; // 状态监听 StreamSubscription? _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( - 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 _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 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 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.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 closeAndGoBack() async { - // 立即返回上一页,避免阻塞UI Get.back(); - - // 之后异步进行清理,不阻塞页面切换 - unawaited(_performShutdown()); - } - - /// 实际执行关闭逻辑(后台) - Future _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(); diff --git a/lib/modules/realtime/views/realtime_view.dart b/lib/modules/realtime/views/realtime_view.dart index 693caf091..cfe33cab4 100644 --- a/lib/modules/realtime/views/realtime_view.dart +++ b/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 { const RealtimeView({Key? key}) : super(key: key); @@ -22,219 +22,311 @@ class RealtimeView extends GetView { 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( + 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( + 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, ), ), ); } + } \ No newline at end of file diff --git a/lib/modules/realtime/views/widgets/shader_mic_flow.dart b/lib/modules/realtime/views/widgets/shader_mic_flow.dart new file mode 100644 index 000000000..bf21bd14b --- /dev/null +++ b/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 createState() => _ShaderMicFlowState(); +} + +class _ShaderMicFlowState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + FragmentShader? _shader; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 60), + vsync: this, + )..repeat(); + + _loadShader(); + } + + Future _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; + } +} \ No newline at end of file diff --git a/lib/modules/realtime/views/widgets/voice_mic_flow.dart b/lib/modules/realtime/views/widgets/voice_mic_flow.dart deleted file mode 100644 index 1716cad47..000000000 --- a/lib/modules/realtime/views/widgets/voice_mic_flow.dart +++ /dev/null @@ -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 createState() => _VoiceMicFlowState(); -} - -class _VoiceMicFlowState extends State - 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; -} \ No newline at end of file diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index 17757e376..36fd66fe3 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/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 language = LanguageModel().obs; diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index faa531560..195daf552 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -512,8 +512,7 @@ class SettingsView extends GetView { 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 { ? Colors.white.withOpacity(0.1) : Colors.grey[200]), - // BLE测试 _buildSimpleNavigationSetting( title: 'BLE蓝牙测试', @@ -627,8 +625,6 @@ class SettingsView extends GetView { ? Colors.white.withOpacity(0.1) : Colors.grey[200]), - - // 退出登录 // _buildSimpleNavigationSetting( // title: 'logout'.tr, // 退出登录 @@ -917,7 +913,8 @@ class SettingsView extends GetView { ), ), 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 { ), ), ), + //------------测试使用标记 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---------------// ], ), ), diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 5be5a8990..b7e42e44c 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/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 = [].obs; String? currentSessionId; - + final appDocDir = getApplicationDocumentsDirectory(); + var appDir; + var dir; Future 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 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) { diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 9f4ac434a..e5035bf17 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/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() diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index d493330ec..9b21bd39a 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/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() - private val isWriting = AtomicBoolean(false) - private var writeThread: Thread? = null - - // 用于存储音频数据的缓冲区 - private val dataBuffer = mutableListOf() - 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 - } - } - } } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 855d0b843..e19579cc3 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/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("filePath") ?: "" + val newName = call.argument("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("sourcePath") ?: "" + val destPath = call.argument("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("fileName")?: "" + val filePath = call.argument("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("isSave") ?: false + try { + azureAsrHelper.stopRecord(isSave) + result.success(true) + } catch (e: Exception) { + result.error("ENABLERECORD_ERROR", e.message, null) + } } else -> { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt index 1e5251de5..d750fae41 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt +++ b/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) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt new file mode 100644 index 000000000..7a32f21da --- /dev/null +++ b/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() + private val isWriting = AtomicBoolean(false) + private var writeThread: Thread? = null + + // 用于存储音频数据的缓冲区 + private val dataBuffer = mutableListOf() + 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 + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index d81842a90..7e6141db4 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/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 /** diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt index 058cb0876..794b66547 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt +++ b/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 { // 发送媒体播放广播 - 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 { + 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 { // 发送切换上一首广播 - 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 { // 发送切换下一首广播 - 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\": \"已切换到下一首\"}" } } diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift index 610ccdc00..1d7b7746d 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift +++ b/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) diff --git a/local_plugins/location_service/ios/Classes/LocationService.swift b/local_plugins/location_service/ios/Classes/LocationService.swift new file mode 100644 index 000000000..d71179ee3 --- /dev/null +++ b/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() + } +} diff --git a/local_plugins/location_service/ios/Classes/LocationServicePlugin.swift b/local_plugins/location_service/ios/Classes/LocationServicePlugin.swift new file mode 100644 index 000000000..ad30a36f7 --- /dev/null +++ b/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() + } + } +} diff --git a/local_plugins/location_service/ios/location_service.podspec b/local_plugins/location_service/ios/location_service.podspec index 2017c03e5..549e6c473 100644 --- a/local_plugins/location_service/ios/location_service.podspec +++ b/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', diff --git a/local_plugins/location_service/ios/location_service/Package.swift b/local_plugins/location_service/ios/location_service/Package.swift deleted file mode 100644 index 0b0dd0fbd..000000000 --- a/local_plugins/location_service/ios/location_service/Package.swift +++ /dev/null @@ -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 - ] - ) - ] -) diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift deleted file mode 100644 index 1b6f10f28..000000000 --- a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift +++ /dev/null @@ -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) - } - } -} diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/PrivacyInfo.xcprivacy b/local_plugins/location_service/ios/location_service/Sources/location_service/PrivacyInfo.xcprivacy deleted file mode 100644 index a34b7e2e6..000000000 --- a/local_plugins/location_service/ios/location_service/Sources/location_service/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,14 +0,0 @@ - - - - - NSPrivacyTrackingDomains - - NSPrivacyAccessedAPITypes - - NSPrivacyCollectedDataTypes - - NSPrivacyTracking - - - diff --git a/local_plugins/location_service/lib/location_service.dart b/local_plugins/location_service/lib/location_service.dart index c662819c6..a760675ce 100644 --- a/local_plugins/location_service/lib/location_service.dart +++ b/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 _dataStream; - late final Stream _statusStream; - - // 获取事件流 - Stream get onDataReceived => _dataStream; - - // 状态流直接传递原始数据 - Stream 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> location() async { final result = await _methodChannel.invokeMethod('location'); diff --git a/local_plugins/location_service/pubspec.yaml b/local_plugins/location_service/pubspec.yaml index b575ce3b7..04f1a58f4 100644 --- a/local_plugins/location_service/pubspec.yaml +++ b/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 diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt index 26224f7f4..d346baed0 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt +++ b/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) diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift index a81039fa6..5a9a1180e 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift +++ b/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() + } + } } diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt index 65bf3ba88..c2ddcfe1f 100644 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt +++ b/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 @@ -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() } diff --git a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt index b954a2d6d..711a85779 100644 --- a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt +++ b/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() + 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() + } } \ No newline at end of file diff --git a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt index a8e787656..b8077c2cf 100644 --- a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt +++ b/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 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 ?: 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 { + val pcm16List = mutableListOf() + + // 确保数据长度是偶数(每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, "核心资源清理完成。") } } } diff --git a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt index 77a6ebb49..2726ceef7 100644 --- a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt +++ b/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) { diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift index 281907ce5..c2a9d424a 100644 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift +++ b/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 + } } \ No newline at end of file diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift index 857e22d43..23f6720b0 100644 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift +++ b/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 diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift index ed4428f8f..039101982 100644 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift +++ b/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) } } }