From c1c0cf6c32306a57afc61146d79ff8febd993e25 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 14 Jun 2025 15:37:37 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(=E7=99=BB=E5=BD=95):=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E8=AF=AD=E8=A8=80=E6=A3=80=E6=B5=8B=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=B9=B6=E6=B8=85=E7=90=86=E7=BF=BB=E8=AF=91=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改登录模块中检测语言的逻辑,从比较字符串改为分别检查语言代码和国家代码,提高准确性 清理翻译文件中的注释格式和代码对齐 --- lib/core/translations/app_translations.dart | 56 +++++++++---------- lib/main.dart | 4 +- .../login/controllers/login_controller.dart | 3 +- lib/modules/login/views/login_view.dart | 6 +- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/lib/core/translations/app_translations.dart b/lib/core/translations/app_translations.dart index 3f7dd6290..7a3457edf 100644 --- a/lib/core/translations/app_translations.dart +++ b/lib/core/translations/app_translations.dart @@ -7,14 +7,14 @@ 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/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'; // 添加泰语导入 @@ -26,15 +26,15 @@ class AppTranslations extends Translations { static const fallbackLocale = Locale('zh', 'CN'); static final supportedLocales = [ const Locale('zh', 'CN'), - const Locale('zh', 'HK'), // 添加繁体中文支持 + 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'), // 添加印度语支持 @@ -58,23 +58,23 @@ 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, // 添加波兰语映射 - }; + }; } diff --git a/lib/main.dart b/lib/main.dart index 9086063c7..8f803a0c4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,10 +24,10 @@ void main() async { // 初始化环境变量 await dotenv.load(); - + // 初始化登录服务(包含Firebase初始化) LoginService(); - + // 设置窗口大小(仅在 macOS 下生效) if (Platform.isMacOS) { const double width = 375.0; diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 82c246def..7b2b4cb38 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -234,7 +234,8 @@ class LoginController extends GetxController { 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( diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index cba8ba6be..e4e75f647 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); @@ -601,9 +602,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, From 2e3d62acd77cfd116cc6a0eb22f3d5c0843715d4 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Sat, 14 Jun 2025 17:36:05 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(=E5=A4=9A=E8=AF=AD=E8=A8=80):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E18=E7=A7=8D=E8=AF=AD=E8=A8=80=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=B9=B6=E4=BC=98=E5=8C=96=E8=AF=AD=E8=A8=80=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加丹麦语、荷兰语、瑞典语等18种新语言翻译文件 在设置控制器中注册新增语言选项 优化中文检测逻辑,使用languageCode和countryCode组合判断 --- lib/core/translations/app_translations.dart | 72 +++++++++++++++++-- lib/core/translations/language/bg_bg.dart | 5 ++ lib/core/translations/language/cs_cz.dart | 5 ++ lib/core/translations/language/da_dk.dart | 4 ++ lib/core/translations/language/el_gr.dart | 5 ++ lib/core/translations/language/et_ee.dart | 5 ++ lib/core/translations/language/fi_fi.dart | 5 ++ lib/core/translations/language/hr_hr.dart | 5 ++ lib/core/translations/language/hu_hu.dart | 5 ++ lib/core/translations/language/is_is.dart | 12 ++++ lib/core/translations/language/lt_lt.dart | 5 ++ lib/core/translations/language/lv_lv.dart | 5 ++ lib/core/translations/language/nb_no.dart | 5 ++ lib/core/translations/language/nl_nl.dart | 4 ++ lib/core/translations/language/ro_ro.dart | 5 ++ lib/core/translations/language/sk_sk.dart | 5 ++ lib/core/translations/language/sl_si.dart | 5 ++ lib/core/translations/language/sr_rs.dart | 12 ++++ lib/core/translations/language/sv_se.dart | 4 ++ lib/core/translations/language/tl_ph.dart | 5 ++ lib/core/translations/language/uk_ua.dart | 5 ++ .../login/controllers/login_controller.dart | 5 +- lib/modules/login/views/login_view.dart | 3 +- lib/modules/profile/views/profile_view.dart | 6 +- .../controllers/settings_controller.dart | 26 +++++++ lib/modules/settings/views/settings_view.dart | 9 +-- 26 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 lib/core/translations/language/bg_bg.dart create mode 100644 lib/core/translations/language/cs_cz.dart create mode 100644 lib/core/translations/language/da_dk.dart create mode 100644 lib/core/translations/language/el_gr.dart create mode 100644 lib/core/translations/language/et_ee.dart create mode 100644 lib/core/translations/language/fi_fi.dart create mode 100644 lib/core/translations/language/hr_hr.dart create mode 100644 lib/core/translations/language/hu_hu.dart create mode 100644 lib/core/translations/language/is_is.dart create mode 100644 lib/core/translations/language/lt_lt.dart create mode 100644 lib/core/translations/language/lv_lv.dart create mode 100644 lib/core/translations/language/nb_no.dart create mode 100644 lib/core/translations/language/nl_nl.dart create mode 100644 lib/core/translations/language/ro_ro.dart create mode 100644 lib/core/translations/language/sk_sk.dart create mode 100644 lib/core/translations/language/sl_si.dart create mode 100644 lib/core/translations/language/sr_rs.dart create mode 100644 lib/core/translations/language/sv_se.dart create mode 100644 lib/core/translations/language/tl_ph.dart create mode 100644 lib/core/translations/language/uk_ua.dart diff --git a/lib/core/translations/app_translations.dart b/lib/core/translations/app_translations.dart index 7a3457edf..58b82c4fa 100644 --- a/lib/core/translations/app_translations.dart +++ b/lib/core/translations/app_translations.dart @@ -3,10 +3,10 @@ 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/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'; // 添加韩语导入 @@ -21,13 +21,33 @@ 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', 'CN'), // 简体中文 const Locale('zh', 'HK'), // 添加繁体中文支持 - const Locale('en', 'US'), + const Locale('en', 'US'), // 英语 const Locale('es', 'ES'), // 添加西班牙语 const Locale('pt', 'PT'), // 添加葡萄牙语支持 const Locale('ja', 'JP'), // 添加日语支持 @@ -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() { @@ -76,5 +116,25 @@ class AppTranslations extends Translations { '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/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 7b2b4cb38..c512d1eb7 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -231,6 +231,7 @@ class LoginController extends GetxController { style: TextStyle(fontSize: 14), ), WidgetSpan( + alignment: PlaceholderAlignment.middle, child: GestureDetector( onTap: () { String url = 'https://www.deepsoud.com/ysxy.html'; @@ -257,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 e4e75f647..98cdfae0f 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -576,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( 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/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..40727d7ec 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( From 6d239318e18db39520baa6bb192ea339cddbe681 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Mon, 16 Jun 2025 16:47:39 +0100 Subject: [PATCH 3/8] add --- local_plugins/location_service/pubspec.yaml | 2 +- local_plugins/realtime/.gitignore | 51 --- local_plugins/realtime/IMPLEMENTATION.md | 190 ---------- local_plugins/realtime/README.md | 125 ------ .../realtime/android/build.gradle.kts | 47 --- .../realtime/android/settings.gradle.kts | 1 - .../android/src/main/AndroidManifest.xml | 11 - .../realtime/RealtimeAudioManager.kt | 355 ------------------ .../realtime/RealtimePlugin.kt | 309 --------------- .../realtime/RealtimeWebSocketManager.kt | 218 ----------- local_plugins/realtime/example.md | 274 -------------- .../realtime/ios/realtime/Package.swift | 18 - .../realtime/RealtimeAudioManager.swift | 314 ---------------- .../Sources/realtime/RealtimePlugin.swift | 274 -------------- .../realtime/RealtimeWebSocketManager.swift | 264 ------------- local_plugins/realtime/lib/realtime.dart | 243 ------------ local_plugins/realtime/pubspec.yaml | 29 -- 17 files changed, 1 insertion(+), 2724 deletions(-) delete mode 100644 local_plugins/realtime/.gitignore delete mode 100644 local_plugins/realtime/IMPLEMENTATION.md delete mode 100644 local_plugins/realtime/README.md delete mode 100644 local_plugins/realtime/android/build.gradle.kts delete mode 100644 local_plugins/realtime/android/settings.gradle.kts delete mode 100644 local_plugins/realtime/android/src/main/AndroidManifest.xml delete mode 100644 local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt delete mode 100644 local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt delete mode 100644 local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt delete mode 100644 local_plugins/realtime/example.md delete mode 100644 local_plugins/realtime/ios/realtime/Package.swift delete mode 100644 local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift delete mode 100644 local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift delete mode 100644 local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift delete mode 100644 local_plugins/realtime/lib/realtime.dart delete mode 100644 local_plugins/realtime/pubspec.yaml diff --git a/local_plugins/location_service/pubspec.yaml b/local_plugins/location_service/pubspec.yaml index a7809ba9f..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: diff --git a/local_plugins/realtime/.gitignore b/local_plugins/realtime/.gitignore deleted file mode 100644 index f5af98475..000000000 --- a/local_plugins/realtime/.gitignore +++ /dev/null @@ -1,51 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# Visual Studio Code related -.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -build/ - -# iOS related -**/ios/.generated/ -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/app.flx -**/ios/Flutter/app.zip -**/ios/Flutter/flutter_assets/ -**/ios/Flutter/flutter_export_environment.sh -**/ios/ServiceDefinitions.json -**/ios/Runner/GeneratedPluginRegistrant.* -.build/ - -# Coverage -coverage/ - -# Exceptions to above rules. -!**/ios/**/default.mode1v3 -!**/ios/**/default.mode2v3 -!**/ios/**/default.pbxuser -!**/ios/**/default.perspectivev3 \ No newline at end of file diff --git a/local_plugins/realtime/IMPLEMENTATION.md b/local_plugins/realtime/IMPLEMENTATION.md deleted file mode 100644 index a5d49a976..000000000 --- a/local_plugins/realtime/IMPLEMENTATION.md +++ /dev/null @@ -1,190 +0,0 @@ -# Realtime Plugin 实现总结 - -## 项目概述 - -基于您提供的Android原生示例,我为DeepVoice项目创建了一个realtime插件,用于通过WebSocket接入Vocode服务器实现实时语音聊天功能。该插件已完成Android和iOS双平台实现。 - -## 架构设计 - -### 插件结构 -``` -local_plugins/realtime/ -├── android/ # Android实现 -│ ├── build.gradle # Android构建配置 -│ ├── src/main/ -│ │ ├── AndroidManifest.xml # Android权限配置 -│ │ └── kotlin/com/yunqiinnovation/realtime/ -│ │ ├── RealtimePlugin.kt # 主插件入口 -│ │ ├── RealtimeAudioManager.kt # 音频管理器(AudioRecord/AudioTrack) -│ │ └── RealtimeWebSocketManager.kt # WebSocket管理器(OkHttp) -├── ios/ # iOS实现 -│ ├── Classes/ # Objective-C桥接文件 -│ │ ├── RealtimePlugin.h -│ │ └── RealtimePlugin.m -│ ├── realtime/ # Swift Package -│ │ ├── Package.swift -│ │ └── Sources/realtime/ -│ │ ├── RealtimePlugin.swift # 主插件入口 -│ │ ├── RealtimeAudioManager.swift # 音频管理器(AVAudioEngine) -│ │ └── RealtimeWebSocketManager.swift # WebSocket管理器(URLSession) -│ └── realtime.podspec -├── lib/ -│ └── realtime.dart # Flutter接口 -├── pubspec.yaml -└── README.md -``` - -### 核心组件 - -1. **RealtimePlugin (SwiftRealtimePlugin)** - - 主插件入口,处理Flutter方法调用 - - 管理各组件间的协调 - - 处理事件分发 - -2. **RealtimeAudioManager** - - 使用AVAudioEngine进行音频录制 - - 使用AVAudioPlayerNode进行音频播放 - - 支持16kHz/16-bit/单声道格式 - - 实现20ms帧长的音频处理 - -3. **RealtimeWebSocketManager** - - 使用URLSessionWebSocketTask实现WebSocket通信 - - 支持音频数据和文本消息的收发 - - 自动连接状态管理 - -## 技术实现对照 - -| 功能 | Android示例 | iOS实现 | -|------|-------------|---------| -| 音频录制 | AudioRecord | AVAudioEngine + AVAudioInputNode | -| 音频播放 | AudioTrack | AVAudioPlayerNode | -| WebSocket | OkHttp WebSocket | URLSessionWebSocketTask | -| 线程管理 | Kotlin协程 | DispatchQueue | -| 音频格式 | 16kHz/16-bit/单声道 | 16kHz/16-bit/单声道 | -| 帧长 | 20ms (320字节) | 20ms (320字节) | - -## Flutter接口 - -### 主要类 -- `RealtimeService`: 主服务类 -- `RealtimeEvent`: 事件类 -- `ConnectionStatus`: 连接状态枚举 -- `VoiceStatus`: 语音状态枚举 -- `RealtimeException`: 异常类 - -### 主要方法 -- `initialize()`: 初始化服务 -- `connect()`: 连接服务器 -- `disconnect()`: 断开连接 -- `startRecording()`: 开始录音 -- `stopRecording()`: 停止录音 -- `eventStream`: 事件流 - -## 使用方式 - -### 1. 在RealtimeController中集成 -已更新`lib/modules/realtime/controllers/realtime_controller.dart`使用真实的realtime插件,取代了原来的模拟实现。 - -### 2. 事件监听 -```dart -_realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - // 处理连接状态变化 - case 'voiceStatusChanged': - // 处理语音状态变化 - case 'textReceived': - // 处理文本消息 - case 'error': - // 处理错误 - } -}); -``` - -## 配置要求 - -### 依赖配置 -已添加到主项目的`pubspec.yaml`: -```yaml -realtime: - path: local_plugins/realtime -``` - -### 权限配置 -iOS的`Info.plist`已包含必要的麦克风权限: -```xml -NSMicrophoneUsageDescription -需要麦克风权限用于语音识别和录音功能 -``` - -## 与Android示例的对应关系 - -1. **初始化对应** - - Android: 创建AudioRecord, AudioTrack, OkHttpClient - - iOS: 创建AVAudioEngine, AVAudioPlayerNode, URLSession - -2. **录音线程对应** - - Android: `loopRecordSend()` 协程 - - iOS: AVAudioInputNode的installTap回调 - -3. **播放线程对应** - - Android: `loopPlayback()` 协程 + LinkedBlockingQueue - - iOS: DispatchQueue + AVAudioPCMBuffer队列 - -4. **WebSocket对应** - - Android: OkHttp WebSocketListener - - iOS: URLSessionWebSocketDelegate - -## 特性支持 - -✅ **已实现** -- Android和iOS双平台支持 -- 实时音频录制和播放(16kHz/16-bit/单声道) -- WebSocket双向通信 -- 状态管理和事件通知 -- 错误处理 -- 资源管理和清理 -- 20ms帧长处理 -- Kotlin协程和DispatchQueue线程管理 - -❌ **未实现(可扩展)** -- 音频编码(Opus等) -- 噪声消除/回声抑制 -- 自动重连机制 -- 音频质量自适应 - -## 使用注意事项 - -1. **服务器地址配置** - ```dart - // 需要替换为实际的Vocode服务器地址 - serverUrl: 'wss://your-vocode-server/ws' - ``` - -2. **音频格式一致性** - - 确保服务器支持16kHz/16-bit/单声道格式 - - 帧长固定为20ms (320字节) - -3. **错误处理** - - 监听事件流中的错误 - - 处理网络断线和重连 - -4. **资源管理** - - 及时调用dispose()释放资源 - - 避免内存泄漏 - -## 测试建议 - -1. **本地测试** - - 先用echo服务器测试WebSocket连接 - - 验证音频录制和播放功能 - -2. **集成测试** - - 与真实Vocode服务器集成 - - 测试端到端语音交互 - -3. **性能测试** - - 测试长时间使用的稳定性 - - 监控内存和CPU使用 - -这个实现为DeepVoice项目提供了完整的实时语音交互能力,可以直接与Vocode服务器进行通信,实现类似Android示例的功能。 \ No newline at end of file diff --git a/local_plugins/realtime/README.md b/local_plugins/realtime/README.md deleted file mode 100644 index a5bc746f0..000000000 --- a/local_plugins/realtime/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# Realtime Plugin - -实时语音聊天插件,通过WebSocket连接Vocode服务器实现实时语音交互。 - -## 功能特性 - -- **实时音频录制**: 16kHz/16-bit/单声道格式录音 -- **WebSocket通信**: 与Vocode服务器进行实时数据传输 -- **音频播放**: 播放服务器返回的语音数据 -- **状态管理**: 连接状态和语音状态监控 -- **事件回调**: 支持各种事件的监听和处理 - -## 技术实现 - -### Android端实现 -- **音频录制**: 使用AudioRecord进行16kHz/16-bit/单声道录音 -- **音频播放**: 使用AudioTrack进行音频播放 -- **WebSocket**: 使用OkHttp WebSocket客户端 -- **线程管理**: 使用Kotlin协程处理录音和播放 - -### iOS端实现 -- **音频录制**: 使用AVAudioEngine和AVAudioInputNode -- **音频播放**: 使用AVAudioPlayerNode进行播放 -- **WebSocket**: 使用URLSessionWebSocketTask -- **线程管理**: 使用DispatchQueue管理录音和播放线程 - -### 核心组件 -- `RealtimePlugin`: 主插件入口,处理Flutter方法调用 -- `RealtimeAudioManager`: 音频管理器,负责录音和播放 -- `RealtimeWebSocketManager`: WebSocket管理器,负责网络通信 - -**Android端组件**: -- AudioRecord + AudioTrack + OkHttp + Kotlin协程 -- 对应您提供的Android原生示例功能 - -**iOS端组件**: -- AVAudioEngine + AVAudioPlayerNode + URLSessionWebSocketTask + DispatchQueue - -## 使用方法 - -```dart -import 'package:realtime/realtime.dart'; - -final realtimeService = RealtimeService(); - -// 初始化 -await realtimeService.initialize( - serverUrl: 'wss://your-server/ws', - sampleRate: 16000, - channels: 1, - bitsPerSample: 16, -); - -// 监听事件 -realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - // 处理连接状态变化 - break; - case 'voiceStatusChanged': - // 处理语音状态变化 - break; - case 'textReceived': - // 处理收到的文本消息 - break; - case 'error': - // 处理错误 - break; - } -}); - -// 连接服务器 -await realtimeService.connect(); - -// 开始录音 -await realtimeService.startRecording(); - -// 停止录音 -await realtimeService.stopRecording(); - -// 断开连接 -await realtimeService.disconnect(); - -// 释放资源 -await realtimeService.dispose(); -``` - -## 音频格式 - -- **采样率**: 16000 Hz -- **声道数**: 1(单声道) -- **位深**: 16-bit -- **帧长**: 20ms (320字节) - -## 权限要求 - -### Android -权限已自动包含在插件中: -- `RECORD_AUDIO`: 音频录制权限 -- `INTERNET`: 网络访问权限 -- `ACCESS_NETWORK_STATE`: 网络状态权限 - -### iOS -在Info.plist中添加麦克风权限: -```xml -NSMicrophoneUsageDescription -应用需要麦克风权限进行语音录制 -``` - -## 注意事项 - -1. 确保服务器地址正确且可访问 -2. 网络环境良好,避免频繁断线 -3. 音频格式与服务器保持一致 -4. 及时释放资源,避免内存泄漏 - -## 错误处理 - -插件会自动处理常见错误: -- 网络连接失败 -- 音频设备不可用 -- 权限被拒绝 -- 服务器断开连接 - -通过事件流可以监听这些错误并进行相应处理。 \ No newline at end of file diff --git a/local_plugins/realtime/android/build.gradle.kts b/local_plugins/realtime/android/build.gradle.kts deleted file mode 100644 index b787e4124..000000000 --- a/local_plugins/realtime/android/build.gradle.kts +++ /dev/null @@ -1,47 +0,0 @@ -plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - kotlin("plugin.serialization") version "1.9.24" -} - -android { - namespace = "com.yunqiinnovation.realtime" - compileSdk = 35 - - defaultConfig { - minSdk = 21 - targetSdk = 33 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = "11" - } - - packaging { - resources { - excludes.add("META-INF/DEPENDENCIES") - excludes.add("META-INF/LICENSE") - excludes.add("META-INF/LICENSE.txt") - excludes.add("META-INF/license.txt") - excludes.add("META-INF/NOTICE") - excludes.add("META-INF/NOTICE.txt") - excludes.add("META-INF/notice.txt") - excludes.add("META-INF/*.kotlin_module") - } - } -} - -dependencies { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - - // OkHttp for WebSocket - implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0")) - implementation("com.squareup.okhttp3:okhttp") -} \ No newline at end of file diff --git a/local_plugins/realtime/android/settings.gradle.kts b/local_plugins/realtime/android/settings.gradle.kts deleted file mode 100644 index dd4a80ad2..000000000 --- a/local_plugins/realtime/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "realtime" \ No newline at end of file diff --git a/local_plugins/realtime/android/src/main/AndroidManifest.xml b/local_plugins/realtime/android/src/main/AndroidManifest.xml deleted file mode 100644 index 64df6a0ae..000000000 --- a/local_plugins/realtime/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - \ No newline at end of file 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 deleted file mode 100644 index b954a2d6d..000000000 --- a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt +++ /dev/null @@ -1,355 +0,0 @@ -package com.yunqiinnovation.realtime - -import android.content.Context -import android.media.* -import android.util.Log -import kotlinx.coroutines.* -import java.util.concurrent.LinkedBlockingQueue - -/** - * 实时音频管理器 - * - * 负责音频的录制和播放,对应Android示例中的AudioRecord和AudioTrack功能 - */ -class RealtimeAudioManager(private val context: Context) { - companion object { - private const val TAG = "RealtimeAudioManager" - } - - // 音频配置 - private var audioConfig: AudioConfig? = null - - // 录音相关 - private var audioRecord: AudioRecord? = null - private var isRecording = false - private var recordJob: Job? = null - - // 播放相关 - private var audioTrack: AudioTrack? = null - private var isPlaying = false - private var playJob: Job? = null - private val playbackQueue = LinkedBlockingQueue() - - // 回调 - var onAudioData: ((ByteArray) -> Unit)? = null - var onStatusChanged: ((VoiceStatus) -> Unit)? = null - - // 协程作用域 - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /// 初始化音频管理器 - fun initialize(config: AudioConfig): Boolean { - this.audioConfig = config - - return setupAudioRecord(config) && setupAudioTrack(config) - } - - /// 设置AudioRecord(录音) - private fun setupAudioRecord(config: AudioConfig): Boolean { - try { - val channelConfig = if (config.channels == 1) { - AudioFormat.CHANNEL_IN_MONO - } else { - AudioFormat.CHANNEL_IN_STEREO - } - - val audioFormat = when (config.bitsPerSample) { - 16 -> AudioFormat.ENCODING_PCM_16BIT - 8 -> AudioFormat.ENCODING_PCM_8BIT - else -> AudioFormat.ENCODING_PCM_16BIT - } - - val bufferSize = AudioRecord.getMinBufferSize( - config.sampleRate, - channelConfig, - audioFormat - ) - - if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { - Log.e(TAG, "无法获取音频录制缓冲区大小") - return false - } - - audioRecord = AudioRecord( - MediaRecorder.AudioSource.MIC, - config.sampleRate, - channelConfig, - audioFormat, - bufferSize - ) - - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - Log.e(TAG, "AudioRecord初始化失败") - return false - } - - Log.i(TAG, "AudioRecord初始化成功,采样率: ${config.sampleRate}, 声道: ${config.channels}, 位深: ${config.bitsPerSample}") - return true - } catch (e: Exception) { - Log.e(TAG, "设置AudioRecord失败: ${e.message}", e) - return false - } - } - - /// 设置AudioTrack(播放) - private fun setupAudioTrack(config: AudioConfig): Boolean { - try { - val channelConfig = if (config.channels == 1) { - AudioFormat.CHANNEL_OUT_MONO - } else { - AudioFormat.CHANNEL_OUT_STEREO - } - - val audioFormat = when (config.bitsPerSample) { - 16 -> AudioFormat.ENCODING_PCM_16BIT - 8 -> AudioFormat.ENCODING_PCM_8BIT - else -> AudioFormat.ENCODING_PCM_16BIT - } - - val bufferSize = AudioTrack.getMinBufferSize( - config.sampleRate, - channelConfig, - audioFormat - ) - - if (bufferSize == AudioTrack.ERROR || bufferSize == AudioTrack.ERROR_BAD_VALUE) { - Log.e(TAG, "无法获取音频播放缓冲区大小") - return false - } - - audioTrack = AudioTrack.Builder() - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(config.sampleRate) - .setChannelMask(channelConfig) - .setEncoding(audioFormat) - .build() - ) - .setBufferSizeInBytes(bufferSize) - .setTransferMode(AudioTrack.MODE_STREAM) - .build() - - if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) { - Log.e(TAG, "AudioTrack初始化失败") - return false - } - - Log.i(TAG, "AudioTrack初始化成功") - return true - } catch (e: Exception) { - Log.e(TAG, "设置AudioTrack失败: ${e.message}", e) - return false - } - } - - /// 开始录音 - fun startRecording(): Boolean { - if (isRecording || audioRecord == null) { - return false - } - - try { - audioRecord?.startRecording() - isRecording = true - onStatusChanged?.invoke(VoiceStatus.RECORDING) - - // 启动录音协程 - recordJob = scope.launch { - loopRecordSend() - } - - Log.i(TAG, "开始录音") - return true - } catch (e: Exception) { - Log.e(TAG, "开始录音失败: ${e.message}", e) - return false - } - } - - /// 录音循环(对应Android示例中的loopRecordSend) - private suspend fun loopRecordSend() { - val config = audioConfig ?: return - val frameSize = config.frameSize - val buffer = ByteArray(frameSize) - - while (isRecording && audioRecord != null) { - try { - val bytesRead = audioRecord!!.read(buffer, 0, frameSize) - - if (bytesRead > 0) { - // 确保读取的数据长度正确 - val audioData = if (bytesRead == frameSize) { - buffer.copyOf() - } else { - buffer.copyOf(bytesRead) - } - - // 回调音频数据 - onAudioData?.invoke(audioData) - } else { - Log.w(TAG, "录音读取数据失败: $bytesRead") - } - } catch (e: Exception) { - Log.e(TAG, "录音循环异常: ${e.message}", e) - break - } - } - } - - /// 停止录音 - fun stopRecording(): Boolean { - if (!isRecording) { - return false - } - - try { - isRecording = false - recordJob?.cancel() - audioRecord?.stop() - onStatusChanged?.invoke(VoiceStatus.IDLE) - - Log.i(TAG, "停止录音") - return true - } catch (e: Exception) { - Log.e(TAG, "停止录音失败: ${e.message}", e) - return false - } - } - - /// 播放音频数据 - fun playAudioData(data: ByteArray) { - if (data.isNotEmpty()) { - playbackQueue.offer(data) - - // 如果没在播放,开始播放 - if (!isPlaying) { - startPlayback() - } - } - } - - /// 开始播放 - private fun startPlayback() { - if (isPlaying || audioTrack == null) { - return - } - - try { - audioTrack?.play() - isPlaying = true - onStatusChanged?.invoke(VoiceStatus.PLAYING) - - // 启动播放协程 - 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) { - try { - val audioData = playbackQueue.poll() - - 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 - } - } - } catch (e: Exception) { - Log.e(TAG, "播放循环异常: ${e.message}", e) - break - } - } - - // 播放结束 - stopPlayback() - } - - /// 停止播放 - fun stopPlaying(): Boolean { - return stopPlayback() - } - - private fun stopPlayback(): Boolean { - if (!isPlaying) { - return false - } - - try { - isPlaying = false - playJob?.cancel() - audioTrack?.stop() - playbackQueue.clear() - onStatusChanged?.invoke(VoiceStatus.IDLE) - - Log.i(TAG, "停止播放") - return true - } catch (e: Exception) { - Log.e(TAG, "停止播放失败: ${e.message}", e) - return false - } - } - - /// 更新配置 - fun updateConfig(config: AudioConfig): Boolean { - // 如果正在录音或播放,先停止 - if (isRecording) { - stopRecording() - } - if (isPlaying) { - stopPlayback() - } - - // 释放旧的音频对象 - releaseAudioObjects() - - return initialize(config) - } - - /// 释放音频对象 - private fun releaseAudioObjects() { - try { - audioRecord?.release() - audioRecord = null - - audioTrack?.release() - audioTrack = null - } catch (e: Exception) { - Log.e(TAG, "释放音频对象失败: ${e.message}", e) - } - } - - /// 释放资源 - fun dispose() { - stopRecording() - stopPlayback() - - scope.cancel() - releaseAudioObjects() - playbackQueue.clear() - - Log.i(TAG, "音频管理器已释放") - } -} \ 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 deleted file mode 100644 index a8e787656..000000000 --- a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt +++ /dev/null @@ -1,309 +0,0 @@ -package com.yunqiinnovation.realtime - -import android.content.Context -import android.util.Log -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import kotlinx.coroutines.* - -/** - * 实时语音聊天插件 - * - * 通过WebSocket连接Vocode服务器实现实时语音交互 - * 包含录音、播放、WebSocket通信等功能 - */ -class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { - companion object { - private const val TAG = "RealtimePlugin" - private const val METHOD_CHANNEL = "realtime/methods" - private const val EVENT_CHANNEL = "realtime/events" - } - - private lateinit var context: Context - private lateinit var methodChannel: MethodChannel - private lateinit var eventChannel: EventChannel - private var eventSink: EventChannel.EventSink? = null - - // 核心组件 - private lateinit var audioManager: RealtimeAudioManager - private lateinit var webSocketManager: RealtimeWebSocketManager - - // 协程作用域 - private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - - // 配置参数 - private var serverUrl: String = "" - private var sampleRate: Int = 16000 - private var channels: Int = 1 - private var bitsPerSample: Int = 16 - - // 状态 - @Volatile - private var connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED - @Volatile - private var voiceStatus: VoiceStatus = VoiceStatus.IDLE - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - - methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, METHOD_CHANNEL) - methodChannel.setMethodCallHandler(this) - - 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) { - methodChannel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) - - // 清理资源 - scope.cancel() - audioManager.dispose() - webSocketManager.dispose() - - Log.i(TAG, "Realtime插件已从引擎分离") - } - - /// 设置各组件的回调 - private fun setupCallbacks() { - // 音频管理器回调 - audioManager.onAudioData = { data -> - webSocketManager.sendAudioData(data) - } - - audioManager.onStatusChanged = { status -> - voiceStatus = status - sendEvent("voiceStatusChanged", status.value) - } - - // WebSocket管理器回调 - webSocketManager.onConnectionStatusChanged = { status -> - connectionStatus = status - sendEvent("connectionStatusChanged", status.value) - } - - webSocketManager.onAudioReceived = { data -> - audioManager.playAudioData(data) - } - - webSocketManager.onTextReceived = { text -> - sendEvent("textReceived", text) - } - - webSocketManager.onError = { error -> - sendEvent("error", error) - } - } - - /// 发送事件到Flutter端 - private fun sendEvent(type: String, data: Any?) { - val event = mapOf( - "type" to type, - "data" to data - ) - - scope.launch { - eventSink?.success(event) - } - } - - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "initialize" -> handleInitialize(call, result) - "connect" -> handleConnect(result) - "disconnect" -> handleDisconnect(result) - "startRecording" -> handleStartRecording(result) - "stopRecording" -> handleStopRecording(result) - "stopPlaying" -> handleStopPlaying(result) - "getConnectionStatus" -> result.success(connectionStatus.value) - "getVoiceStatus" -> result.success(voiceStatus.value) - "sendTextMessage" -> handleSendTextMessage(call, result) - "setAudioConfig" -> handleSetAudioConfig(call, result) - "dispose" -> handleDispose(result) - else -> result.notImplemented() - } - } - - private fun handleInitialize(call: MethodCall, result: Result) { - try { - 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 - - // 初始化音频管理器 - val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample) - val success = audioManager.initialize(audioConfig) - - if (success) { - webSocketManager.initialize(serverUrl) - Log.i(TAG, "实时语音插件初始化成功") - } else { - Log.e(TAG, "实时语音插件初始化失败") - } - - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "初始化失败: ${e.message}", e) - result.error("INITIALIZATION_ERROR", "初始化失败: ${e.message}", null) - } - } - - private fun handleConnect(result: Result) { - scope.launch { - try { - val success = webSocketManager.connect() - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "连接失败: ${e.message}", e) - result.error("CONNECTION_ERROR", "连接失败: ${e.message}", null) - } - } - } - - private fun handleDisconnect(result: Result) { - scope.launch { - try { - webSocketManager.disconnect() - result.success(true) - } catch (e: Exception) { - Log.e(TAG, "断开连接失败: ${e.message}", e) - result.error("DISCONNECTION_ERROR", "断开连接失败: ${e.message}", null) - } - } - } - - private fun handleStartRecording(result: Result) { - scope.launch { - try { - val success = audioManager.startRecording() - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "开始录音失败: ${e.message}", e) - result.error("RECORDING_ERROR", "开始录音失败: ${e.message}", null) - } - } - } - - private fun handleStopRecording(result: Result) { - scope.launch { - try { - val success = audioManager.stopRecording() - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "停止录音失败: ${e.message}", e) - result.error("RECORDING_ERROR", "停止录音失败: ${e.message}", null) - } - } - } - - private fun handleStopPlaying(result: Result) { - scope.launch { - try { - val success = audioManager.stopPlaying() - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "停止播放失败: ${e.message}", e) - result.error("PLAYBACK_ERROR", "停止播放失败: ${e.message}", null) - } - } - } - - private fun handleSendTextMessage(call: MethodCall, result: Result) { - try { - val args = call.arguments as? Map - val message = args?.get("message") as? String - ?: return result.error("INVALID_ARGUMENTS", "消息内容不能为空", null) - - val success = webSocketManager.sendTextMessage(message) - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "发送文本消息失败: ${e.message}", e) - result.error("MESSAGE_ERROR", "发送文本消息失败: ${e.message}", null) - } - } - - private fun handleSetAudioConfig(call: MethodCall, result: Result) { - try { - 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 } - - val audioConfig = AudioConfig(sampleRate, channels, bitsPerSample) - val success = audioManager.updateConfig(audioConfig) - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "设置音频配置失败: ${e.message}", e) - result.error("CONFIG_ERROR", "设置音频配置失败: ${e.message}", null) - } - } - - 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) - } - } - } - - // EventChannel.StreamHandler 实现 - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - eventSink = events - Log.i(TAG, "事件流监听已开始") - } - - override fun onCancel(arguments: Any?) { - eventSink = null - Log.i(TAG, "事件流监听已取消") - } -} - -// 枚举定义 -enum class ConnectionStatus(val value: String) { - DISCONNECTED("disconnected"), - CONNECTING("connecting"), - CONNECTED("connected"), - ERROR("error") -} - -enum class VoiceStatus(val value: String) { - IDLE("idle"), - RECORDING("recording"), - PROCESSING("processing"), - PLAYING("playing") -} - -// 音频配置 -data class AudioConfig( - val sampleRate: Int, - val channels: Int, - val bitsPerSample: Int -) { - val frameSize: Int - get() = (sampleRate * channels * bitsPerSample / 8 * 20) / 1000 // 20ms帧 -} \ No newline at end of file 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 deleted file mode 100644 index 77a6ebb49..000000000 --- a/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt +++ /dev/null @@ -1,218 +0,0 @@ -package com.yunqiinnovation.realtime - -import android.util.Log -import kotlinx.coroutines.* -import okhttp3.* -import okio.ByteString -import java.util.concurrent.TimeUnit - -/** - * 实时WebSocket管理器 - * - * 负责与Vocode服务器的WebSocket通信,对应Android示例中的OkHttp WebSocket功能 - */ -class RealtimeWebSocketManager : WebSocketListener() { - companion object { - private const val TAG = "RealtimeWebSocketManager" - private const val CONNECT_TIMEOUT = 30L - private const val READ_TIMEOUT = 60L - private const val WRITE_TIMEOUT = 30L - } - - // OkHttp客户端和WebSocket - private var okHttpClient: OkHttpClient? = null - private var webSocket: WebSocket? = null - - // 服务器URL - private var serverUrl: String = "" - - // 连接状态 - @Volatile - private var isConnected = false - @Volatile - private var isConnecting = false - - // 回调 - var onConnectionStatusChanged: ((ConnectionStatus) -> Unit)? = null - var onAudioReceived: ((ByteArray) -> Unit)? = null - var onTextReceived: ((String) -> Unit)? = null - var onError: ((String) -> Unit)? = null - - // 协程作用域 - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /// 初始化WebSocket管理器 - fun initialize(serverUrl: String) { - this.serverUrl = serverUrl - - // 创建OkHttp客户端 - okHttpClient = OkHttpClient.Builder() - .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) - .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build() - - Log.i(TAG, "WebSocket管理器初始化,服务器地址: $serverUrl") - } - - /// 连接到服务器 - suspend fun connect(): Boolean = withContext(Dispatchers.IO) { - if (isConnected || isConnecting) { - return@withContext false - } - - val client = okHttpClient ?: run { - Log.e(TAG, "OkHttp客户端未初始化") - onError?.invoke("客户端未初始化") - return@withContext false - } - - try { - isConnecting = true - onConnectionStatusChanged?.invoke(ConnectionStatus.CONNECTING) - - val request = Request.Builder() - .url(serverUrl) - .build() - - webSocket = client.newWebSocket(request, this@RealtimeWebSocketManager) - - // 等待连接建立 - var waitTime = 0 - while (isConnecting && waitTime < CONNECT_TIMEOUT * 1000) { - delay(100) - waitTime += 100 - } - - if (isConnected) { - Log.i(TAG, "WebSocket连接成功") - return@withContext true - } else { - Log.e(TAG, "WebSocket连接超时") - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) - return@withContext false - } - } catch (e: Exception) { - Log.e(TAG, "WebSocket连接失败: ${e.message}", e) - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) - onError?.invoke("连接失败: ${e.message}") - return@withContext false - } - } - - /// 断开连接 - fun disconnect() { - try { - isConnected = false - isConnecting = false - - webSocket?.close(1000, "正常关闭") - webSocket = null - - onConnectionStatusChanged?.invoke(ConnectionStatus.DISCONNECTED) - Log.i(TAG, "WebSocket已断开连接") - } catch (e: Exception) { - Log.e(TAG, "断开WebSocket连接失败: ${e.message}", e) - } - } - - /// 发送音频数据 - fun sendAudioData(data: ByteArray) { - if (!isConnected) { - return - } - - try { - val byteString = ByteString.of(*data) - val success = webSocket?.send(byteString) ?: false - - if (!success) { - Log.w(TAG, "发送音频数据失败") - } - } catch (e: Exception) { - Log.e(TAG, "发送音频数据异常: ${e.message}", e) - } - } - - /// 发送文本消息 - fun sendTextMessage(message: String): Boolean { - if (!isConnected) { - Log.w(TAG, "WebSocket未连接,无法发送文本消息") - return false - } - - return try { - val success = webSocket?.send(message) ?: false - if (!success) { - Log.w(TAG, "发送文本消息失败") - } - success - } catch (e: Exception) { - Log.e(TAG, "发送文本消息异常: ${e.message}", e) - false - } - } - - /// 释放资源 - fun dispose() { - disconnect() - - scope.cancel() - - okHttpClient?.dispatcher?.executorService?.shutdown() - okHttpClient = null - - Log.i(TAG, "WebSocket管理器已释放") - } - - // WebSocketListener 回调实现 - override fun onOpen(webSocket: WebSocket, response: Response) { - Log.i(TAG, "WebSocket连接已建立") - isConnected = true - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.CONNECTED) - } - - override fun onMessage(webSocket: WebSocket, text: String) { - Log.d(TAG, "收到文本消息: $text") - onTextReceived?.invoke(text) - } - - override fun onMessage(webSocket: WebSocket, bytes: ByteString) { - Log.d(TAG, "收到音频数据: ${bytes.size} 字节") - onAudioReceived?.invoke(bytes.toByteArray()) - } - - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.i(TAG, "WebSocket正在关闭,代码: $code, 原因: $reason") - webSocket.close(1000, null) - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.i(TAG, "WebSocket已关闭,代码: $code, 原因: $reason") - isConnected = false - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.DISCONNECTED) - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - val errorMessage = when { - t.message?.contains("Unable to parse TLS packet header") == true -> - "SSL错误:请检查服务器是否支持wss://,本地开发请使用ws://" - t.message?.contains("Connection refused") == true -> - "连接被拒绝:请检查服务器是否运行在指定端口" - t.message?.contains("timeout") == true -> - "连接超时:请检查网络连接和服务器地址" - else -> "连接失败: ${t.message}" - } - - Log.e(TAG, "WebSocket连接失败: ${t.message}", t) - isConnected = false - isConnecting = false - onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) - onError?.invoke(errorMessage) - } -} \ No newline at end of file diff --git a/local_plugins/realtime/example.md b/local_plugins/realtime/example.md deleted file mode 100644 index 74b8ebd44..000000000 --- a/local_plugins/realtime/example.md +++ /dev/null @@ -1,274 +0,0 @@ -# Realtime Plugin 使用示例 - -## 基本用法 - -```dart -import 'package:realtime/realtime.dart'; - -class VoiceCallPage extends StatefulWidget { - @override - _VoiceCallPageState createState() => _VoiceCallPageState(); -} - -class _VoiceCallPageState extends State { - final RealtimeService _realtimeService = RealtimeService(); - StreamSubscription? _eventSubscription; - - ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; - VoiceStatus _voiceStatus = VoiceStatus.idle; - - @override - void initState() { - super.initState(); - _setupEventListener(); - _initializeService(); - } - - void _setupEventListener() { - _eventSubscription = _realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - setState(() { - _connectionStatus = _parseConnectionStatus(event.data); - }); - break; - case 'voiceStatusChanged': - setState(() { - _voiceStatus = _parseVoiceStatus(event.data); - }); - break; - case 'textReceived': - print('收到文本: ${event.data}'); - break; - case 'error': - print('错误: ${event.data}'); - break; - } - }); - } - - Future _initializeService() async { - try { - final success = await _realtimeService.initialize( - serverUrl: 'wss://your-vocode-server.com/ws', - sampleRate: 16000, - channels: 1, - bitsPerSample: 16, - ); - - if (success) { - print('Realtime服务初始化成功'); - } else { - print('Realtime服务初始化失败'); - } - } catch (e) { - print('初始化错误: $e'); - } - } - - Future _connect() async { - try { - final success = await _realtimeService.connect(); - if (!success) { - print('连接失败'); - } - } catch (e) { - print('连接错误: $e'); - } - } - - Future _disconnect() async { - try { - await _realtimeService.disconnect(); - } catch (e) { - print('断开连接错误: $e'); - } - } - - Future _startRecording() async { - try { - final success = await _realtimeService.startRecording(); - if (!success) { - print('开始录音失败'); - } - } catch (e) { - print('录音错误: $e'); - } - } - - Future _stopRecording() async { - try { - final success = await _realtimeService.stopRecording(); - if (!success) { - print('停止录音失败'); - } - } catch (e) { - print('停止录音错误: $e'); - } - } - - ConnectionStatus _parseConnectionStatus(String status) { - switch (status) { - case 'connected': return ConnectionStatus.connected; - case 'connecting': return ConnectionStatus.connecting; - case 'disconnected': return ConnectionStatus.disconnected; - case 'error': return ConnectionStatus.error; - default: return ConnectionStatus.disconnected; - } - } - - VoiceStatus _parseVoiceStatus(String status) { - switch (status) { - case 'recording': return VoiceStatus.recording; - case 'processing': return VoiceStatus.processing; - case 'playing': return VoiceStatus.playing; - case 'idle': return VoiceStatus.idle; - default: return VoiceStatus.idle; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('实时语音聊天'), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 连接状态显示 - Text('连接状态: ${_connectionStatus.name}'), - SizedBox(height: 20), - - // 语音状态显示 - Text('语音状态: ${_voiceStatus.name}'), - SizedBox(height: 40), - - // 连接按钮 - ElevatedButton( - onPressed: _connectionStatus == ConnectionStatus.disconnected - ? _connect - : _disconnect, - child: Text(_connectionStatus == ConnectionStatus.disconnected - ? '连接' - : '断开'), - ), - SizedBox(height: 20), - - // 录音按钮 - ElevatedButton( - onPressed: _connectionStatus == ConnectionStatus.connected - ? (_voiceStatus == VoiceStatus.recording - ? _stopRecording - : _startRecording) - : null, - child: Text(_voiceStatus == VoiceStatus.recording - ? '停止录音' - : '开始录音'), - ), - ], - ), - ), - ); - } - - @override - void dispose() { - _eventSubscription?.cancel(); - _realtimeService.dispose(); - super.dispose(); - } -} -``` - -## GetX控制器中的使用 - -```dart -import 'package:get/get.dart'; -import 'package:realtime/realtime.dart'; - -class VoiceController extends GetxController { - final RealtimeService _realtimeService = RealtimeService(); - - final RxBool isConnected = false.obs; - final RxBool isRecording = false.obs; - - StreamSubscription? _eventSubscription; - - @override - void onInit() { - super.onInit(); - _setupEventListener(); - _initializeService(); - } - - void _setupEventListener() { - _eventSubscription = _realtimeService.eventStream.listen((event) { - switch (event.type) { - case 'connectionStatusChanged': - isConnected.value = event.data == 'connected'; - break; - case 'voiceStatusChanged': - isRecording.value = event.data == 'recording'; - break; - } - }); - } - - Future _initializeService() async { - await _realtimeService.initialize( - serverUrl: 'wss://your-server/ws', - ); - } - - Future toggleConnection() async { - if (isConnected.value) { - await _realtimeService.disconnect(); - } else { - await _realtimeService.connect(); - } - } - - Future toggleRecording() async { - if (isRecording.value) { - await _realtimeService.stopRecording(); - } else { - await _realtimeService.startRecording(); - } - } - - @override - void onClose() { - _eventSubscription?.cancel(); - _realtimeService.dispose(); - super.onClose(); - } -} -``` - -## 权限配置 - -### iOS -在 `ios/Runner/Info.plist` 中添加: - -```xml -NSMicrophoneUsageDescription -应用需要麦克风权限进行语音录制 -``` - -## 服务器配置示例 - -需要一个支持WebSocket的Vocode服务器,具体实现可参考Vocode官方文档。 - -服务器需要: -1. 接收16kHz/16-bit/单声道的PCM音频数据 -2. 返回相同格式的音频数据 -3. 支持文本消息交换 - -## 注意事项 - -1. 确保网络连接稳定 -2. 音频格式必须与服务器保持一致 -3. 及时处理事件流中的错误 -4. 在适当时机释放资源 \ No newline at end of file diff --git a/local_plugins/realtime/ios/realtime/Package.swift b/local_plugins/realtime/ios/realtime/Package.swift deleted file mode 100644 index 514fbc1e1..000000000 --- a/local_plugins/realtime/ios/realtime/Package.swift +++ /dev/null @@ -1,18 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "realtime", - platforms: [.iOS("18.0")], - products: [ - .library(name: "realtime", targets: ["realtime"]) - ], - dependencies: [], - targets: [ - .target( - name: "realtime", - dependencies: [], - path: "Sources/realtime" - ) - ] -) \ No newline at end of file diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift deleted file mode 100644 index 281907ce5..000000000 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift +++ /dev/null @@ -1,314 +0,0 @@ -import AVFoundation -import os.log -import Accelerate - -/** - * 实时音频管理器 - * - * 负责音频的录制和播放,对应Android示例中的AudioRecord和AudioTrack功能 - */ -class RealtimeAudioManager { - - private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimeAudioManager") - - // 音频配置 - private var audioConfig: AudioConfig? - - // 录音相关 - private var audioEngine: AVAudioEngine? - private var inputNode: AVAudioInputNode? - private var recordingFormat: AVAudioFormat? - private var isRecording = false - - // 播放相关 - private var audioPlayer: AVAudioPlayerNode? - private var playbackFormat: AVAudioFormat? - private var playbackQueue: DispatchQueue - private var audioBufferQueue: [AVAudioPCMBuffer] = [] - private var isPlaying = false - - // 回调 - var onAudioData: ((Data) -> Void)? - var onStatusChanged: ((VoiceStatus) -> Void)? - - // 录音缓冲区 - private var recordingBuffer: AVAudioPCMBuffer? - private var frameSize: Int = 320 // 20ms @ 16kHz - - init() { - playbackQueue = DispatchQueue(label: "com.realtime.playback", qos: .userInitiated) - setupAudioSession() - } - - /// 设置音频会话 - private func setupAudioSession() { - do { - let session = AVAudioSession.sharedInstance() - try session.setCategory(.playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetooth]) - try session.setActive(true) - os_log("音频会话设置成功", log: log, type: .info) - } catch { - os_log("音频会话设置失败: %@", log: log, type: .error, error.localizedDescription) - } - } - - /// 初始化音频管理器 - func initialize(config: AudioConfig) -> Bool { - self.audioConfig = config - self.frameSize = config.frameSize - - guard setupAudioEngine(config: config) else { - os_log("音频引擎初始化失败", log: log, type: .error) - return false - } - - guard setupAudioPlayer(config: config) else { - os_log("音频播放器初始化失败", log: log, type: .error) - return false - } - - os_log("音频管理器初始化成功", log: log, type: .info) - return true - } - - /// 设置音频引擎(录音) - private func setupAudioEngine(config: AudioConfig) -> Bool { - audioEngine = AVAudioEngine() - - guard let audioEngine = audioEngine else { return false } - - inputNode = audioEngine.inputNode - - // 创建录音格式:16kHz, 16-bit, 单声道 - recordingFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: Double(config.sampleRate), - channels: AVAudioChannelCount(config.channels), - interleaved: true) - - guard let recordingFormat = recordingFormat else { - os_log("无法创建录音格式", log: log, type: .error) - return false - } - - // 创建录音缓冲区 - let frameCount = AVAudioFrameCount(config.frameSize / (config.bitsPerSample / 8)) - recordingBuffer = AVAudioPCMBuffer(pcmFormat: recordingFormat, frameCapacity: frameCount) - - return true - } - - /// 设置音频播放器 - private func setupAudioPlayer(config: AudioConfig) -> Bool { - guard let audioEngine = audioEngine else { return false } - - audioPlayer = AVAudioPlayerNode() - - // 创建播放格式:16kHz, 16-bit, 单声道 - playbackFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: Double(config.sampleRate), - channels: AVAudioChannelCount(config.channels), - interleaved: true) - - guard let audioPlayer = audioPlayer, - let playbackFormat = playbackFormat else { - os_log("无法创建播放格式", log: log, type: .error) - return false - } - - // 连接播放器到音频引擎 - audioEngine.attach(audioPlayer) - audioEngine.connect(audioPlayer, to: audioEngine.outputNode, format: playbackFormat) - - return true - } - - /// 开始录音 - func startRecording() -> Bool { - guard let audioEngine = audioEngine, - let inputNode = inputNode, - let recordingFormat = recordingFormat, - !isRecording else { - return false - } - - // 安装录音tap - inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(frameSize / 2), format: recordingFormat) { [weak self] buffer, time in - self?.processRecordedAudio(buffer: buffer) - } - - do { - try audioEngine.start() - isRecording = true - onStatusChanged?(.recording) - os_log("开始录音", log: log, type: .info) - return true - } catch { - os_log("开始录音失败: %@", log: log, type: .error, error.localizedDescription) - return false - } - } - - /// 处理录制的音频数据 - private func processRecordedAudio(buffer: AVAudioPCMBuffer) { - guard let channelData = buffer.int16ChannelData?[0], - buffer.frameLength > 0 else { - return - } - - // 确保数据长度匹配frameSize - let dataLength = min(Int(buffer.frameLength) * 2, frameSize) // 2 bytes per sample - - // 转换为Data - let data = Data(bytes: channelData, count: dataLength) - - // 回调音频数据 - onAudioData?(data) - } - - /// 停止录音 - func stopRecording() -> Bool { - guard let audioEngine = audioEngine, - let inputNode = inputNode, - isRecording else { - return false - } - - inputNode.removeTap(onBus: 0) - audioEngine.stop() - isRecording = false - onStatusChanged?(.idle) - os_log("停止录音", log: log, type: .info) - return true - } - - /// 播放音频数据 - func playAudioData(_ data: Data) { - guard let playbackFormat = playbackFormat, - let audioPlayer = audioPlayer else { - return - } - - playbackQueue.async { [weak self] in - self?.enqueueAudioData(data, format: playbackFormat, player: audioPlayer) - } - } - - /// 将音频数据加入播放队列 - private func enqueueAudioData(_ data: Data, format: AVAudioFormat, player: AVAudioPlayerNode) { - // 创建PCM缓冲区 - let frameCount = AVAudioFrameCount(data.count / 2) // 16-bit = 2 bytes per sample - - guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { - os_log("无法创建播放缓冲区", log: log, type: .error) - return - } - - buffer.frameLength = frameCount - - // 复制数据到缓冲区 - guard let channelData = buffer.int16ChannelData?[0] else { - return - } - - data.withUnsafeBytes { bytes in - let int16Pointer = bytes.bindMemory(to: Int16.self) - channelData.update(from: int16Pointer.baseAddress!, count: Int(frameCount)) - } - - // 加入播放队列 - audioBufferQueue.append(buffer) - - // 如果没在播放,开始播放 - if !isPlaying { - startPlayback() - } - } - - /// 开始播放 - private func startPlayback() { - guard let audioEngine = audioEngine, - let audioPlayer = audioPlayer, - !audioBufferQueue.isEmpty else { - return - } - - if !audioEngine.isRunning { - do { - try audioEngine.start() - } catch { - os_log("启动音频引擎失败: %@", log: log, type: .error, error.localizedDescription) - return - } - } - - if !audioPlayer.isPlaying { - audioPlayer.play() - } - - isPlaying = true - onStatusChanged?(.playing) - - // 播放队列中的音频 - playNextBuffer() - } - - /// 播放下一个缓冲区 - private func playNextBuffer() { - guard let audioPlayer = audioPlayer, - !audioBufferQueue.isEmpty else { - isPlaying = false - onStatusChanged?(.idle) - return - } - - let buffer = audioBufferQueue.removeFirst() - - audioPlayer.scheduleBuffer(buffer) { [weak self] in - DispatchQueue.main.async { - self?.playNextBuffer() - } - } - } - - /// 停止播放 - func stopPlaying() -> Bool { - guard let audioPlayer = audioPlayer else { - return false - } - - audioPlayer.stop() - audioBufferQueue.removeAll() - isPlaying = false - onStatusChanged?(.idle) - os_log("停止播放", log: log, type: .info) - return true - } - - /// 更新配置 - func updateConfig(config: AudioConfig) -> Bool { - // 如果正在录音或播放,先停止 - if isRecording { - _ = stopRecording() - } - if isPlaying { - _ = stopPlaying() - } - - return initialize(config: config) - } - - /// 释放资源 - func dispose() { - _ = stopRecording() - _ = stopPlaying() - - audioEngine?.stop() - audioEngine = nil - audioPlayer = nil - audioBufferQueue.removeAll() - - os_log("音频管理器已释放", log: log, type: .info) - } -} \ 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 deleted file mode 100644 index 857e22d43..000000000 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift +++ /dev/null @@ -1,274 +0,0 @@ -import Flutter -import UIKit -import AVFoundation -import os.log - -/** - * 实时语音聊天插件 - * - * 通过WebSocket连接Vocode服务器实现实时语音交互 - * 包含录音、播放、WebSocket通信等功能 - */ -@objc public class RealtimePlugin: NSObject, FlutterPlugin { - - private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimePlugin") - - // Flutter通道 - private var methodChannel: FlutterMethodChannel? - private var eventChannel: FlutterEventChannel? - private var eventSink: FlutterEventSink? - - // 核心组件 - private let audioManager = RealtimeAudioManager() - private let websocketManager = RealtimeWebSocketManager() - - // 配置参数 - private var serverUrl: String = "" - private var sampleRate: Int = 16000 - private var channels: Int = 1 - private var bitsPerSample: Int = 16 - - // 状态 - private var connectionStatus: ConnectionStatus = .disconnected - private var voiceStatus: VoiceStatus = .idle - - /// 插件注册 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = RealtimePlugin() - - // 方法通道 - let methodChannel = FlutterMethodChannel(name: "realtime/methods", binaryMessenger: registrar.messenger()) - registrar.addMethodCallDelegate(instance, channel: methodChannel) - instance.methodChannel = methodChannel - - // 事件通道 - let eventChannel = FlutterEventChannel(name: "realtime/events", binaryMessenger: registrar.messenger()) - eventChannel.setStreamHandler(instance) - instance.eventChannel = eventChannel - - // 设置组件回调 - instance.setupCallbacks() - } - - /// 设置各组件的回调 - private func setupCallbacks() { - // 音频管理器回调 - audioManager.onAudioData = { [weak self] data in - self?.websocketManager.sendAudioData(data) - } - - audioManager.onStatusChanged = { [weak self] status in - self?.voiceStatus = status - self?.sendEvent(type: "voiceStatusChanged", data: status.rawValue) - } - - // WebSocket管理器回调 - websocketManager.onConnectionStatusChanged = { [weak self] status in - self?.connectionStatus = status - self?.sendEvent(type: "connectionStatusChanged", data: status.rawValue) - } - - websocketManager.onAudioReceived = { [weak self] data in - self?.audioManager.playAudioData(data) - } - - websocketManager.onTextReceived = { [weak self] text in - self?.sendEvent(type: "textReceived", data: text) - } - - websocketManager.onError = { [weak self] error in - self?.sendEvent(type: "error", data: error) - } - } - - /// 发送事件到Flutter端 - private func sendEvent(type: String, data: Any?) { - guard let eventSink = eventSink else { return } - - let event: [String: Any] = [ - "type": type, - "data": data ?? NSNull() - ] - - DispatchQueue.main.async { - eventSink(event) - } - } -} - -// MARK: - FlutterPlugin -extension RealtimePlugin { - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "initialize": - handleInitialize(call, result) - case "connect": - handleConnect(result) - case "disconnect": - handleDisconnect(result) - case "startRecording": - handleStartRecording(result) - case "stopRecording": - handleStopRecording(result) - case "stopPlaying": - handleStopPlaying(result) - case "getConnectionStatus": - result(connectionStatus.rawValue) - case "getVoiceStatus": - result(voiceStatus.rawValue) - case "sendTextMessage": - handleSendTextMessage(call, result) - case "setAudioConfig": - handleSetAudioConfig(call, result) - case "dispose": - handleDispose(result) - default: - result(FlutterMethodNotImplemented) - } - } - - private func handleInitialize(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let serverUrl = args["serverUrl"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "服务器地址不能为空", details: nil)) - return - } - - self.serverUrl = serverUrl - self.sampleRate = args["sampleRate"] as? Int ?? 16000 - self.channels = args["channels"] as? Int ?? 1 - self.bitsPerSample = args["bitsPerSample"] as? Int ?? 16 - - // 初始化音频管理器 - let audioConfig = AudioConfig( - sampleRate: sampleRate, - channels: channels, - bitsPerSample: bitsPerSample - ) - - let success = audioManager.initialize(config: audioConfig) - if success { - websocketManager.initialize(serverUrl: serverUrl) - os_log("实时语音插件初始化成功", log: log, type: .info) - } else { - os_log("实时语音插件初始化失败", log: log, type: .error) - } - - result(success) - } - - private func handleConnect(_ result: @escaping FlutterResult) { - websocketManager.connect { [weak self] success in - DispatchQueue.main.async { - result(success) - } - } - } - - private func handleDisconnect(_ result: @escaping FlutterResult) { - websocketManager.disconnect() - result(true) - } - - private func handleStartRecording(_ result: @escaping FlutterResult) { - let success = audioManager.startRecording() - result(success) - } - - private func handleStopRecording(_ result: @escaping FlutterResult) { - let success = audioManager.stopRecording() - result(success) - } - - private func handleStopPlaying(_ result: @escaping FlutterResult) { - let success = audioManager.stopPlaying() - result(success) - } - - private func handleSendTextMessage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let message = args["message"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "消息内容不能为空", details: nil)) - return - } - - let success = websocketManager.sendTextMessage(message) - result(success) - } - - private func handleSetAudioConfig(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any] else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "参数无效", details: nil)) - return - } - - if let sampleRate = args["sampleRate"] as? Int { - self.sampleRate = sampleRate - } - if let channels = args["channels"] as? Int { - self.channels = channels - } - if let bitsPerSample = args["bitsPerSample"] as? Int { - self.bitsPerSample = bitsPerSample - } - - let audioConfig = AudioConfig( - sampleRate: sampleRate, - channels: channels, - bitsPerSample: bitsPerSample - ) - - let success = audioManager.updateConfig(config: audioConfig) - result(success) - } - - private func handleDispose(_ result: @escaping FlutterResult) { - audioManager.dispose() - websocketManager.dispose() - result(nil) - } -} - -// MARK: - FlutterStreamHandler -extension RealtimePlugin: FlutterStreamHandler { - - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - return nil - } -} - -// MARK: - 枚举定义 - -enum ConnectionStatus: String { - case disconnected = "disconnected" - case connecting = "connecting" - case connected = "connected" - case error = "error" -} - -enum VoiceStatus: String { - case idle = "idle" - case recording = "recording" - case processing = "processing" - case playing = "playing" -} - -// MARK: - 音频配置 - -struct AudioConfig { - let sampleRate: Int - let channels: Int - let bitsPerSample: Int - - var frameSize: Int { - // 20ms的帧大小 - return (sampleRate * channels * bitsPerSample / 8 * 20) / 1000 - } -} \ No newline at end of file diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift deleted file mode 100644 index ed4428f8f..000000000 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift +++ /dev/null @@ -1,264 +0,0 @@ -import Foundation -import Network -import os.log - -/** - * 实时WebSocket管理器 - * - * 负责与Vocode服务器的WebSocket通信,对应Android示例中的OkHttp WebSocket功能 - */ -class RealtimeWebSocketManager: NSObject { - - private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimeWebSocketManager") - - // WebSocket连接 - private var webSocketTask: URLSessionWebSocketTask? - private var urlSession: URLSession? - - // 服务器URL - private var serverUrl: String = "" - - // 连接状态 - private var isConnected = false - private var isConnecting = false - - // 回调 - var onConnectionStatusChanged: ((ConnectionStatus) -> Void)? - var onAudioReceived: ((Data) -> Void)? - var onTextReceived: ((String) -> Void)? - var onError: ((String) -> Void)? - - // 发送队列 - private let sendQueue = DispatchQueue(label: "com.realtime.websocket.send", qos: .userInitiated) - private let receiveQueue = DispatchQueue(label: "com.realtime.websocket.receive", qos: .userInitiated) - - override init() { - super.init() - setupURLSession() - } - - /// 设置URL会话 - private func setupURLSession() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 60 - urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) - } - - /// 初始化WebSocket管理器 - func initialize(serverUrl: String) { - self.serverUrl = serverUrl - os_log("WebSocket管理器初始化,服务器地址: %@", log: log, type: .info, serverUrl) - } - - /// 连接到服务器 - func connect(completion: @escaping (Bool) -> Void) { - guard !isConnected && !isConnecting else { - completion(false) - return - } - - guard let url = URL(string: serverUrl) else { - os_log("无效的服务器地址: %@", log: log, type: .error, serverUrl) - onError?("无效的服务器地址") - completion(false) - return - } - - isConnecting = true - onConnectionStatusChanged?(.connecting) - - // 创建WebSocket任务 - var request = URLRequest(url: url) - request.timeoutInterval = 30 - - webSocketTask = urlSession?.webSocketTask(with: request) - - // 开始监听消息 - startListening() - - // 开始连接 - webSocketTask?.resume() - - // 等待连接建立 - DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.checkConnection(completion: completion) - } - } - - /// 检查连接状态 - private func checkConnection(completion: @escaping (Bool) -> Void) { - guard let webSocketTask = webSocketTask else { - isConnecting = false - onConnectionStatusChanged?(.error) - completion(false) - return - } - - switch webSocketTask.state { - case .running: - isConnected = true - isConnecting = false - onConnectionStatusChanged?(.connected) - os_log("WebSocket连接成功", log: log, type: .info) - completion(true) - case .suspended: - // 等待连接完成 - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.checkConnection(completion: completion) - } - default: - isConnecting = false - onConnectionStatusChanged?(.error) - os_log("WebSocket连接失败", log: log, type: .error) - completion(false) - } - } - - /// 开始监听消息 - private func startListening() { - receiveMessage() - } - - /// 接收消息 - private func receiveMessage() { - webSocketTask?.receive { [weak self] result in - switch result { - case .success(let message): - self?.handleReceivedMessage(message) - // 继续监听下一条消息 - self?.receiveMessage() - case .failure(let error): - self?.handleReceiveError(error) - } - } - } - - /// 处理接收到的消息 - private func handleReceivedMessage(_ message: URLSessionWebSocketTask.Message) { - switch message { - case .data(let data): - // 音频数据 - onAudioReceived?(data) - case .string(let text): - // 文本数据 - onTextReceived?(text) - @unknown default: - os_log("收到未知类型消息", log: log, type: .error) - } - } - - /// 处理接收错误 - private func handleReceiveError(_ error: Error) { - os_log("WebSocket接收错误: %@", log: log, type: .error, error.localizedDescription) - - // 如果连接断开,尝试重连 - if isConnected { - isConnected = false - onConnectionStatusChanged?(.error) - onError?("连接断开: \(error.localizedDescription)") - } - } - - /// 发送音频数据 - func sendAudioData(_ data: Data) { - 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) - } - } - } - } - - /// 发送文本消息 - func sendTextMessage(_ message: String) -> Bool { - guard isConnected else { - os_log("WebSocket未连接,无法发送文本消息", log: log, type: .error) - return false - } - - sendQueue.async { [weak self] in - self?.webSocketTask?.send(.string(message)) { error in - if let error = error { - self?.os_log("发送文本消息失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription) - } - } - } - - return true - } - - /// 断开连接 - func disconnect() { - guard isConnected || isConnecting else { return } - - webSocketTask?.cancel(with: .goingAway, reason: nil) - webSocketTask = nil - - isConnected = false - isConnecting = false - onConnectionStatusChanged?(.disconnected) - os_log("WebSocket已断开连接", log: log, type: .info) - } - - /// 释放资源 - func dispose() { - disconnect() - urlSession?.invalidateAndCancel() - urlSession = nil - os_log("WebSocket管理器已释放", log: log, type: .info) - } - - // 修复方法访问性问题 - 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: - URLSessionWebSocketDelegate -extension RealtimeWebSocketManager: URLSessionWebSocketDelegate { - - func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { - os_log("WebSocket连接已建立", log: log, type: .info) - isConnected = true - isConnecting = false - onConnectionStatusChanged?(.connected) - } - - func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - os_log("WebSocket连接已关闭,关闭码: %d", log: log, type: .info, closeCode.rawValue) - isConnected = false - isConnecting = false - onConnectionStatusChanged?(.disconnected) - } -} - -// MARK: - URLSessionDelegate -extension RealtimeWebSocketManager: URLSessionDelegate { - - func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - if let error = error { - os_log("URLSession失效: %@", log: log, type: .error, error.localizedDescription) - onError?("会话失效: \(error.localizedDescription)") - } - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if let error = error { - os_log("URLSessionTask完成时出错: %@", log: log, type: .error, error.localizedDescription) - if isConnected || isConnecting { - isConnected = false - isConnecting = false - onConnectionStatusChanged?(.error) - onError?("连接错误: \(error.localizedDescription)") - } - } - } -} \ No newline at end of file diff --git a/local_plugins/realtime/lib/realtime.dart b/local_plugins/realtime/lib/realtime.dart deleted file mode 100644 index 237e9e3e8..000000000 --- a/local_plugins/realtime/lib/realtime.dart +++ /dev/null @@ -1,243 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/services.dart'; - -/// 实时语音服务异常 -class RealtimeException implements Exception { - final String message; - - RealtimeException(this.message); - - @override - String toString() => 'RealtimeException: $message'; -} - -/// 连接状态 -enum ConnectionStatus { - disconnected, - connecting, - connected, - error, -} - -/// 语音状态 -enum VoiceStatus { - idle, - recording, - processing, - playing, -} - -/// 实时语音事件 -class RealtimeEvent { - final String type; - final dynamic data; - - RealtimeEvent({required this.type, this.data}); - - factory RealtimeEvent.fromMap(Map map) { - return RealtimeEvent( - type: map['type'] as String, - data: map['data'], - ); - } -} - -/// 实时语音聊天服务 -class RealtimeService { - static const MethodChannel _channel = MethodChannel('realtime/methods'); - static const EventChannel _eventChannel = EventChannel('realtime/events'); - - StreamController? _eventStreamController; - Stream? _eventStream; - - /// 获取事件流 - Stream get eventStream { - if (_eventStream == null) { - _eventStreamController = StreamController.broadcast(); - _eventStream = _eventStreamController!.stream; - - // 监听原生事件 - _eventChannel.receiveBroadcastStream().listen( - (dynamic event) { - if (event is Map) { - final eventMap = Map.from(event); - final realtimeEvent = RealtimeEvent.fromMap(eventMap); - _eventStreamController!.add(realtimeEvent); - } - }, - onError: (error) { - _eventStreamController!.addError(RealtimeException('事件流错误: $error')); - }, - ); - } - - return _eventStream!; - } - - /// 初始化实时语音服务 - /// - /// [serverUrl] WebSocket服务器地址 - /// [sampleRate] 采样率,默认16000 - /// [channels] 声道数,默认1(单声道) - /// [bitsPerSample] 位深,默认16 - Future initialize({ - required String serverUrl, - int sampleRate = 16000, - int channels = 1, - int bitsPerSample = 16, - }) async { - try { - final result = await _channel.invokeMethod( - 'initialize', - { - 'serverUrl': serverUrl, - 'sampleRate': sampleRate, - 'channels': channels, - 'bitsPerSample': bitsPerSample, - }, - ); - - return result ?? false; - } catch (e) { - throw RealtimeException('初始化失败: $e'); - } - } - - /// 连接到服务器 - Future connect() async { - try { - final result = await _channel.invokeMethod('connect'); - return result ?? false; - } catch (e) { - throw RealtimeException('连接失败: $e'); - } - } - - /// 断开连接 - Future disconnect() async { - try { - final result = await _channel.invokeMethod('disconnect'); - return result ?? false; - } catch (e) { - throw RealtimeException('断开连接失败: $e'); - } - } - - /// 开始录音 - Future startRecording() async { - try { - final result = await _channel.invokeMethod('startRecording'); - return result ?? false; - } catch (e) { - throw RealtimeException('开始录音失败: $e'); - } - } - - /// 停止录音 - Future stopRecording() async { - try { - final result = await _channel.invokeMethod('stopRecording'); - return result ?? false; - } catch (e) { - throw RealtimeException('停止录音失败: $e'); - } - } - - /// 停止播放 - Future stopPlaying() async { - try { - final result = await _channel.invokeMethod('stopPlaying'); - return result ?? false; - } catch (e) { - throw RealtimeException('停止播放失败: $e'); - } - } - - /// 获取连接状态 - Future getConnectionStatus() async { - try { - final result = await _channel.invokeMethod('getConnectionStatus'); - switch (result) { - case 'disconnected': - return ConnectionStatus.disconnected; - case 'connecting': - return ConnectionStatus.connecting; - case 'connected': - return ConnectionStatus.connected; - case 'error': - return ConnectionStatus.error; - default: - return ConnectionStatus.disconnected; - } - } catch (e) { - throw RealtimeException('获取连接状态失败: $e'); - } - } - - /// 获取语音状态 - Future getVoiceStatus() async { - try { - final result = await _channel.invokeMethod('getVoiceStatus'); - switch (result) { - case 'idle': - return VoiceStatus.idle; - case 'recording': - return VoiceStatus.recording; - case 'processing': - return VoiceStatus.processing; - case 'playing': - return VoiceStatus.playing; - default: - return VoiceStatus.idle; - } - } catch (e) { - throw RealtimeException('获取语音状态失败: $e'); - } - } - - /// 发送文本消息 - Future sendTextMessage(String message) async { - try { - final result = await _channel.invokeMethod( - 'sendTextMessage', - {'message': message}, - ); - return result ?? false; - } catch (e) { - throw RealtimeException('发送文本消息失败: $e'); - } - } - - /// 设置音频参数 - Future setAudioConfig({ - int? sampleRate, - int? channels, - int? bitsPerSample, - }) async { - try { - final params = {}; - if (sampleRate != null) params['sampleRate'] = sampleRate; - if (channels != null) params['channels'] = channels; - if (bitsPerSample != null) params['bitsPerSample'] = bitsPerSample; - - final result = await _channel.invokeMethod('setAudioConfig', params); - return result ?? false; - } catch (e) { - throw RealtimeException('设置音频参数失败: $e'); - } - } - - /// 释放资源 - Future dispose() async { - try { - await _channel.invokeMethod('dispose'); - _eventStreamController?.close(); - _eventStreamController = null; - _eventStream = null; - } catch (e) { - throw RealtimeException('释放资源失败: $e'); - } - } -} \ No newline at end of file diff --git a/local_plugins/realtime/pubspec.yaml b/local_plugins/realtime/pubspec.yaml deleted file mode 100644 index 0eed88a7d..000000000 --- a/local_plugins/realtime/pubspec.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: realtime -description: 实时语音聊天插件,通过WebSocket连接Vocode服务器实现语音交互 -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=2.5.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# Flutter插件配置 -flutter: - plugin: - platforms: - android: - package: com.yunqiinnovation.realtime - pluginClass: RealtimePlugin - ios: - pluginClass: RealtimePlugin - swiftPackage: - path: ios/realtime \ No newline at end of file From feab4c0d35214eea15997410e7701072b262da70 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Mon, 16 Jun 2025 16:48:35 +0100 Subject: [PATCH 4/8] add --- local_plugins/realtime/.gitignore | 51 ++ local_plugins/realtime/IMPLEMENTATION.md | 190 +++++++ local_plugins/realtime/README.md | 125 +++++ .../realtime/android/build.gradle.kts | 47 ++ .../realtime/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 11 + .../realtime/RealtimeAudioManager.kt | 519 ++++++++++++++++++ .../realtime/RealtimePlugin.kt | 455 +++++++++++++++ .../realtime/RealtimeWebSocketManager.kt | 303 ++++++++++ local_plugins/realtime/example.md | 274 +++++++++ .../realtime/ios/realtime/Package.swift | 18 + .../realtime/RealtimeAudioManager.swift | 344 ++++++++++++ .../Sources/realtime/RealtimePlugin.swift | 319 +++++++++++ .../realtime/RealtimeWebSocketManager.swift | 323 +++++++++++ local_plugins/realtime/lib/realtime.dart | 243 ++++++++ local_plugins/realtime/pubspec.yaml | 29 + 16 files changed, 3252 insertions(+) create mode 100644 local_plugins/realtime/.gitignore create mode 100644 local_plugins/realtime/IMPLEMENTATION.md create mode 100644 local_plugins/realtime/README.md create mode 100644 local_plugins/realtime/android/build.gradle.kts create mode 100644 local_plugins/realtime/android/settings.gradle.kts create mode 100644 local_plugins/realtime/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt create mode 100644 local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt create mode 100644 local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt create mode 100644 local_plugins/realtime/example.md create mode 100644 local_plugins/realtime/ios/realtime/Package.swift create mode 100644 local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift create mode 100644 local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift create mode 100644 local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift create mode 100644 local_plugins/realtime/lib/realtime.dart create mode 100644 local_plugins/realtime/pubspec.yaml diff --git a/local_plugins/realtime/.gitignore b/local_plugins/realtime/.gitignore new file mode 100644 index 000000000..f5af98475 --- /dev/null +++ b/local_plugins/realtime/.gitignore @@ -0,0 +1,51 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# iOS related +**/ios/.generated/ +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* +.build/ + +# Coverage +coverage/ + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 \ No newline at end of file diff --git a/local_plugins/realtime/IMPLEMENTATION.md b/local_plugins/realtime/IMPLEMENTATION.md new file mode 100644 index 000000000..a5d49a976 --- /dev/null +++ b/local_plugins/realtime/IMPLEMENTATION.md @@ -0,0 +1,190 @@ +# Realtime Plugin 实现总结 + +## 项目概述 + +基于您提供的Android原生示例,我为DeepVoice项目创建了一个realtime插件,用于通过WebSocket接入Vocode服务器实现实时语音聊天功能。该插件已完成Android和iOS双平台实现。 + +## 架构设计 + +### 插件结构 +``` +local_plugins/realtime/ +├── android/ # Android实现 +│ ├── build.gradle # Android构建配置 +│ ├── src/main/ +│ │ ├── AndroidManifest.xml # Android权限配置 +│ │ └── kotlin/com/yunqiinnovation/realtime/ +│ │ ├── RealtimePlugin.kt # 主插件入口 +│ │ ├── RealtimeAudioManager.kt # 音频管理器(AudioRecord/AudioTrack) +│ │ └── RealtimeWebSocketManager.kt # WebSocket管理器(OkHttp) +├── ios/ # iOS实现 +│ ├── Classes/ # Objective-C桥接文件 +│ │ ├── RealtimePlugin.h +│ │ └── RealtimePlugin.m +│ ├── realtime/ # Swift Package +│ │ ├── Package.swift +│ │ └── Sources/realtime/ +│ │ ├── RealtimePlugin.swift # 主插件入口 +│ │ ├── RealtimeAudioManager.swift # 音频管理器(AVAudioEngine) +│ │ └── RealtimeWebSocketManager.swift # WebSocket管理器(URLSession) +│ └── realtime.podspec +├── lib/ +│ └── realtime.dart # Flutter接口 +├── pubspec.yaml +└── README.md +``` + +### 核心组件 + +1. **RealtimePlugin (SwiftRealtimePlugin)** + - 主插件入口,处理Flutter方法调用 + - 管理各组件间的协调 + - 处理事件分发 + +2. **RealtimeAudioManager** + - 使用AVAudioEngine进行音频录制 + - 使用AVAudioPlayerNode进行音频播放 + - 支持16kHz/16-bit/单声道格式 + - 实现20ms帧长的音频处理 + +3. **RealtimeWebSocketManager** + - 使用URLSessionWebSocketTask实现WebSocket通信 + - 支持音频数据和文本消息的收发 + - 自动连接状态管理 + +## 技术实现对照 + +| 功能 | Android示例 | iOS实现 | +|------|-------------|---------| +| 音频录制 | AudioRecord | AVAudioEngine + AVAudioInputNode | +| 音频播放 | AudioTrack | AVAudioPlayerNode | +| WebSocket | OkHttp WebSocket | URLSessionWebSocketTask | +| 线程管理 | Kotlin协程 | DispatchQueue | +| 音频格式 | 16kHz/16-bit/单声道 | 16kHz/16-bit/单声道 | +| 帧长 | 20ms (320字节) | 20ms (320字节) | + +## Flutter接口 + +### 主要类 +- `RealtimeService`: 主服务类 +- `RealtimeEvent`: 事件类 +- `ConnectionStatus`: 连接状态枚举 +- `VoiceStatus`: 语音状态枚举 +- `RealtimeException`: 异常类 + +### 主要方法 +- `initialize()`: 初始化服务 +- `connect()`: 连接服务器 +- `disconnect()`: 断开连接 +- `startRecording()`: 开始录音 +- `stopRecording()`: 停止录音 +- `eventStream`: 事件流 + +## 使用方式 + +### 1. 在RealtimeController中集成 +已更新`lib/modules/realtime/controllers/realtime_controller.dart`使用真实的realtime插件,取代了原来的模拟实现。 + +### 2. 事件监听 +```dart +_realtimeService.eventStream.listen((event) { + switch (event.type) { + case 'connectionStatusChanged': + // 处理连接状态变化 + case 'voiceStatusChanged': + // 处理语音状态变化 + case 'textReceived': + // 处理文本消息 + case 'error': + // 处理错误 + } +}); +``` + +## 配置要求 + +### 依赖配置 +已添加到主项目的`pubspec.yaml`: +```yaml +realtime: + path: local_plugins/realtime +``` + +### 权限配置 +iOS的`Info.plist`已包含必要的麦克风权限: +```xml +NSMicrophoneUsageDescription +需要麦克风权限用于语音识别和录音功能 +``` + +## 与Android示例的对应关系 + +1. **初始化对应** + - Android: 创建AudioRecord, AudioTrack, OkHttpClient + - iOS: 创建AVAudioEngine, AVAudioPlayerNode, URLSession + +2. **录音线程对应** + - Android: `loopRecordSend()` 协程 + - iOS: AVAudioInputNode的installTap回调 + +3. **播放线程对应** + - Android: `loopPlayback()` 协程 + LinkedBlockingQueue + - iOS: DispatchQueue + AVAudioPCMBuffer队列 + +4. **WebSocket对应** + - Android: OkHttp WebSocketListener + - iOS: URLSessionWebSocketDelegate + +## 特性支持 + +✅ **已实现** +- Android和iOS双平台支持 +- 实时音频录制和播放(16kHz/16-bit/单声道) +- WebSocket双向通信 +- 状态管理和事件通知 +- 错误处理 +- 资源管理和清理 +- 20ms帧长处理 +- Kotlin协程和DispatchQueue线程管理 + +❌ **未实现(可扩展)** +- 音频编码(Opus等) +- 噪声消除/回声抑制 +- 自动重连机制 +- 音频质量自适应 + +## 使用注意事项 + +1. **服务器地址配置** + ```dart + // 需要替换为实际的Vocode服务器地址 + serverUrl: 'wss://your-vocode-server/ws' + ``` + +2. **音频格式一致性** + - 确保服务器支持16kHz/16-bit/单声道格式 + - 帧长固定为20ms (320字节) + +3. **错误处理** + - 监听事件流中的错误 + - 处理网络断线和重连 + +4. **资源管理** + - 及时调用dispose()释放资源 + - 避免内存泄漏 + +## 测试建议 + +1. **本地测试** + - 先用echo服务器测试WebSocket连接 + - 验证音频录制和播放功能 + +2. **集成测试** + - 与真实Vocode服务器集成 + - 测试端到端语音交互 + +3. **性能测试** + - 测试长时间使用的稳定性 + - 监控内存和CPU使用 + +这个实现为DeepVoice项目提供了完整的实时语音交互能力,可以直接与Vocode服务器进行通信,实现类似Android示例的功能。 \ No newline at end of file diff --git a/local_plugins/realtime/README.md b/local_plugins/realtime/README.md new file mode 100644 index 000000000..a5bc746f0 --- /dev/null +++ b/local_plugins/realtime/README.md @@ -0,0 +1,125 @@ +# Realtime Plugin + +实时语音聊天插件,通过WebSocket连接Vocode服务器实现实时语音交互。 + +## 功能特性 + +- **实时音频录制**: 16kHz/16-bit/单声道格式录音 +- **WebSocket通信**: 与Vocode服务器进行实时数据传输 +- **音频播放**: 播放服务器返回的语音数据 +- **状态管理**: 连接状态和语音状态监控 +- **事件回调**: 支持各种事件的监听和处理 + +## 技术实现 + +### Android端实现 +- **音频录制**: 使用AudioRecord进行16kHz/16-bit/单声道录音 +- **音频播放**: 使用AudioTrack进行音频播放 +- **WebSocket**: 使用OkHttp WebSocket客户端 +- **线程管理**: 使用Kotlin协程处理录音和播放 + +### iOS端实现 +- **音频录制**: 使用AVAudioEngine和AVAudioInputNode +- **音频播放**: 使用AVAudioPlayerNode进行播放 +- **WebSocket**: 使用URLSessionWebSocketTask +- **线程管理**: 使用DispatchQueue管理录音和播放线程 + +### 核心组件 +- `RealtimePlugin`: 主插件入口,处理Flutter方法调用 +- `RealtimeAudioManager`: 音频管理器,负责录音和播放 +- `RealtimeWebSocketManager`: WebSocket管理器,负责网络通信 + +**Android端组件**: +- AudioRecord + AudioTrack + OkHttp + Kotlin协程 +- 对应您提供的Android原生示例功能 + +**iOS端组件**: +- AVAudioEngine + AVAudioPlayerNode + URLSessionWebSocketTask + DispatchQueue + +## 使用方法 + +```dart +import 'package:realtime/realtime.dart'; + +final realtimeService = RealtimeService(); + +// 初始化 +await realtimeService.initialize( + serverUrl: 'wss://your-server/ws', + sampleRate: 16000, + channels: 1, + bitsPerSample: 16, +); + +// 监听事件 +realtimeService.eventStream.listen((event) { + switch (event.type) { + case 'connectionStatusChanged': + // 处理连接状态变化 + break; + case 'voiceStatusChanged': + // 处理语音状态变化 + break; + case 'textReceived': + // 处理收到的文本消息 + break; + case 'error': + // 处理错误 + break; + } +}); + +// 连接服务器 +await realtimeService.connect(); + +// 开始录音 +await realtimeService.startRecording(); + +// 停止录音 +await realtimeService.stopRecording(); + +// 断开连接 +await realtimeService.disconnect(); + +// 释放资源 +await realtimeService.dispose(); +``` + +## 音频格式 + +- **采样率**: 16000 Hz +- **声道数**: 1(单声道) +- **位深**: 16-bit +- **帧长**: 20ms (320字节) + +## 权限要求 + +### Android +权限已自动包含在插件中: +- `RECORD_AUDIO`: 音频录制权限 +- `INTERNET`: 网络访问权限 +- `ACCESS_NETWORK_STATE`: 网络状态权限 + +### iOS +在Info.plist中添加麦克风权限: +```xml +NSMicrophoneUsageDescription +应用需要麦克风权限进行语音录制 +``` + +## 注意事项 + +1. 确保服务器地址正确且可访问 +2. 网络环境良好,避免频繁断线 +3. 音频格式与服务器保持一致 +4. 及时释放资源,避免内存泄漏 + +## 错误处理 + +插件会自动处理常见错误: +- 网络连接失败 +- 音频设备不可用 +- 权限被拒绝 +- 服务器断开连接 + +通过事件流可以监听这些错误并进行相应处理。 \ No newline at end of file diff --git a/local_plugins/realtime/android/build.gradle.kts b/local_plugins/realtime/android/build.gradle.kts new file mode 100644 index 000000000..b787e4124 --- /dev/null +++ b/local_plugins/realtime/android/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + kotlin("plugin.serialization") version "1.9.24" +} + +android { + namespace = "com.yunqiinnovation.realtime" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + targetSdk = 33 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // OkHttp for WebSocket + implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0")) + implementation("com.squareup.okhttp3:okhttp") +} \ No newline at end of file diff --git a/local_plugins/realtime/android/settings.gradle.kts b/local_plugins/realtime/android/settings.gradle.kts new file mode 100644 index 000000000..dd4a80ad2 --- /dev/null +++ b/local_plugins/realtime/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "realtime" \ No newline at end of file diff --git a/local_plugins/realtime/android/src/main/AndroidManifest.xml b/local_plugins/realtime/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..64df6a0ae --- /dev/null +++ b/local_plugins/realtime/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..711a85779 --- /dev/null +++ b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeAudioManager.kt @@ -0,0 +1,519 @@ +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 + + +/** + * 实时音频管理器 + * + * 负责音频的录制和播放,对应Android示例中的AudioRecord和AudioTrack功能 + */ +class RealtimeAudioManager(private val context: Context) { + companion object { + private const val TAG = "RealtimeAudioManager" + } + + // 音频配置 + private var audioConfig: AudioConfig? = null + + // 录音相关 + private var audioRecord: AudioRecord? = null + private var isRecording = false + private var recordJob: Job? = null + + // 播放相关 + private var audioTrack: AudioTrack? = null + 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()) + + /// 初始化音频管理器 + 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) + } + + /// 设置AudioRecord(录音) + private fun setupAudioRecord(config: AudioConfig): Boolean { + try { + val channelConfig = if (config.channels == 1) { + AudioFormat.CHANNEL_IN_MONO + } else { + AudioFormat.CHANNEL_IN_STEREO + } + + val audioFormat = when (config.bitsPerSample) { + 16 -> AudioFormat.ENCODING_PCM_16BIT + 8 -> AudioFormat.ENCODING_PCM_8BIT + else -> AudioFormat.ENCODING_PCM_16BIT + } + + val bufferSize = AudioRecord.getMinBufferSize( + config.sampleRate, + channelConfig, + audioFormat + ) + + if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { + Log.e(TAG, "无法获取音频录制缓冲区大小") + return false + } + + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + config.sampleRate, + channelConfig, + audioFormat, + bufferSize + ) + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "AudioRecord初始化失败") + return false + } + + Log.i(TAG, "AudioRecord初始化成功,采样率: ${config.sampleRate}, 声道: ${config.channels}, 位深: ${config.bitsPerSample}") + return true + } catch (e: Exception) { + Log.e(TAG, "设置AudioRecord失败: ${e.message}", e) + return false + } + } + + /// 设置AudioTrack(播放) + private fun setupAudioTrack(config: AudioConfig): Boolean { + try { + val channelConfig = if (config.channels == 1) { + AudioFormat.CHANNEL_OUT_MONO + } else { + AudioFormat.CHANNEL_OUT_STEREO + } + + val audioFormat = when (config.bitsPerSample) { + 16 -> AudioFormat.ENCODING_PCM_16BIT + 8 -> AudioFormat.ENCODING_PCM_8BIT + else -> AudioFormat.ENCODING_PCM_16BIT + } + + val bufferSize = AudioTrack.getMinBufferSize( + config.sampleRate, + channelConfig, + audioFormat + ) + + if (bufferSize == AudioTrack.ERROR || bufferSize == AudioTrack.ERROR_BAD_VALUE) { + Log.e(TAG, "无法获取音频播放缓冲区大小") + return false + } + + // 总是使用VOICE_COMMUNICATION模式以获得最佳语音处理效果 + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + + audioTrack = AudioTrack.Builder() + .setAudioAttributes(audioAttributes) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(config.sampleRate) + .setChannelMask(channelConfig) + .setEncoding(audioFormat) + .build() + ) + .setBufferSizeInBytes(bufferSize) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + + if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) { + Log.e(TAG, "AudioTrack初始化失败") + return false + } + + Log.i(TAG, "AudioTrack初始化成功, 启用语音通信模式") + return true + } catch (e: Exception) { + Log.e(TAG, "设置AudioTrack失败: ${e.message}", e) + return false + } + } + + /// 开始录音 + fun startRecording(): Boolean { + if (isRecording || audioRecord == null) { + return false + } + + try { + // 设置回音消除(总是启用) + setupEchoCancellation() + + audioRecord?.startRecording() + isRecording = true + onStatusChanged?.invoke(VoiceStatus.RECORDING) + + // 启动录音协程 + recordJob = scope.launch { + loopRecordSend() + } + + Log.i(TAG, "开始录音, 回音消除: ${acousticEchoCanceler?.enabled}") + return true + } catch (e: Exception) { + Log.e(TAG, "开始录音失败: ${e.message}", e) + return false + } + } + + /// 录音循环(对应Android示例中的loopRecordSend) + private suspend fun loopRecordSend() { + val config = audioConfig ?: return + val frameSize = config.frameSize + val buffer = ByteArray(frameSize) + + while (isRecording && audioRecord != null) { + try { + val bytesRead = audioRecord!!.read(buffer, 0, frameSize) + + if (bytesRead > 0) { + // 确保读取的数据长度正确 + val audioData = if (bytesRead == frameSize) { + buffer + } else { + buffer.copyOf(bytesRead) + } + + // 计算RMS值 + val rms = calculateRMS(audioData) + onRmsChanged?.invoke(rms) + + // 直接回调音频数据到Flutter层 + onAudioData?.invoke(audioData) + } else { + Log.w(TAG, "录音读取数据失败: $bytesRead") + } + } catch (e: Exception) { + Log.e(TAG, "录音循环异常: ${e.message}", e) + break + } + } + } + + /// 停止录音 + fun stopRecording(): Boolean { + if (!isRecording) { + return false + } + + try { + isRecording = false + recordJob?.cancel() + audioRecord?.stop() + + // 清理回音消除 + cleanupEchoCancellation() + + onStatusChanged?.invoke(VoiceStatus.IDLE) + Log.i(TAG, "录音已停止") + return true + } catch (e: Exception) { + Log.e(TAG, "停止录音失败: ${e.message}", e) + return false + } + } + + /// 播放音频数据 + fun playAudioData(data: ByteArray) { + if (data.isNotEmpty()) { + // 计算播放数据的RMS值 + val rms = calculateRMS(data) + onRmsChanged?.invoke(rms) + + playbackQueue.offer(data) + if (!isPlaying) { + startPlayback() + } + } + } + + /// 开始播放 + private fun startPlayback() { + if (isPlaying || audioTrack == null) { + return + } + + try { + isPlaying = true + onStatusChanged?.invoke(VoiceStatus.PLAYING) + audioTrack?.play() + + playJob = scope.launch { + loopPlayback() + } + Log.i(TAG, "开始播放") + } catch (e: Exception) { + Log.e(TAG, "开始播放失败: ${e.message}", e) + } + } + + /// 播放循环 + private suspend fun loopPlayback() { + Log.d(TAG, "启动播放循环") + + while (isPlaying) { + try { + // take() 会一直等待直到队列中有数据 + val data = playbackQueue.take() + + + // 直接播放整个数据块,让AudioTrack自己处理时序 + audioTrack?.write(data, 0, data.size) + + } catch (e: InterruptedException) { + Log.d(TAG, "播放循环被中断") + break + } catch (e: Exception) { + Log.e(TAG, "播放循环异常: ${e.message}", e) + } + } + Log.d(TAG, "播放循环结束") + stopPlaybackInternal() + } + + /// 内部停止播放方法 + 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, "播放已通过内部调用停止") + } + + /// 停止播放(外部调用) + fun stopPlaying(): Boolean { + if (!isPlaying) { + return true + } + try { + isPlaying = false + playJob?.cancel() // 取消协程 + playJob = null + stopPlaybackInternal() + + // 恢复音频模式 + restoreAudioMode() + + return true + } catch (e: Exception) { + Log.e(TAG, "停止播放失败: ${e.message}", e) + return false + } + } + + /// 更新配置 + fun updateConfig(config: AudioConfig): Boolean { + // 如果正在录音或播放,先停止 + if (isRecording) { + stopRecording() + } + if (isPlaying) { + stopPlaying() + } + + // 释放旧的音频对象 + releaseAudioObjects() + + return initialize(config) + } + + /// 释放音频对象 + private fun releaseAudioObjects() { + try { + audioRecord?.release() + audioRecord = null + + audioTrack?.release() + audioTrack = null + } catch (e: Exception) { + Log.e(TAG, "释放音频对象失败: ${e.message}", e) + } + } + + /// 释放资源 + fun dispose() { + stopRecording() + stopPlaying() + + // 清理回音消除和恢复音频模式 + cleanupEchoCancellation() + restoreAudioMode() + + scope.cancel() + releaseAudioObjects() + playbackQueue.clear() + + 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 new file mode 100644 index 000000000..b8077c2cf --- /dev/null +++ b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimePlugin.kt @@ -0,0 +1,455 @@ +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 +import io.flutter.plugin.common.MethodCall +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 + +/** + * 实时语音聊天插件 + * + * 通过WebSocket连接Vocode服务器实现实时语音交互 + * 包含录音、播放、WebSocket通信等功能 + */ +class RealtimePlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { + companion object { + private const val TAG = "RealtimePlugin" + private const val METHOD_CHANNEL = "realtime/methods" + private const val EVENT_CHANNEL = "realtime/events" + } + + private lateinit var context: Context + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var eventSink: EventChannel.EventSink? = null + + // 核心组件:设为可空,因为它们的生命周期与页面会话绑定 + private var audioManager: RealtimeAudioManager? = null + private var webSocketManager: RealtimeWebSocketManager? = null + + // 协程作用域:需要可变,因为在重用插件时需要重新创建 + private var scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + // 主线程Handler,用于向Flutter发送事件与回调 + private val mainHandler = Handler(Looper.getMainLooper()) + + // 配置参数 + private var serverUrl: String = "" + private var sampleRate: Int = 16000 + private var channels: Int = 1 + private var bitsPerSample: Int = 16 + + // 状态 + @Volatile + private var connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED + @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 + + methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(this) + + Log.i(TAG, "Realtime插件已附加到引擎") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + Log.i(TAG, "插件正在从引擎分离,执行清理...") + performCleanup() + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + Log.i(TAG, "Realtime插件已从引擎分离") + } + + /// 设置各组件的回调 + private fun setupCallbacks() { + Log.d(TAG, "setupCallbacks: 正在为新的管理器实例设置回调...") + + // 音频管理器回调 + audioManager?.onAudioData = { data -> + // 发送到WebSocket + webSocketManager?.sendAudioData(data) + // 发送录音PCM数据到Flutter用于可视化 + val pcm16Data = convertByteArrayToPCM16(data) + sendEvent("recordingPcmData", pcm16Data) + } + + audioManager?.onStatusChanged = { status -> + voiceStatus = status + sendEvent("voiceStatusChanged", status.value) + } + + // 新增RMS回调 + audioManager?.onRmsChanged = { rms -> + sendEvent("audioRms", rms) + } + + // WebSocket管理器回调 + webSocketManager?.onConnectionStatusChanged = { status -> + connectionStatus = status + sendEvent("connectionStatusChanged", status.value) + } + + 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 -> + // 直接发送原始JSON文本到Flutter层处理 + sendEvent("textReceived", text) + } + + 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 + ) + + try { + mainHandler.post { + try { + sink.success(event) + } catch (e: Exception) { + Log.w(TAG, "发送事件失败: \\${e.message}") + } + } + } catch (e: Exception) { + Log.w(TAG, "发送事件时出现异常: \\${e.message}") + } + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "initialize" -> handleInitialize(call, result) + "connect" -> handleConnect(result) + "disconnect" -> handleDisconnect(result) + "startRecording" -> handleStartRecording(result) + "stopRecording" -> handleStopRecording(result) + "stopPlaying" -> handleStopPlaying(result) + "getConnectionStatus" -> result.success(connectionStatus.value) + "getVoiceStatus" -> result.success(voiceStatus.value) + "sendTextMessage" -> handleSendTextMessage(call, result) + "setAudioConfig" -> handleSetAudioConfig(call, result) + "dispose" -> handleDispose(result) + else -> result.notImplemented() + } + } + + 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 + + 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) ?: false + + if (success) { + webSocketManager?.initialize(serverUrl) + Log.i(TAG, "实时语音插件初始化成功") + } else { + Log.e(TAG, "实时语音插件初始化失败") + } + + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "初始化失败: ${e.message}", e) + result.error("INITIALIZATION_ERROR", "初始化失败: ${e.message}", null) + } + } + + private fun handleConnect(result: Result) { + Log.d(TAG, "handleConnect: 收到连接请求。scope是否活跃? ${scope.isActive}") + scope.launch { + try { + val success = webSocketManager?.connect() ?: false + mainHandler.post { result.success(success) } + } catch (e: Exception) { + Log.e(TAG, "连接失败: ${e.message}", e) + mainHandler.post { result.error("CONNECTION_ERROR", "连接失败: ${e.message}", null) } + } + } + } + + private fun handleDisconnect(result: Result) { + scope.launch { + try { + webSocketManager?.disconnect() + mainHandler.post { result.success(true) } + } catch (e: Exception) { + Log.e(TAG, "断开连接失败: ${e.message}", e) + mainHandler.post { result.error("DISCONNECTION_ERROR", "断开连接失败: ${e.message}", null) } + } + } + } + + private fun handleStartRecording(result: Result) { + scope.launch { + try { + val success = audioManager?.startRecording() ?: false + mainHandler.post { result.success(success) } + } catch (e: Exception) { + Log.e(TAG, "开始录音失败: ${e.message}", e) + mainHandler.post { result.error("RECORDING_ERROR", "开始录音失败: ${e.message}", null) } + } + } + } + + private fun handleStopRecording(result: Result) { + scope.launch { + try { + val success = audioManager?.stopRecording() ?: false + mainHandler.post { result.success(success) } + } catch (e: Exception) { + Log.e(TAG, "停止录音失败: ${e.message}", e) + mainHandler.post { result.error("RECORDING_ERROR", "停止录音失败: ${e.message}", null) } + } + } + } + + private fun handleStopPlaying(result: Result) { + scope.launch { + try { + val success = audioManager?.stopPlaying() ?: false + mainHandler.post { result.success(success) } + } catch (e: Exception) { + Log.e(TAG, "停止播放失败: ${e.message}", e) + mainHandler.post { result.error("PLAYBACK_ERROR", "停止播放失败: ${e.message}", null) } + } + } + } + + private fun handleSendTextMessage(call: MethodCall, result: Result) { + try { + val args = call.arguments as? Map + val message = args?.get("message") as? String + ?: return result.error("INVALID_ARGUMENTS", "消息内容不能为空", null) + + val success = webSocketManager?.sendTextMessage(message) ?: false + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "发送文本消息失败: ${e.message}", e) + result.error("MESSAGE_ERROR", "发送文本消息失败: ${e.message}", null) + } + } + + private fun handleSetAudioConfig(call: MethodCall, result: Result) { + try { + val args = call.arguments as? Map + ?: return result.error("INVALID_ARGUMENTS", "参数无效", null) + + 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) ?: false + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "设置音频配置失败: ${e.message}", e) + result.error("CONFIG_ERROR", "设置音频配置失败: ${e.message}", null) + } + } + + private fun handleDispose(result: Result) { + 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, "核心资源清理完成。") + } + } + } + + // EventChannel.StreamHandler 实现 + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + Log.i(TAG, "事件流监听已开始") + } + + override fun onCancel(arguments: Any?) { + eventSink = null + Log.i(TAG, "事件流监听已取消") + } +} + +// 枚举定义 +enum class ConnectionStatus(val value: String) { + DISCONNECTED("disconnected"), + CONNECTING("connecting"), + CONNECTED("connected"), + ERROR("error") +} + +enum class VoiceStatus(val value: String) { + IDLE("idle"), + RECORDING("recording"), + PROCESSING("processing"), + PLAYING("playing") +} + +// 音频配置 +data class AudioConfig( + val sampleRate: Int, + val channels: Int, + val bitsPerSample: Int +) { + val frameSize: Int + get() = (sampleRate * channels * bitsPerSample / 8 * 20) / 1000 // 20ms帧 +} \ No newline at end of file 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 new file mode 100644 index 000000000..2726ceef7 --- /dev/null +++ b/local_plugins/realtime/android/src/main/kotlin/com/yunqiinnovation/realtime/RealtimeWebSocketManager.kt @@ -0,0 +1,303 @@ +package com.yunqiinnovation.realtime + +import android.util.Log +import kotlinx.coroutines.* +import okhttp3.* +import okio.ByteString +import java.util.concurrent.TimeUnit + +/** + * 实时WebSocket管理器 + * + * 负责与Vocode服务器的WebSocket通信,对应Android示例中的OkHttp WebSocket功能 + */ +class RealtimeWebSocketManager : WebSocketListener() { + companion object { + private const val TAG = "RealtimeWebSocketManager" + private const val CONNECT_TIMEOUT = 30L + private const val READ_TIMEOUT = 60L + private const val WRITE_TIMEOUT = 30L + } + + // OkHttp客户端和WebSocket + private var okHttpClient: OkHttpClient? = null + private var webSocket: WebSocket? = null + + // 服务器URL + private var serverUrl: String = "" + + // 连接状态 + @Volatile + private var isConnected = false + @Volatile + private var isConnecting = false + + // 回调 + var onConnectionStatusChanged: ((ConnectionStatus) -> Unit)? = null + var onAudioReceived: ((ByteArray) -> Unit)? = null + var onTextReceived: ((String) -> Unit)? = null + var onError: ((String) -> Unit)? = null + + // 协程作用域 + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /// 初始化WebSocket管理器 + fun initialize(serverUrl: String) { + Log.d(TAG, "initialize: 开始初始化。当前httpClient是否为null? ${okHttpClient == null}") + this.serverUrl = serverUrl + + // 创建OkHttp客户端 + okHttpClient = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) + .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + 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, "connect: 连接失败,因为OkHttp客户端为null。请确保已调用initialize()。") + onError?.invoke("客户端未初始化") + return@withContext false + } + + Log.d(TAG, "connect: OkHttpClient实例存在,准备发起新连接。") + + try { + isConnecting = true + onConnectionStatusChanged?.invoke(ConnectionStatus.CONNECTING) + + val request = Request.Builder() + .url(serverUrl) + .build() + + Log.d(TAG, "connect: 正在创建新的WebSocket实例...") + webSocket = client.newWebSocket(request, this@RealtimeWebSocketManager) + + // 等待连接建立 + var waitTime = 0 + while (isConnecting && waitTime < CONNECT_TIMEOUT * 1000) { + delay(100) + waitTime += 100 + } + + if (isConnected) { + Log.i(TAG, "connect: WebSocket连接成功") + return@withContext true + } else { + Log.e(TAG, "connect: WebSocket连接超时") + isConnecting = false + onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) + return@withContext false + } + } catch (e: Exception) { + Log.e(TAG, "WebSocket连接失败: ${e.message}", e) + isConnecting = false + onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) + onError?.invoke("连接失败: ${e.message}") + return@withContext false + } + } + + /// 断开连接 + fun disconnect() { + try { + isConnected = false + isConnecting = false + + webSocket?.close(1000, "正常关闭") + webSocket = null + + onConnectionStatusChanged?.invoke(ConnectionStatus.DISCONNECTED) + Log.i(TAG, "WebSocket已断开连接") + } catch (e: Exception) { + Log.e(TAG, "断开WebSocket连接失败: ${e.message}", e) + } + } + + /// 发送音频数据 + fun sendAudioData(data: ByteArray) { + if (!isConnected) { + return + } + + try { + // 将音频数据编码为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, "发送音频数据失败") + } + } catch (e: Exception) { + Log.e(TAG, "发送音频数据异常: ${e.message}", e) + } + } + + /// 发送文本消息 + fun sendTextMessage(message: String): Boolean { + if (!isConnected) { + Log.w(TAG, "WebSocket未连接,无法发送文本消息") + return false + } + + return try { + val success = webSocket?.send(message) ?: false + if (!success) { + Log.w(TAG, "发送文本消息失败") + } + success + } catch (e: Exception) { + Log.e(TAG, "发送文本消息异常: ${e.message}", e) + false + } + } + + /// 释放资源 + fun dispose() { + disconnect() + + // 立即取消scope,不等待 + try { + scope.cancel() + } catch (e: Exception) { + Log.w(TAG, "取消scope时出现异常: ${e.message}") + } + + // 立即强制关闭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管理器已释放") + } + + // WebSocketListener 回调实现 + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.i(TAG, "WebSocket连接已建立") + isConnected = true + isConnecting = false + onConnectionStatusChanged?.invoke(ConnectionStatus.CONNECTED) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + // 添加收到文本消息的日志输出,显示消息类型和前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) { + val audioData = bytes.toByteArray() + onAudioReceived?.invoke(audioData) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.i(TAG, "WebSocket正在关闭,代码: $code, 原因: $reason") + webSocket.close(1000, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.i(TAG, "WebSocket已关闭,代码: $code, 原因: $reason") + isConnected = false + isConnecting = false + onConnectionStatusChanged?.invoke(ConnectionStatus.DISCONNECTED) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + val errorMessage = when { + t.message?.contains("Unable to parse TLS packet header") == true -> + "SSL错误:请检查服务器是否支持wss://,本地开发请使用ws://" + t.message?.contains("Connection refused") == true -> + "连接被拒绝:请检查服务器是否运行在指定端口" + t.message?.contains("timeout") == true -> + "连接超时:请检查网络连接和服务器地址" + else -> "连接失败: ${t.message}" + } + + Log.e(TAG, "WebSocket连接失败: ${t.message}", t) + isConnected = false + isConnecting = false + onConnectionStatusChanged?.invoke(ConnectionStatus.ERROR) + onError?.invoke(errorMessage) + } +} \ No newline at end of file diff --git a/local_plugins/realtime/example.md b/local_plugins/realtime/example.md new file mode 100644 index 000000000..74b8ebd44 --- /dev/null +++ b/local_plugins/realtime/example.md @@ -0,0 +1,274 @@ +# Realtime Plugin 使用示例 + +## 基本用法 + +```dart +import 'package:realtime/realtime.dart'; + +class VoiceCallPage extends StatefulWidget { + @override + _VoiceCallPageState createState() => _VoiceCallPageState(); +} + +class _VoiceCallPageState extends State { + final RealtimeService _realtimeService = RealtimeService(); + StreamSubscription? _eventSubscription; + + ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; + VoiceStatus _voiceStatus = VoiceStatus.idle; + + @override + void initState() { + super.initState(); + _setupEventListener(); + _initializeService(); + } + + void _setupEventListener() { + _eventSubscription = _realtimeService.eventStream.listen((event) { + switch (event.type) { + case 'connectionStatusChanged': + setState(() { + _connectionStatus = _parseConnectionStatus(event.data); + }); + break; + case 'voiceStatusChanged': + setState(() { + _voiceStatus = _parseVoiceStatus(event.data); + }); + break; + case 'textReceived': + print('收到文本: ${event.data}'); + break; + case 'error': + print('错误: ${event.data}'); + break; + } + }); + } + + Future _initializeService() async { + try { + final success = await _realtimeService.initialize( + serverUrl: 'wss://your-vocode-server.com/ws', + sampleRate: 16000, + channels: 1, + bitsPerSample: 16, + ); + + if (success) { + print('Realtime服务初始化成功'); + } else { + print('Realtime服务初始化失败'); + } + } catch (e) { + print('初始化错误: $e'); + } + } + + Future _connect() async { + try { + final success = await _realtimeService.connect(); + if (!success) { + print('连接失败'); + } + } catch (e) { + print('连接错误: $e'); + } + } + + Future _disconnect() async { + try { + await _realtimeService.disconnect(); + } catch (e) { + print('断开连接错误: $e'); + } + } + + Future _startRecording() async { + try { + final success = await _realtimeService.startRecording(); + if (!success) { + print('开始录音失败'); + } + } catch (e) { + print('录音错误: $e'); + } + } + + Future _stopRecording() async { + try { + final success = await _realtimeService.stopRecording(); + if (!success) { + print('停止录音失败'); + } + } catch (e) { + print('停止录音错误: $e'); + } + } + + ConnectionStatus _parseConnectionStatus(String status) { + switch (status) { + case 'connected': return ConnectionStatus.connected; + case 'connecting': return ConnectionStatus.connecting; + case 'disconnected': return ConnectionStatus.disconnected; + case 'error': return ConnectionStatus.error; + default: return ConnectionStatus.disconnected; + } + } + + VoiceStatus _parseVoiceStatus(String status) { + switch (status) { + case 'recording': return VoiceStatus.recording; + case 'processing': return VoiceStatus.processing; + case 'playing': return VoiceStatus.playing; + case 'idle': return VoiceStatus.idle; + default: return VoiceStatus.idle; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('实时语音聊天'), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 连接状态显示 + Text('连接状态: ${_connectionStatus.name}'), + SizedBox(height: 20), + + // 语音状态显示 + Text('语音状态: ${_voiceStatus.name}'), + SizedBox(height: 40), + + // 连接按钮 + ElevatedButton( + onPressed: _connectionStatus == ConnectionStatus.disconnected + ? _connect + : _disconnect, + child: Text(_connectionStatus == ConnectionStatus.disconnected + ? '连接' + : '断开'), + ), + SizedBox(height: 20), + + // 录音按钮 + ElevatedButton( + onPressed: _connectionStatus == ConnectionStatus.connected + ? (_voiceStatus == VoiceStatus.recording + ? _stopRecording + : _startRecording) + : null, + child: Text(_voiceStatus == VoiceStatus.recording + ? '停止录音' + : '开始录音'), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _eventSubscription?.cancel(); + _realtimeService.dispose(); + super.dispose(); + } +} +``` + +## GetX控制器中的使用 + +```dart +import 'package:get/get.dart'; +import 'package:realtime/realtime.dart'; + +class VoiceController extends GetxController { + final RealtimeService _realtimeService = RealtimeService(); + + final RxBool isConnected = false.obs; + final RxBool isRecording = false.obs; + + StreamSubscription? _eventSubscription; + + @override + void onInit() { + super.onInit(); + _setupEventListener(); + _initializeService(); + } + + void _setupEventListener() { + _eventSubscription = _realtimeService.eventStream.listen((event) { + switch (event.type) { + case 'connectionStatusChanged': + isConnected.value = event.data == 'connected'; + break; + case 'voiceStatusChanged': + isRecording.value = event.data == 'recording'; + break; + } + }); + } + + Future _initializeService() async { + await _realtimeService.initialize( + serverUrl: 'wss://your-server/ws', + ); + } + + Future toggleConnection() async { + if (isConnected.value) { + await _realtimeService.disconnect(); + } else { + await _realtimeService.connect(); + } + } + + Future toggleRecording() async { + if (isRecording.value) { + await _realtimeService.stopRecording(); + } else { + await _realtimeService.startRecording(); + } + } + + @override + void onClose() { + _eventSubscription?.cancel(); + _realtimeService.dispose(); + super.onClose(); + } +} +``` + +## 权限配置 + +### iOS +在 `ios/Runner/Info.plist` 中添加: + +```xml +NSMicrophoneUsageDescription +应用需要麦克风权限进行语音录制 +``` + +## 服务器配置示例 + +需要一个支持WebSocket的Vocode服务器,具体实现可参考Vocode官方文档。 + +服务器需要: +1. 接收16kHz/16-bit/单声道的PCM音频数据 +2. 返回相同格式的音频数据 +3. 支持文本消息交换 + +## 注意事项 + +1. 确保网络连接稳定 +2. 音频格式必须与服务器保持一致 +3. 及时处理事件流中的错误 +4. 在适当时机释放资源 \ No newline at end of file diff --git a/local_plugins/realtime/ios/realtime/Package.swift b/local_plugins/realtime/ios/realtime/Package.swift new file mode 100644 index 000000000..514fbc1e1 --- /dev/null +++ b/local_plugins/realtime/ios/realtime/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "realtime", + platforms: [.iOS("18.0")], + products: [ + .library(name: "realtime", targets: ["realtime"]) + ], + dependencies: [], + targets: [ + .target( + name: "realtime", + dependencies: [], + path: "Sources/realtime" + ) + ] +) \ No newline at end of file diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift new file mode 100644 index 000000000..c2a9d424a --- /dev/null +++ b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift @@ -0,0 +1,344 @@ +import AVFoundation +import os.log +import Accelerate + +/** + * 实时音频管理器 + * + * 负责音频的录制和播放,对应Android示例中的AudioRecord和AudioTrack功能 + */ +class RealtimeAudioManager { + + private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimeAudioManager") + + // 音频配置 + private var audioConfig: AudioConfig? + + // 录音相关 + private var audioEngine: AVAudioEngine? + private var inputNode: AVAudioInputNode? + private var recordingFormat: AVAudioFormat? + private var isRecording = false + + // 播放相关 + private var audioPlayer: AVAudioPlayerNode? + private var playbackFormat: AVAudioFormat? + private var playbackQueue: DispatchQueue + private var audioBufferQueue: [AVAudioPCMBuffer] = [] + private var isPlaying = false + + // 回调 + var onAudioData: ((Data) -> Void)? + var onStatusChanged: ((VoiceStatus) -> Void)? + var onRmsChanged: ((Float) -> Void)? + + // 录音缓冲区 + private var recordingBuffer: AVAudioPCMBuffer? + private var frameSize: Int = 320 // 20ms @ 16kHz + + init() { + playbackQueue = DispatchQueue(label: "com.realtime.playback", qos: .userInitiated) + setupAudioSession() + } + + /// 设置音频会话 + private func setupAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetooth]) + try session.setActive(true) + os_log("音频会话设置成功", log: log, type: .info) + } catch { + os_log("音频会话设置失败: %@", log: log, type: .error, error.localizedDescription) + } + } + + /// 初始化音频管理器 + func initialize(config: AudioConfig) -> Bool { + self.audioConfig = config + self.frameSize = config.frameSize + + guard setupAudioEngine(config: config) else { + os_log("音频引擎初始化失败", log: log, type: .error) + return false + } + + guard setupAudioPlayer(config: config) else { + os_log("音频播放器初始化失败", log: log, type: .error) + return false + } + + os_log("音频管理器初始化成功", log: log, type: .info) + return true + } + + /// 设置音频引擎(录音) + private func setupAudioEngine(config: AudioConfig) -> Bool { + audioEngine = AVAudioEngine() + + guard let audioEngine = audioEngine else { return false } + + inputNode = audioEngine.inputNode + + // 创建录音格式:16kHz, 16-bit, 单声道 + recordingFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: Double(config.sampleRate), + channels: AVAudioChannelCount(config.channels), + interleaved: true) + + guard let recordingFormat = recordingFormat else { + os_log("无法创建录音格式", log: log, type: .error) + return false + } + + // 创建录音缓冲区 + let frameCount = AVAudioFrameCount(config.frameSize / (config.bitsPerSample / 8)) + recordingBuffer = AVAudioPCMBuffer(pcmFormat: recordingFormat, frameCapacity: frameCount) + + return true + } + + /// 设置音频播放器 + private func setupAudioPlayer(config: AudioConfig) -> Bool { + guard let audioEngine = audioEngine else { return false } + + audioPlayer = AVAudioPlayerNode() + + // 创建播放格式:16kHz, 16-bit, 单声道 + playbackFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: Double(config.sampleRate), + channels: AVAudioChannelCount(config.channels), + interleaved: true) + + guard let audioPlayer = audioPlayer, + let playbackFormat = playbackFormat else { + os_log("无法创建播放格式", log: log, type: .error) + return false + } + + // 连接播放器到音频引擎 + audioEngine.attach(audioPlayer) + audioEngine.connect(audioPlayer, to: audioEngine.outputNode, format: playbackFormat) + + return true + } + + /// 开始录音 + func startRecording() -> Bool { + guard let audioEngine = audioEngine, + let inputNode = inputNode, + let recordingFormat = recordingFormat, + !isRecording else { + return false + } + + // 安装录音tap + inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(frameSize / 2), format: recordingFormat) { [weak self] buffer, time in + self?.processRecordedAudio(buffer: buffer) + } + + do { + try audioEngine.start() + isRecording = true + onStatusChanged?(.recording) + os_log("开始录音", log: log, type: .info) + return true + } catch { + os_log("开始录音失败: %@", log: log, type: .error, error.localizedDescription) + return false + } + } + + /// 处理录制的音频数据 + private func processRecordedAudio(buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.int16ChannelData?[0], + buffer.frameLength > 0 else { + return + } + + // 确保数据长度匹配frameSize + let dataLength = min(Int(buffer.frameLength) * 2, frameSize) // 2 bytes per sample + + // 转换为Data + let data = Data(bytes: channelData, count: dataLength) + + // 计算RMS值 + let rms = calculateRMS(buffer: buffer) + onRmsChanged?(rms) + + // 回调音频数据 + onAudioData?(data) + } + + /// 停止录音 + func stopRecording() -> Bool { + guard let audioEngine = audioEngine, + let inputNode = inputNode, + isRecording else { + return false + } + + inputNode.removeTap(onBus: 0) + audioEngine.stop() + isRecording = false + onStatusChanged?(.idle) + os_log("停止录音", log: log, type: .info) + return true + } + + /// 播放音频数据 + func playAudioData(_ data: Data) { + guard let playbackFormat = playbackFormat, + let audioPlayer = audioPlayer else { + return + } + + playbackQueue.async { [weak self] in + self?.enqueueAudioData(data, format: playbackFormat, player: audioPlayer) + } + } + + /// 将音频数据加入播放队列 + private func enqueueAudioData(_ data: Data, format: AVAudioFormat, player: AVAudioPlayerNode) { + // 创建PCM缓冲区 + let frameCount = AVAudioFrameCount(data.count / 2) // 16-bit = 2 bytes per sample + + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { + os_log("无法创建播放缓冲区", log: log, type: .error) + return + } + + buffer.frameLength = frameCount + + // 复制数据到缓冲区 + guard let channelData = buffer.int16ChannelData?[0] else { + return + } + + data.withUnsafeBytes { bytes in + let int16Pointer = bytes.bindMemory(to: Int16.self) + 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) + + // 如果没在播放,开始播放 + if !isPlaying { + startPlayback() + } + } + + /// 开始播放 + private func startPlayback() { + guard let audioEngine = audioEngine, + let audioPlayer = audioPlayer, + !audioBufferQueue.isEmpty else { + return + } + + if !audioEngine.isRunning { + do { + try audioEngine.start() + } catch { + os_log("启动音频引擎失败: %@", log: log, type: .error, error.localizedDescription) + return + } + } + + if !audioPlayer.isPlaying { + audioPlayer.play() + } + + isPlaying = true + onStatusChanged?(.playing) + + // 播放队列中的音频 + playNextBuffer() + } + + /// 播放下一个缓冲区 + private func playNextBuffer() { + guard let audioPlayer = audioPlayer, + !audioBufferQueue.isEmpty else { + isPlaying = false + onStatusChanged?(.idle) + return + } + + let buffer = audioBufferQueue.removeFirst() + + audioPlayer.scheduleBuffer(buffer) { [weak self] in + DispatchQueue.main.async { + self?.playNextBuffer() + } + } + } + + /// 停止播放 + func stopPlaying() -> Bool { + guard let audioPlayer = audioPlayer else { + return false + } + + audioPlayer.stop() + audioBufferQueue.removeAll() + isPlaying = false + onStatusChanged?(.idle) + os_log("停止播放", log: log, type: .info) + return true + } + + /// 更新配置 + func updateConfig(config: AudioConfig) -> Bool { + // 如果正在录音或播放,先停止 + if isRecording { + _ = stopRecording() + } + if isPlaying { + _ = stopPlaying() + } + + return initialize(config: config) + } + + /// 释放资源 + func dispose() { + _ = stopRecording() + _ = stopPlaying() + + audioEngine?.stop() + audioEngine = nil + audioPlayer = nil + audioBufferQueue.removeAll() + + 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 new file mode 100644 index 000000000..23f6720b0 --- /dev/null +++ b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimePlugin.swift @@ -0,0 +1,319 @@ +import Flutter +import UIKit +import AVFoundation +import os.log + +/** + * 实时语音聊天插件 + * + * 通过WebSocket连接Vocode服务器实现实时语音交互 + * 包含录音、播放、WebSocket通信等功能 + */ +@objc public class RealtimePlugin: NSObject, FlutterPlugin { + + private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimePlugin") + + // Flutter通道 + private var methodChannel: FlutterMethodChannel? + private var eventChannel: FlutterEventChannel? + private var eventSink: FlutterEventSink? + + // 核心组件 + private let audioManager = RealtimeAudioManager() + private let websocketManager = RealtimeWebSocketManager() + + // 配置参数 + private var serverUrl: String = "" + private var sampleRate: Int = 16000 + private var channels: Int = 1 + private var bitsPerSample: Int = 16 + + // 状态 + private var connectionStatus: ConnectionStatus = .disconnected + private var voiceStatus: VoiceStatus = .idle + + /// 插件注册 + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = RealtimePlugin() + + // 方法通道 + let methodChannel = FlutterMethodChannel(name: "realtime/methods", binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(instance, channel: methodChannel) + instance.methodChannel = methodChannel + + // 事件通道 + let eventChannel = FlutterEventChannel(name: "realtime/events", binaryMessenger: registrar.messenger()) + eventChannel.setStreamHandler(instance) + instance.eventChannel = eventChannel + + // 设置组件回调 + instance.setupCallbacks() + } + + /// 设置各组件的回调 + 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 + self?.voiceStatus = status + 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 + self?.sendEvent(type: "connectionStatusChanged", data: status.rawValue) + } + + 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) + } + + websocketManager.onError = { [weak self] error in + self?.sendEvent(type: "error", data: error) + } + } + + /// 发送事件到Flutter端 + private func sendEvent(type: String, data: Any?) { + guard let eventSink = eventSink else { return } + + let event: [String: Any] = [ + "type": type, + "data": data ?? NSNull() + ] + + DispatchQueue.main.async { + 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 +extension RealtimePlugin { + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + handleInitialize(call, result) + case "connect": + handleConnect(result) + case "disconnect": + handleDisconnect(result) + case "startRecording": + handleStartRecording(result) + case "stopRecording": + handleStopRecording(result) + case "stopPlaying": + handleStopPlaying(result) + case "getConnectionStatus": + result(connectionStatus.rawValue) + case "getVoiceStatus": + result(voiceStatus.rawValue) + case "sendTextMessage": + handleSendTextMessage(call, result) + case "setAudioConfig": + handleSetAudioConfig(call, result) + case "dispose": + handleDispose(result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func handleInitialize(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let serverUrl = args["serverUrl"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "服务器地址不能为空", details: nil)) + return + } + + self.serverUrl = serverUrl + self.sampleRate = args["sampleRate"] as? Int ?? 16000 + self.channels = args["channels"] as? Int ?? 1 + self.bitsPerSample = args["bitsPerSample"] as? Int ?? 16 + + // 初始化音频管理器 + let audioConfig = AudioConfig( + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample + ) + + let success = audioManager.initialize(config: audioConfig) + if success { + websocketManager.initialize(serverUrl: serverUrl) + os_log("实时语音插件初始化成功", log: log, type: .info) + } else { + os_log("实时语音插件初始化失败", log: log, type: .error) + } + + result(success) + } + + private func handleConnect(_ result: @escaping FlutterResult) { + websocketManager.connect { [weak self] success in + DispatchQueue.main.async { + result(success) + } + } + } + + private func handleDisconnect(_ result: @escaping FlutterResult) { + websocketManager.disconnect() + result(true) + } + + private func handleStartRecording(_ result: @escaping FlutterResult) { + let success = audioManager.startRecording() + result(success) + } + + private func handleStopRecording(_ result: @escaping FlutterResult) { + let success = audioManager.stopRecording() + result(success) + } + + private func handleStopPlaying(_ result: @escaping FlutterResult) { + let success = audioManager.stopPlaying() + result(success) + } + + private func handleSendTextMessage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let message = args["message"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "消息内容不能为空", details: nil)) + return + } + + let success = websocketManager.sendTextMessage(message) + result(success) + } + + private func handleSetAudioConfig(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "参数无效", details: nil)) + return + } + + if let sampleRate = args["sampleRate"] as? Int { + self.sampleRate = sampleRate + } + if let channels = args["channels"] as? Int { + self.channels = channels + } + if let bitsPerSample = args["bitsPerSample"] as? Int { + self.bitsPerSample = bitsPerSample + } + + let audioConfig = AudioConfig( + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample + ) + + let success = audioManager.updateConfig(config: audioConfig) + result(success) + } + + private func handleDispose(_ result: @escaping FlutterResult) { + audioManager.dispose() + websocketManager.dispose() + result(nil) + } +} + +// MARK: - FlutterStreamHandler +extension RealtimePlugin: FlutterStreamHandler { + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } +} + +// MARK: - 枚举定义 + +enum ConnectionStatus: String { + case disconnected = "disconnected" + case connecting = "connecting" + case connected = "connected" + case error = "error" +} + +enum VoiceStatus: String { + case idle = "idle" + case recording = "recording" + case processing = "processing" + case playing = "playing" +} + +// MARK: - 音频配置 + +struct AudioConfig { + let sampleRate: Int + let channels: Int + let bitsPerSample: Int + + var frameSize: Int { + // 20ms的帧大小 + return (sampleRate * channels * bitsPerSample / 8 * 20) / 1000 + } +} \ No newline at end of file diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift new file mode 100644 index 000000000..039101982 --- /dev/null +++ b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeWebSocketManager.swift @@ -0,0 +1,323 @@ +import Foundation +import Network +import os.log + +/** + * 实时WebSocket管理器 + * + * 负责与Vocode服务器的WebSocket通信,对应Android示例中的OkHttp WebSocket功能 + */ +class RealtimeWebSocketManager: NSObject { + + private let log = OSLog(subsystem: "com.yunqiinnovation.realtime", category: "RealtimeWebSocketManager") + + // WebSocket连接 + private var webSocketTask: URLSessionWebSocketTask? + private var urlSession: URLSession? + + // 服务器URL + private var serverUrl: String = "" + + // 连接状态 + private var isConnected = false + private var isConnecting = false + + // 回调 + var onConnectionStatusChanged: ((ConnectionStatus) -> Void)? + var onAudioReceived: ((Data) -> Void)? + var onTextReceived: ((String) -> Void)? + var onError: ((String) -> Void)? + + // 发送队列 + private let sendQueue = DispatchQueue(label: "com.realtime.websocket.send", qos: .userInitiated) + private let receiveQueue = DispatchQueue(label: "com.realtime.websocket.receive", qos: .userInitiated) + + override init() { + super.init() + setupURLSession() + } + + /// 设置URL会话 + private func setupURLSession() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + + /// 初始化WebSocket管理器 + func initialize(serverUrl: String) { + self.serverUrl = serverUrl + os_log("WebSocket管理器初始化,服务器地址: %@", log: log, type: .info, serverUrl) + } + + /// 连接到服务器 + func connect(completion: @escaping (Bool) -> Void) { + guard !isConnected && !isConnecting else { + completion(false) + return + } + + guard let url = URL(string: serverUrl) else { + os_log("无效的服务器地址: %@", log: log, type: .error, serverUrl) + onError?("无效的服务器地址") + completion(false) + return + } + + isConnecting = true + onConnectionStatusChanged?(.connecting) + + // 创建WebSocket任务 + var request = URLRequest(url: url) + request.timeoutInterval = 30 + + webSocketTask = urlSession?.webSocketTask(with: request) + + // 开始监听消息 + startListening() + + // 开始连接 + webSocketTask?.resume() + + // 等待连接建立 + DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.checkConnection(completion: completion) + } + } + + /// 检查连接状态 + private func checkConnection(completion: @escaping (Bool) -> Void) { + guard let webSocketTask = webSocketTask else { + isConnecting = false + onConnectionStatusChanged?(.error) + completion(false) + return + } + + switch webSocketTask.state { + case .running: + isConnected = true + isConnecting = false + onConnectionStatusChanged?(.connected) + os_log("WebSocket连接成功", log: log, type: .info) + completion(true) + case .suspended: + // 等待连接完成 + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.checkConnection(completion: completion) + } + default: + isConnecting = false + onConnectionStatusChanged?(.error) + os_log("WebSocket连接失败", log: log, type: .error) + completion(false) + } + } + + /// 开始监听消息 + private func startListening() { + receiveMessage() + } + + /// 接收消息 + private func receiveMessage() { + webSocketTask?.receive { [weak self] result in + switch result { + case .success(let message): + self?.handleReceivedMessage(message) + // 继续监听下一条消息 + self?.receiveMessage() + case .failure(let error): + self?.handleReceiveError(error) + } + } + } + + /// 处理接收到的消息 + private func handleReceivedMessage(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .data(let data): + // 保留对二进制数据的支持以防万一 + onAudioReceived?(data) + case .string(let 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) + } + } + + /// 处理接收错误 + private func handleReceiveError(_ error: Error) { + os_log("WebSocket接收错误: %@", log: log, type: .error, error.localizedDescription) + + // 如果连接断开,尝试重连 + if isConnected { + isConnected = false + onConnectionStatusChanged?(.error) + onError?("连接断开: \(error.localizedDescription)") + } + } + + /// 发送音频数据 + func sendAudioData(_ data: Data) { + guard isConnected else { return } + + sendQueue.async { [weak self] in + // 将音频数据编码为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) + } + } + } + + /// 发送文本消息 + func sendTextMessage(_ message: String) -> Bool { + guard isConnected else { + os_log("WebSocket未连接,无法发送文本消息", log: log, type: .error) + return false + } + + sendQueue.async { [weak self] in + self?.webSocketTask?.send(.string(message)) { error in + if let error = error { + self?.os_log("发送文本消息失败: %@", log: self?.log ?? OSLog.default, type: .error, error.localizedDescription) + } + } + } + + return true + } + + /// 断开连接 + func disconnect() { + guard isConnected || isConnecting else { return } + + webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask = nil + + isConnected = false + isConnecting = false + onConnectionStatusChanged?(.disconnected) + os_log("WebSocket已断开连接", log: log, type: .info) + } + + /// 释放资源 + func dispose() { + disconnect() + urlSession?.invalidateAndCancel() + urlSession = nil + os_log("WebSocket管理器已释放", log: log, type: .info) + } + + // 修复方法访问性问题 + 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: - URLSessionWebSocketDelegate +extension RealtimeWebSocketManager: URLSessionWebSocketDelegate { + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + os_log("WebSocket连接已建立", log: log, type: .info) + isConnected = true + isConnecting = false + onConnectionStatusChanged?(.connected) + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + os_log("WebSocket连接已关闭,关闭码: %d", log: log, type: .info, closeCode.rawValue) + isConnected = false + isConnecting = false + onConnectionStatusChanged?(.disconnected) + } +} + +// MARK: - URLSessionDelegate +extension RealtimeWebSocketManager: URLSessionDelegate { + + func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + if let error = error { + os_log("URLSession失效: %@", log: log, type: .error, error.localizedDescription) + onError?("会话失效: \(error.localizedDescription)") + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + os_log("URLSessionTask完成时出错: %@", log: log, type: .error, error.localizedDescription) + if isConnected || isConnecting { + isConnected = false + isConnecting = false + onConnectionStatusChanged?(.error) + onError?("连接错误: \(error.localizedDescription)") + } + } + } +} \ No newline at end of file diff --git a/local_plugins/realtime/lib/realtime.dart b/local_plugins/realtime/lib/realtime.dart new file mode 100644 index 000000000..237e9e3e8 --- /dev/null +++ b/local_plugins/realtime/lib/realtime.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; + +/// 实时语音服务异常 +class RealtimeException implements Exception { + final String message; + + RealtimeException(this.message); + + @override + String toString() => 'RealtimeException: $message'; +} + +/// 连接状态 +enum ConnectionStatus { + disconnected, + connecting, + connected, + error, +} + +/// 语音状态 +enum VoiceStatus { + idle, + recording, + processing, + playing, +} + +/// 实时语音事件 +class RealtimeEvent { + final String type; + final dynamic data; + + RealtimeEvent({required this.type, this.data}); + + factory RealtimeEvent.fromMap(Map map) { + return RealtimeEvent( + type: map['type'] as String, + data: map['data'], + ); + } +} + +/// 实时语音聊天服务 +class RealtimeService { + static const MethodChannel _channel = MethodChannel('realtime/methods'); + static const EventChannel _eventChannel = EventChannel('realtime/events'); + + StreamController? _eventStreamController; + Stream? _eventStream; + + /// 获取事件流 + Stream get eventStream { + if (_eventStream == null) { + _eventStreamController = StreamController.broadcast(); + _eventStream = _eventStreamController!.stream; + + // 监听原生事件 + _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is Map) { + final eventMap = Map.from(event); + final realtimeEvent = RealtimeEvent.fromMap(eventMap); + _eventStreamController!.add(realtimeEvent); + } + }, + onError: (error) { + _eventStreamController!.addError(RealtimeException('事件流错误: $error')); + }, + ); + } + + return _eventStream!; + } + + /// 初始化实时语音服务 + /// + /// [serverUrl] WebSocket服务器地址 + /// [sampleRate] 采样率,默认16000 + /// [channels] 声道数,默认1(单声道) + /// [bitsPerSample] 位深,默认16 + Future initialize({ + required String serverUrl, + int sampleRate = 16000, + int channels = 1, + int bitsPerSample = 16, + }) async { + try { + final result = await _channel.invokeMethod( + 'initialize', + { + 'serverUrl': serverUrl, + 'sampleRate': sampleRate, + 'channels': channels, + 'bitsPerSample': bitsPerSample, + }, + ); + + return result ?? false; + } catch (e) { + throw RealtimeException('初始化失败: $e'); + } + } + + /// 连接到服务器 + Future connect() async { + try { + final result = await _channel.invokeMethod('connect'); + return result ?? false; + } catch (e) { + throw RealtimeException('连接失败: $e'); + } + } + + /// 断开连接 + Future disconnect() async { + try { + final result = await _channel.invokeMethod('disconnect'); + return result ?? false; + } catch (e) { + throw RealtimeException('断开连接失败: $e'); + } + } + + /// 开始录音 + Future startRecording() async { + try { + final result = await _channel.invokeMethod('startRecording'); + return result ?? false; + } catch (e) { + throw RealtimeException('开始录音失败: $e'); + } + } + + /// 停止录音 + Future stopRecording() async { + try { + final result = await _channel.invokeMethod('stopRecording'); + return result ?? false; + } catch (e) { + throw RealtimeException('停止录音失败: $e'); + } + } + + /// 停止播放 + Future stopPlaying() async { + try { + final result = await _channel.invokeMethod('stopPlaying'); + return result ?? false; + } catch (e) { + throw RealtimeException('停止播放失败: $e'); + } + } + + /// 获取连接状态 + Future getConnectionStatus() async { + try { + final result = await _channel.invokeMethod('getConnectionStatus'); + switch (result) { + case 'disconnected': + return ConnectionStatus.disconnected; + case 'connecting': + return ConnectionStatus.connecting; + case 'connected': + return ConnectionStatus.connected; + case 'error': + return ConnectionStatus.error; + default: + return ConnectionStatus.disconnected; + } + } catch (e) { + throw RealtimeException('获取连接状态失败: $e'); + } + } + + /// 获取语音状态 + Future getVoiceStatus() async { + try { + final result = await _channel.invokeMethod('getVoiceStatus'); + switch (result) { + case 'idle': + return VoiceStatus.idle; + case 'recording': + return VoiceStatus.recording; + case 'processing': + return VoiceStatus.processing; + case 'playing': + return VoiceStatus.playing; + default: + return VoiceStatus.idle; + } + } catch (e) { + throw RealtimeException('获取语音状态失败: $e'); + } + } + + /// 发送文本消息 + Future sendTextMessage(String message) async { + try { + final result = await _channel.invokeMethod( + 'sendTextMessage', + {'message': message}, + ); + return result ?? false; + } catch (e) { + throw RealtimeException('发送文本消息失败: $e'); + } + } + + /// 设置音频参数 + Future setAudioConfig({ + int? sampleRate, + int? channels, + int? bitsPerSample, + }) async { + try { + final params = {}; + if (sampleRate != null) params['sampleRate'] = sampleRate; + if (channels != null) params['channels'] = channels; + if (bitsPerSample != null) params['bitsPerSample'] = bitsPerSample; + + final result = await _channel.invokeMethod('setAudioConfig', params); + return result ?? false; + } catch (e) { + throw RealtimeException('设置音频参数失败: $e'); + } + } + + /// 释放资源 + Future dispose() async { + try { + await _channel.invokeMethod('dispose'); + _eventStreamController?.close(); + _eventStreamController = null; + _eventStream = null; + } catch (e) { + throw RealtimeException('释放资源失败: $e'); + } + } +} \ No newline at end of file diff --git a/local_plugins/realtime/pubspec.yaml b/local_plugins/realtime/pubspec.yaml new file mode 100644 index 000000000..0eed88a7d --- /dev/null +++ b/local_plugins/realtime/pubspec.yaml @@ -0,0 +1,29 @@ +name: realtime +description: 实时语音聊天插件,通过WebSocket连接Vocode服务器实现语音交互 +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# Flutter插件配置 +flutter: + plugin: + platforms: + android: + package: com.yunqiinnovation.realtime + pluginClass: RealtimePlugin + ios: + pluginClass: RealtimePlugin + swiftPackage: + path: ios/realtime \ No newline at end of file From ac6e55937d2f67c77b5a15be2f86803f00390125 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Mon, 16 Jun 2025 18:05:58 +0100 Subject: [PATCH 5/8] add --- lib/modules/realtime/README.md | 172 ++++--- .../controllers/realtime_controller.dart | 342 +++++++------ lib/modules/realtime/views/realtime_view.dart | 452 +++++++++++------- .../views/widgets/shader_mic_flow.dart | 104 ++++ .../views/widgets/voice_mic_flow.dart | 89 ---- .../azure_speech/AzureAsrHelper.kt | 114 +++-- .../azure_speech/AzureTtsHelper.kt | 44 +- 7 files changed, 755 insertions(+), 562 deletions(-) create mode 100644 lib/modules/realtime/views/widgets/shader_mic_flow.dart delete mode 100644 lib/modules/realtime/views/widgets/voice_mic_flow.dart 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/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..b2680e0c3 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 @@ -64,6 +64,10 @@ 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 @@ -104,6 +108,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 @@ -171,36 +179,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 -> { @@ -651,6 +632,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 +698,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 +786,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() } } 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) From 214449737a0a8a385a194803c371c44dcde4e484 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Tue, 17 Jun 2025 10:25:46 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=BD=95=E9=9F=B3?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/asr_service.dart | 10 +- .../speech_impl/azure_asr_service.dart | 42 ++- .../speech_impl/volcano_asr_api_service.dart | 16 +- .../speech_impl/volcano_asr_service.dart | 16 +- .../speech_impl/xunfei_asr_service.dart | 16 +- .../controllers/meeting_controller.dart | 131 +++++++-- .../meeting_record_controller.dart | 92 +++++-- .../meeting/views/meeting_record_view.dart | 199 +++++++++++++- lib/modules/meeting/views/meeting_view.dart | 18 +- .../controllers/translation_controller.dart | 42 ++- .../azure_speech/AzureAsrHelper.kt | 205 +++----------- .../azure_speech/AzureSpeechPlugin.kt | 41 ++- .../azure_speech/tools/RecordFile.kt | 251 ++++++++++++++++++ 13 files changed, 833 insertions(+), 246 deletions(-) create mode 100644 local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt 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/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..d7891456c 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,12 @@ class MeetingView extends GetView { isDarkMode, icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), - title: '音/视频录音', + title: '现场录音', onTap: () { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord); + Get.toNamed(Routes.meetingRecord, arguments: { + 'audioTypes': 0, + }); // TODO: 实现从文件导入逻辑 }, ), @@ -576,10 +576,12 @@ class MeetingView extends GetView { isDarkMode, icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), - title: '现场录音', + title: '音、视频录音', onTap: () { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord); + Get.toNamed(Routes.meetingRecord, arguments: { + 'audioTypes': 1, + }); // TODO: 实现从相册导入逻辑 }, @@ -604,7 +606,9 @@ class MeetingView extends GetView { title: '通话录音', onTap: () { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord); + Get.toNamed(Routes.meetingRecord, arguments: { + 'audioTypes': 2, + }); // TODO: 实现从其他APP导入逻辑 }, ), 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/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 b2680e0c3..24e9f6e82 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语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 @@ -70,8 +63,8 @@ class AzureAsrHelper(private val context: Context) { private var originalAudioMode = AudioManager.MODE_NORMAL // 录音文件处理 - var recordfile: RecordingFile? = null - private var isRecording = false + var recordfile: RecordFile? = null + /** * 音频来源类型 @@ -153,7 +146,7 @@ class AzureAsrHelper(private val context: Context) { setProperty("Speech_SegmentationStrategy", "Time") } // 录音文件类 - recordfile = RecordingFile() + recordfile = RecordFile; // 创建识别器 val setupSuccess = setupRecognizer() if (setupSuccess) { @@ -360,10 +353,7 @@ class AzureAsrHelper(private val context: Context) { // 开始连续识别 recognizer?.startContinuousRecognitionAsync() isContinuousRecognitionActive = true - Log.e(tag, "开始连续语音识别 是否录音${isRecording}") - if (isRecording) { - enableRecord() - } + return true } catch (e: Exception) { stopAudioProcessing() @@ -510,7 +500,7 @@ class AzureAsrHelper(private val context: Context) { // 停止音频处理 stopAudioProcessing() // 停止录音 - recordfile!!.closeFile() + recordfile!!.closeFile(true) // 释放recognizer recognizer?.close() recognizer = null @@ -522,7 +512,7 @@ class AzureAsrHelper(private val context: Context) { // 释放音频配置 audioConfig?.close() audioConfig = null - isRecording = false + // 确保状态被重置 isContinuousRecognitionActive = false microphoneStream = null @@ -532,7 +522,7 @@ class AzureAsrHelper(private val context: Context) { speechConfig = null } catch (e: Exception) { // 确保状态被重置 - isRecording = false + isContinuousRecognitionActive = false microphoneStream = null externalAudioStream = null @@ -588,41 +578,55 @@ class AzureAsrHelper(private val context: Context) { /** * 开启录音 */ - fun enableRecord() { + fun enableRecord(filePath: String) { Log.i(tag, "开启录音:") -if( recordfile!!.isPause) -{ - recordfile!!.isPause=false -} -else{ - recordfile!!.closeFile() - isRecording = true - if (isContinuousRecognitionActive) { - recordfile!!.creatingFiles() - } -} + recordfile!!.closeFile(true) + + recordfile!!.creatingFiles(filePath) } + /** + * 移动文件到新路径 + */ + fun moveFile(sourcePath: String, destPath: String): Boolean { + Log.i(tag, "移动文件到新路径:") + + recordfile!!.moveFile(sourcePath,destPath) + return true + } + + + /** + * 重命名指定路径的音频文件 + */ + fun renameFile(filePath: String, newName: String): Boolean { + Log.i(tag, "重命名指定路径的音频文件:") + + + recordfile!!.renameFile(filePath,newName) + return true + } + /** * 停止录音 */ fun pauseRecord() { Log.i(tag, "停止连续录音:") - isRecording = false + recordfile!!.isPause=true } /** - * 停止录音 + * 关闭录音 */ - fun stopRecord() { - Log.i(tag, "停止连续录音:") - isRecording = false + fun stopRecord(isSave:Boolean) { + Log.i(tag, "关闭录音:") + recordfile!!.isPause=false - recordfile!!.closeFile() + recordfile!!.closeFile(isSave) } /** @@ -941,133 +945,4 @@ else{ } - inner class RecordingFile { - private var currentAudioFile: File? = null - private var fos: FileOutputStream? = null - - // 新增:用于异步写入的队列和线程 - private val writeQueue = LinkedBlockingQueue() - 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..38346fd28 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 @@ -354,12 +354,36 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { result.success(true) } - "enableRecord" -> { - val fileName = call.argument("fileName")?: "" + "renameFile" -> { + + val filePath = call.argument("filePath")?: "" + val newName = call.argument("newName")?: "" try { - FileLogger.d(tag, "音频文件名称为: ${fileName}") // - azureAsrHelper.recordfile?.fileName= fileName!! - azureAsrHelper.enableRecord() + FileLogger.d(tag, "音频文件名称为: ${filePath}") // + azureAsrHelper.renameFile(filePath,newName) + result.success(true) + } catch (e: Exception) { + result.error("ENABLERECORD_ERROR", e.message, null) + } + } + "moveFile" -> { + val sourcePath = call.argument("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 filePath = call.argument("filePath")?: "" + try { + FileLogger.d(tag, "音频文件名称为: ${filePath}") // + + azureAsrHelper.enableRecord(filePath) result.success(true) } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) @@ -370,8 +394,13 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { result.success(true) } "stopRecord" -> { - azureAsrHelper.stopRecord() + val isSave = call.argument("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/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..e23c6cb34 --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt @@ -0,0 +1,251 @@ +package com.yunqiinnovation.azure_speech.tools +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile + +import android.util.Log +import java.io.FileInputStream +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean + + object RecordFile { + private var currentAudioFile: File? = null + private var fos: FileOutputStream? = null + + // 新增:用于异步写入的队列和线程 + private val writeQueue = LinkedBlockingQueue() + 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 From 9991a64dbc347de77638821cfb20de1bccd61786 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Tue, 17 Jun 2025 11:14:18 +0800 Subject: [PATCH 7/8] add --- lib/modules/meeting/views/meeting_view.dart | 18 ++++++++----- .../main/kotlin/com/example/ota/OtaPlugin.kt | 26 ++++++++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index d7891456c..83414157b 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/lib/modules/meeting/views/meeting_view.dart @@ -562,11 +562,12 @@ class MeetingView extends GetView { icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), title: '现场录音', - onTap: () { + onTap: () async { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord, arguments: { + await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 0, }); + controller.addMeetingAudio(); // TODO: 实现从文件导入逻辑 }, ), @@ -577,12 +578,12 @@ class MeetingView extends GetView { icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), title: '音、视频录音', - onTap: () { + onTap: () async { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord, arguments: { + await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 1, }); - + controller.addMeetingAudio(); // TODO: 实现从相册导入逻辑 }, ), @@ -604,11 +605,14 @@ class MeetingView extends GetView { ], ), title: '通话录音', - onTap: () { + onTap: () async { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord, arguments: { + + await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 2, }); + controller.addMeetingAudio(); + // TODO: 实现从其他APP导入逻辑 }, ), 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..3b34cf9cd 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,7 +268,14 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { streamSink = null } }) - // 初始化 otaManager 和 bluetoothHelper + + + } + + override fun onMethodCall(call: MethodCall, result: Result) { + if (call.method == "init") { + + // 初始化 otaManager 和 bluetoothHelper ConfigHelper.initialize(context) otaManager = OTAManager(context) bluetoothHelper = BluetoothHelper(context) @@ -289,11 +296,9 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { } Log.i("TAG", "onAttachedToEngine") - - } - - override fun onMethodCall(call: MethodCall, result: Result) { - if (call.method == "startUpgrading") { + result.success("") + } + else if (call.method == "startUpgrading") { Log.d("TAG", "startUpgrading") // 接收传入的参数 val arguments = call.arguments as? Map @@ -326,13 +331,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() } From 8c919c750b61eeba1564fb4d3ae19c8557b7a356 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Tue, 17 Jun 2025 11:21:24 +0800 Subject: [PATCH 8/8] add --- .../azure_speech/AzureAsrHelper.kt | 43 ++-- .../azure_speech/AzureSpeechPlugin.kt | 55 ++--- .../azure_speech/tools/RecordFile.kt | 215 +++++++++--------- .../yunqiinnovation/ble_service/BleService.kt | 4 - .../main/kotlin/com/example/ota/OtaPlugin.kt | 57 +++-- 5 files changed, 188 insertions(+), 186 deletions(-) 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 24e9f6e82..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 @@ -57,7 +57,7 @@ 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 @@ -101,7 +101,7 @@ class AzureAsrHelper(private val context: Context) { // 释放之前的资源 dispose() - + // 初始化音频管理器 audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL @@ -353,7 +353,7 @@ class AzureAsrHelper(private val context: Context) { // 开始连续识别 recognizer?.startContinuousRecognitionAsync() isContinuousRecognitionActive = true - + return true } catch (e: Exception) { stopAudioProcessing() @@ -584,17 +584,18 @@ class AzureAsrHelper(private val context: Context) { recordfile!!.closeFile(true) - recordfile!!.creatingFiles(filePath) + recordfile!!.creatingFiles(filePath) } - /** + + /** * 移动文件到新路径 */ fun moveFile(sourcePath: String, destPath: String): Boolean { Log.i(tag, "移动文件到新路径:") - recordfile!!.moveFile(sourcePath,destPath) - return true + recordfile!!.moveFile(sourcePath, destPath) + return true } @@ -605,27 +606,27 @@ class AzureAsrHelper(private val context: Context) { Log.i(tag, "重命名指定路径的音频文件:") - recordfile!!.renameFile(filePath,newName) - return true + recordfile!!.renameFile(filePath, newName) + return true } - /** + /** * 停止录音 */ fun pauseRecord() { Log.i(tag, "停止连续录音:") - recordfile!!.isPause=true + recordfile!!.isPause = true } /** * 关闭录音 */ - fun stopRecord(isSave:Boolean) { + fun stopRecord(isSave: Boolean) { Log.i(tag, "关闭录音:") - recordfile!!.isPause=false + recordfile!!.isPause = false recordfile!!.closeFile(isSave) } @@ -706,11 +707,11 @@ class AzureAsrHelper(private val context: Context) { */ private fun applyAudioEffects() { val sessionId = audioRecord?.audioSessionId ?: return - + try { // 设置音频模式为通信模式 this@AzureAsrHelper.audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION - + // 启用声学回音消除 if (AcousticEchoCanceler.isAvailable()) { echoCanceler = AcousticEchoCanceler.create(sessionId) @@ -719,7 +720,7 @@ class AzureAsrHelper(private val context: Context) { } else { Log.w(tag, "设备不支持声学回音消除") } - + // 启用噪声抑制 if (NoiseSuppressor.isAvailable()) { noiseSuppressor = NoiseSuppressor.create(sessionId) @@ -728,7 +729,7 @@ class AzureAsrHelper(private val context: Context) { } else { Log.w(tag, "设备不支持噪声抑制") } - + // 启用自动增益控制 if (AutomaticGainControl.isAvailable()) { automaticGainControl = AutomaticGainControl.create(sessionId) @@ -737,7 +738,7 @@ class AzureAsrHelper(private val context: Context) { } else { Log.w(tag, "设备不支持自动增益控制") } - + Log.i(tag, "回音消除和音频增强功能已全部启用") } catch (e: Exception) { Log.e(tag, "设置音频效果失败: ${e.message}") @@ -795,21 +796,21 @@ class AzureAsrHelper(private val context: Context) { 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 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 38346fd28..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 @@ -355,50 +354,54 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { } "renameFile" -> { - - val filePath = call.argument("filePath")?: "" - val newName = call.argument("newName")?: "" + + 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) { + 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")?: "" + + "moveFile" -> { + val sourcePath = call.argument("sourcePath") ?: "" + val destPath = call.argument("destPath") ?: "" try { - FileLogger.d(tag, "音频文件名称为: ${sourcePath}") // + FileLogger.d(tag, "音频文件名称为: ${sourcePath}") // - azureAsrHelper.moveFile(sourcePath,destPath) - result.success(true) - } catch (e: Exception) { + azureAsrHelper.moveFile(sourcePath, destPath) + result.success(true) + } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) } } - "enableRecord" -> { - val filePath = call.argument("filePath")?: "" + + "enableRecord" -> { + val filePath = call.argument("filePath") ?: "" try { - FileLogger.d(tag, "音频文件名称为: ${filePath}") // + FileLogger.d(tag, "音频文件名称为: ${filePath}") // - azureAsrHelper.enableRecord(filePath) - result.success(true) - } catch (e: Exception) { + azureAsrHelper.enableRecord(filePath) + result.success(true) + } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) } } - "pauseRecord" -> { + + "pauseRecord" -> { azureAsrHelper.pauseRecord() result.success(true) } + "stopRecord" -> { - val isSave = call.argument("isSave")?:false + val isSave = call.argument("isSave") ?: false try { - azureAsrHelper.stopRecord(isSave) - result.success(true) - } catch (e: Exception) { + azureAsrHelper.stopRecord(isSave) + result.success(true) + } catch (e: Exception) { result.error("ENABLERECORD_ERROR", e.message, null) } } 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 index e23c6cb34..7a32f21da 100644 --- 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 @@ -1,4 +1,5 @@ package com.yunqiinnovation.azure_speech.tools + import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile @@ -8,41 +9,41 @@ 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 +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 writeQueue = LinkedBlockingQueue() + private val isWriting = AtomicBoolean(false) + private var writeThread: Thread? = null - // 用于存储音频数据的缓冲区 - private val dataBuffer = mutableListOf() - private var totalBytesWritten = 0 + // 用于存储音频数据的缓冲区 + private val dataBuffer = mutableListOf() + private var totalBytesWritten = 0 - // 音频配置 - private val sampleRate = 16000 + // 音频配置 + private val sampleRate = 16000 - // 新增:用于存储最后成功保存的文件 - private var lastSavedFile: File? = null - private var lastFileTimestamp: String = "" - var isPause = false - var fileName = "" + // 新增:用于存储最后成功保存的文件 + private var lastSavedFile: File? = null + private var lastFileTimestamp: String = "" + var isPause = false + var fileName = "" - // 修改:添加 filePath 参数 + // 修改:添加 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() @@ -56,59 +57,59 @@ import java.util.concurrent.atomic.AtomicBoolean } - // 新增:启动写入线程 - 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}") + // 新增:启动写入线程 + 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() } + writeThread?.start() + } - /** - * 保存音频数据到 WAV 文件(异步) - */ - internal fun saveAudioDataToWav(buffer: ByteArray) { - if (fos == null || currentAudioFile == null) return - // 放入队列,由写线程写入 - writeQueue.offer(buffer.copyOf()) - totalBytesWritten += buffer.size - } + /** + * 保存音频数据到 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() - ) - } + // 更新文件头生成(修正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 新的文件名(不含扩展名) @@ -116,28 +117,28 @@ import java.util.concurrent.atomic.AtomicBoolean */ 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) @@ -156,7 +157,8 @@ import java.util.concurrent.atomic.AtomicBoolean false } } -/** + + /** * 移动文件到新路径 * @param sourcePath 原始文件完整路径 * @param destPath 目标文件完整路径 @@ -165,25 +167,25 @@ import java.util.concurrent.atomic.AtomicBoolean 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)) { @@ -213,39 +215,40 @@ import java.util.concurrent.atomic.AtomicBoolean false } } - internal fun closeFile( isSave:Boolean): Boolean { - try { - if (fos == null || currentAudioFile == null) { + + 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 - } - 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 + } 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 } + } catch (e: Exception) { + Log.e("tag", "更新WAV文件头失败: ${e.message}") return false + } finally { + fos = null + currentAudioFile = null } - } \ No newline at end of file + 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/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt index 3b34cf9cd..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,37 +268,36 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { streamSink = null } }) - + } override fun onMethodCall(call: MethodCall, result: Result) { - 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回调") + 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.w(TAG, "BLE回调注册失败") + Log.e(TAG, "BLE服务初始化失败") + } - } else { - Log.e(TAG, "BLE服务初始化失败") - - } - Log.i("TAG", "onAttachedToEngine") - result.success("") - } - else if (call.method == "startUpgrading") { + Log.i("TAG", "onAttachedToEngine") + result.success("") + } else if (call.method == "startUpgrading") { Log.d("TAG", "startUpgrading") // 接收传入的参数 val arguments = call.arguments as? Map @@ -331,16 +330,16 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { BleService.isEnterOta = false } - result.success("") + result.success("") } else if (call.method == "onOtaClose") { BleService.isEnterOta = false isDisconnect = false - otaManager?.bluetoothHelper?.stopScan() - + otaManager?.bluetoothHelper?.stopScan() + otaManager?.bluetoothHelper?.disconnectDevice(otaDevice) - - result.success("") + + result.success("") } else { result.notImplemented() }